From 0851ab0f5417847661fdef6484823a7550e898cf Mon Sep 17 00:00:00 2001 From: Manish Mohapatra Date: Sun, 28 Jan 2024 20:36:56 -0500 Subject: [PATCH 001/248] Implementing read_spikeglx_event() --- .../extractors/neoextractors/spikeglx.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/extractors/neoextractors/spikeglx.py b/src/spikeinterface/extractors/neoextractors/spikeglx.py index 6a6901b62e..b1b9a1a700 100644 --- a/src/spikeinterface/extractors/neoextractors/spikeglx.py +++ b/src/spikeinterface/extractors/neoextractors/spikeglx.py @@ -12,7 +12,7 @@ from spikeinterface.core.core_tools import define_function_from_class from spikeinterface.extractors.neuropixels_utils import get_neuropixels_sample_shifts -from .neobaseextractor import NeoBaseRecordingExtractor +from .neobaseextractor import NeoBaseRecordingExtractor, NeoBaseEventExtractor class SpikeGLXRecordingExtractor(NeoBaseRecordingExtractor): @@ -100,3 +100,45 @@ def map_to_neo_kwargs(cls, folder_path, load_sync_channel=False): read_spikeglx = define_function_from_class(source_class=SpikeGLXRecordingExtractor, name="read_spikeglx") + +class SpikeGLXEventExtractor(NeoBaseEventExtractor): + """ + Class for reading events saved on the event channel by SpikeGLX software. + + Parameters + ---------- + folder_path: str + + """ + + mode = "folder" + NeoRawIOClass = "SpikeGLXRawIO" + name = "spikeglx" + + def __init__(self, folder_path, block_index=None): + neo_kwargs = self.map_to_neo_kwargs(folder_path) + NeoBaseEventExtractor.__init__(self, block_index=block_index, **neo_kwargs) + + @classmethod + def map_to_neo_kwargs(cls, folder_path): + neo_kwargs = {"dirname": str(folder_path)} + return neo_kwargs + +def read_spikeglx_event(folder_path, block_index=None): + """ + Read SpikeGLX events + + Parameters + ---------- + folder_path: str or Path + Path to openephys folder + block_index: int, default: None + If there are several blocks (experiments), specify the block index you want to load. + + Returns + ------- + event: SpikeGLXEventExtractor + """ + + event = SpikeGLXEventExtractor(folder_path, block_index=block_index) + return event \ No newline at end of file From 1ca24c83d19214367109a6c30c1ec773530e9fda Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 01:37:45 +0000 Subject: [PATCH 002/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/extractors/neoextractors/spikeglx.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/extractors/neoextractors/spikeglx.py b/src/spikeinterface/extractors/neoextractors/spikeglx.py index b1b9a1a700..96c8b98d57 100644 --- a/src/spikeinterface/extractors/neoextractors/spikeglx.py +++ b/src/spikeinterface/extractors/neoextractors/spikeglx.py @@ -101,6 +101,7 @@ def map_to_neo_kwargs(cls, folder_path, load_sync_channel=False): read_spikeglx = define_function_from_class(source_class=SpikeGLXRecordingExtractor, name="read_spikeglx") + class SpikeGLXEventExtractor(NeoBaseEventExtractor): """ Class for reading events saved on the event channel by SpikeGLX software. @@ -124,6 +125,7 @@ def map_to_neo_kwargs(cls, folder_path): neo_kwargs = {"dirname": str(folder_path)} return neo_kwargs + def read_spikeglx_event(folder_path, block_index=None): """ Read SpikeGLX events @@ -141,4 +143,4 @@ def read_spikeglx_event(folder_path, block_index=None): """ event = SpikeGLXEventExtractor(folder_path, block_index=block_index) - return event \ No newline at end of file + return event From 57e1703cf3d0935061ab1dd89d9f1a00075ef8ff Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Tue, 27 Feb 2024 18:08:59 +0100 Subject: [PATCH 003/248] Initial discussion Charlie and Sam to make a Motion object --- src/spikeinterface/preprocessing/motion.py | 6 +- .../sortingcomponents/motion_estimation.py | 7 ++ .../sortingcomponents/motion_interpolation.py | 9 ++ .../sortingcomponents/motion_utils.py | 98 +++++++++++++++++++ .../tests/test_motiopn_utils.py | 3 + 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/spikeinterface/sortingcomponents/motion_utils.py create mode 100644 src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 1b182a6436..26ebe9ee38 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -293,6 +293,8 @@ def correct_motion( Optional output if `output_motion_info=True` """ + # TODO : Use motion object + # local import are important because "sortingcomponents" is not important by default from spikeinterface.sortingcomponents.peak_detection import detect_peaks, detect_peak_methods from spikeinterface.sortingcomponents.peak_selection import select_peaks @@ -401,7 +403,8 @@ def correct_motion( if folder is not None: (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") - + + # TODO save Motion np.save(folder / "temporal_bins.npy", temporal_bins) np.save(folder / "motion.npy", motion) if spatial_bins is not None: @@ -413,6 +416,7 @@ def correct_motion( run_times=run_times, peaks=peaks, peak_locations=peak_locations, + # TODO use Motion temporal_bins=temporal_bins, spatial_bins=spatial_bins, motion=motion, diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion_estimation.py index ef3a39bed1..4c65f8f44b 100644 --- a/src/spikeinterface/sortingcomponents/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion_estimation.py @@ -15,6 +15,9 @@ from .tools import make_multi_method_doc + + + def estimate_motion( recording, peaks, @@ -182,12 +185,16 @@ def estimate_motion( non_rigid_window_centers = spatial_bin_edges[:-1] + bin_um / 2 motion = motion @ non_rigid_windows + + # TODO : add Motion object here if output_extra_check: return motion, temporal_bins, non_rigid_window_centers, extra_check else: return motion, temporal_bins, non_rigid_window_centers + + class DecentralizedRegistration: """ Method developed by the Paninski's group from Columbia university: diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index f71ae0304d..05e9073c1b 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -22,9 +22,11 @@ def correct_motion_on_peaks( peaks, peak_locations, sampling_frequency, + # TODO use add Motion motion, temporal_bins, spatial_bins, + ### direction="y", ): """ @@ -74,9 +76,11 @@ def interpolate_motion_on_traces( traces, times, channel_locations, + # TODO : add Motion object here motion, temporal_bins, spatial_bins, + ### direction=1, channel_inds=None, spatial_interpolation_method="kriging", @@ -132,6 +136,8 @@ def interpolate_motion_on_traces( # inperpolation kernel will be the same per temporal bin for bin_ind in np.unique(bin_inds): + # TODO use # TODO : add Motion.get_displacement_at_time_and_depth() instead + # Step 1 : channel motion if spatial_bins.shape[0] == 1: # rigid motion : same motion for all channels @@ -364,9 +370,12 @@ def __init__( self, parent_recording_segment, channel_locations, + # TODO : add Motion object here motion, temporal_bins, spatial_bins, + ### + direction, spatial_interpolation_method, spatial_interpolation_kwargs, diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py new file mode 100644 index 0000000000..87a7350e7d --- /dev/null +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -0,0 +1,98 @@ +import numpy as np + + + +class Motion: + """ + Motion of the tissue relative the probe. + + Parameters + ---------- + + displacement: numpy array 2d or list of + Motion estimate in um. + Shape (temporal bins, spatial bins) + motion.shape[0] = temporal_bins.shape[0] + motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) + temporal_bins_s: numpy.array 1d or list of + temporal bins (bin center) + spatial_bins_um: numpy.array 1d + Windows center. + spatial_bins_um.shape[0] == displacement.shape[1] + If rigid then spatial_bins_um.shape[0] == 1 + + """ + def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y"): + if isinstance(displacement, np.ndarray): + self.displacement = [displacement] + assert isinstance(temporal_bins_s, np.ndarray) + self.temporal_bins_s = [temporal_bins_s] + else: + assert isinstance(displacement, (list, tuple)) + self.displacement = displacement + self.temporal_bins_s = temporal_bins_s + + assert isinstance(spatial_bins_um, np.ndarray) + self.spatial_bins_um = spatial_bins_um + + self.num_segments = len(self.displacement) + self.interpolator = None + + self.direction = direction + self.dim = ["x", "y", "z"].index(direction) + + def make_interpolators(self): + from scipy.interpolate import RegularGridInterpolator2D + self.interpolator = [ + RegularGridInterpolator2D((self.spatial_bins_um, self.temporal_bins_s[j]), self.displacement[j]) + for j in range(self.num_segments) + ] + self.temporal_bounds = [(t[0], t[-1]) for t in self.temporal_bins_s] + self.spatial_bounds = (self.spatial_bins_um.min(), self.spatial_bins_um.max()) + + def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_index=None): + """ + + + Parameters + ---------- + times_s: np.array + + + locations_um: np.array + + segment_index: + + """ + if self.interpolator is None: + self.make_interpolators() + + if segment_index is None: + if self.num_segments == 1: + segment_index = 0 + else: + raise ValueError("Several segment need segment_index=") + + if locations_um.ndim == 1: + locations_um = locations_um + else: + locations_um = locations_um[:, self.dim] + times_s = np.clip(times_s, *self.temporal_bounds[segment_index]) + positions = np.clip(positions, *self.spatial_bounds) + points = np.stack([positions, times_s], axis=1) + + return self.interpolator[segment_index](points) + + def to_dict(self): + return dict( + displacement=self.displacement, + temporal_bins_s=self.temporal_bins_s, + spatial_bins_um=self.spatial_bins_um, + ) + + def save(self): + pass + + @classmethod + def load(cls): + pass diff --git a/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py new file mode 100644 index 0000000000..9efd26a3d5 --- /dev/null +++ b/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py @@ -0,0 +1,3 @@ + + +# TODO Motion Make some test \ No newline at end of file From ee25d9604d5b5c77f6e547774d1bffe2f3d7b2ff Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Wed, 17 Apr 2024 15:24:40 +0200 Subject: [PATCH 004/248] Add sinaps research platform recording --- .../extractors/extractorlist.py | 2 + .../extractors/sinapsrecordingextractor.py | 97 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/spikeinterface/extractors/sinapsrecordingextractor.py diff --git a/src/spikeinterface/extractors/extractorlist.py b/src/spikeinterface/extractors/extractorlist.py index 228f7085bd..4957202c56 100644 --- a/src/spikeinterface/extractors/extractorlist.py +++ b/src/spikeinterface/extractors/extractorlist.py @@ -45,6 +45,7 @@ from .herdingspikesextractors import HerdingspikesSortingExtractor, read_herdingspikes from .mdaextractors import MdaRecordingExtractor, MdaSortingExtractor, read_mda_recording, read_mda_sorting from .phykilosortextractors import PhySortingExtractor, KiloSortSortingExtractor, read_phy, read_kilosort +from .sinapsrecordingextractor import SinapsResearchPlatformRecordingExtractor, read_sinaps_research_platform # sorting in relation with simulator from .shybridextractors import ( @@ -77,6 +78,7 @@ CompressedBinaryIblExtractor, IblRecordingExtractor, MCSH5RecordingExtractor, + SinapsResearchPlatformRecordingExtractor, ] recording_extractor_full_list += neo_recording_extractors_list diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py new file mode 100644 index 0000000000..fd42401b65 --- /dev/null +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -0,0 +1,97 @@ +from pathlib import Path +import numpy as np + +from ..core import BinaryRecordingExtractor, ChannelSliceRecording +from ..core.core_tools import define_function_from_class + + +class SinapsResearchPlatformRecordingExtractor(ChannelSliceRecording): + extractor_name = "SinapsResearchPlatform" + mode = "file" + name = "sinaps_research_platform" + + def __init__(self, file_path, stream_name="filt"): + from ..preprocessing import UnsignedToSignedRecording + + file_path = Path(file_path) + meta_file = file_path.parent / f"metadata_{file_path.stem}.txt" + meta = parse_sinaps_meta(meta_file) + + num_aux_channels = meta["nbHWAux"] + meta["numberUserAUX"] + num_total_channels = 2 * meta["nbElectrodes"] + num_aux_channels + num_electrodes = meta["nbElectrodes"] + sampling_frequency = meta["samplingFreq"] + + channel_locations = meta["electrodePhysicalPosition"] + num_shanks = meta["nbShanks"] + num_electrodes_per_shank = meta["nbElectrodesShank"] + num_bits = int(np.log2(meta["nbADCLevels"])) + + channel_groups = [] + for i in range(num_shanks): + channel_groups.extend([i] * num_electrodes_per_shank) + + gain_ephys = meta["voltageConverter"] + gain_aux = meta["voltageAUXConverter"] + + recording = BinaryRecordingExtractor( + file_path, sampling_frequency, dtype="uint16", num_channels=num_total_channels + ) + recording = UnsignedToSignedRecording(recording, bit_depth=num_bits) + + if stream_name == "raw": + channel_slice = recording.channel_ids[:num_electrodes] + renamed_channels = np.arange(num_electrodes) + locations = channel_locations + groups = channel_groups + gain = gain_ephys + elif stream_name == "filt": + channel_slice = recording.channel_ids[num_electrodes : 2 * num_electrodes] + renamed_channels = np.arange(num_electrodes) + locations = channel_locations + groups = channel_groups + gain = gain_ephys + elif stream_name == "aux": + channel_slice = recording.channel_ids[2 * num_electrodes :] + hw_chans = meta["hwAUXChannelName"][1:-1].split(",") + user_chans = meta["userAuxName"][1:-1].split(",") + renamed_channels = hw_chans + user_chans + locations = None + groups = None + gain = gain_aux + else: + raise ValueError("stream_name must be 'raw', 'filt', or 'aux'") + + ChannelSliceRecording.__init__(self, recording, channel_ids=channel_slice, renamed_channel_ids=renamed_channels) + if locations is not None: + self.set_channel_locations(locations) + if groups is not None: + self.set_channel_groups(groups) + self.set_channel_gains(gain) + + +read_sinaps_research_platform = define_function_from_class( + source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" +) + + +def parse_sinaps_meta(meta_file): + meta_dict = {} + with open(meta_file) as f: + lines = f.readlines() + for l in lines: + if "**" in l or "=" not in l: + continue + else: + key, val = l.split("=") + val = val.replace("\n", "") + try: + val = int(val) + except: + pass + try: + val = eval(val) + except: + pass + meta_dict[key] = val + return meta_dict From ddbce702355c5a4a66969a64709ddf5fd7cc7b73 Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 22 Apr 2024 15:09:29 +0100 Subject: [PATCH 005/248] Add an H5 extractor for sinaps research platform --- .../extractors/extractorlist.py | 1 + .../extractors/sinapsrecordingh5extractor.py | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/spikeinterface/extractors/sinapsrecordingh5extractor.py diff --git a/src/spikeinterface/extractors/extractorlist.py b/src/spikeinterface/extractors/extractorlist.py index 4957202c56..b226a2d838 100644 --- a/src/spikeinterface/extractors/extractorlist.py +++ b/src/spikeinterface/extractors/extractorlist.py @@ -46,6 +46,7 @@ from .mdaextractors import MdaRecordingExtractor, MdaSortingExtractor, read_mda_recording, read_mda_sorting from .phykilosortextractors import PhySortingExtractor, KiloSortSortingExtractor, read_phy, read_kilosort from .sinapsrecordingextractor import SinapsResearchPlatformRecordingExtractor, read_sinaps_research_platform +from .sinapsrecordingh5extractor import SinapsResearchPlatformH5RecordingExtractor, read_sinaps_research_platform_h5 # sorting in relation with simulator from .shybridextractors import ( diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py new file mode 100644 index 0000000000..e1dbedebbe --- /dev/null +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -0,0 +1,112 @@ +from pathlib import Path +import numpy as np + +from ..core.core_tools import define_function_from_class +from ..core import BaseRecording, BaseRecordingSegment + +try: + import h5py + + HAVE_MCSH5 = True +except ImportError: + HAVE_MCSH5 = False + +class SinapsResearchPlatformH5RecordingExtractor(BaseRecording): + extractor_name = "SinapsResearchPlatformH5" + mode = "file" + name = "sinaps_research_platform_h5" + + def __init__(self, file_path): + + assert self.installed, self.installation_mesg + self._file_path = file_path + + mcs_info = openSiNAPSFile(self._file_path) + self._rf = mcs_info["filehandle"] + + BaseRecording.__init__( + self, + sampling_frequency=mcs_info["sampling_frequency"], + channel_ids=mcs_info["channel_ids"], + dtype=mcs_info["dtype"], + ) + + self.extra_requirements.append("h5py") + + recording_segment = SiNAPSRecordingSegment( + self._rf, mcs_info["num_frames"], sampling_frequency=mcs_info["sampling_frequency"] + ) + self.add_recording_segment(recording_segment) + + # set gain + self.set_channel_gains(mcs_info["gain"]) + self.set_channel_offsets(mcs_info["offset"]) + + # set other properties + + self._kwargs = {"file_path": str(Path(file_path).absolute())} + + def __del__(self): + self._rf.close() + +class SiNAPSRecordingSegment(BaseRecordingSegment): + def __init__(self, rf, num_frames, sampling_frequency): + BaseRecordingSegment.__init__(self, sampling_frequency=sampling_frequency) + self._rf = rf + self._num_samples = int(num_frames) + self._stream = self._rf.require_group('RealTimeProcessedData') + + def get_num_samples(self): + return self._num_samples + + def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): + if isinstance(channel_indices, slice): + traces = self._stream.get('FilteredData')[channel_indices, start_frame:end_frame].T + else: + # channel_indices is np.ndarray + if np.array(channel_indices).size > 1 and np.any(np.diff(channel_indices) < 0): + # get around h5py constraint that it does not allow datasets + # to be indexed out of order + sorted_channel_indices = np.sort(channel_indices) + resorted_indices = np.array([list(sorted_channel_indices).index(ch) for ch in channel_indices]) + recordings = self._stream.get('FilteredData')[sorted_channel_indices, start_frame:end_frame].T + traces = recordings[:, resorted_indices] + else: + traces = self._stream.get('FilteredData')[channel_indices, start_frame:end_frame].T + return traces + + +read_sinaps_research_platform_h5 = define_function_from_class( + source_class=SinapsResearchPlatformH5RecordingExtractor, name="read_sinaps_research_platform_h5" +) + +def openSiNAPSFile(filename): + """Open an SiNAPS hdf5 file, read and return the recording info.""" + rf = h5py.File(filename, "r") + + stream = rf.require_group('RealTimeProcessedData') + data = stream.get("FilteredData") + dtype = data.dtype + + parameters = rf.require_group('Parameters') + gain = parameters.get('VoltageConverter')[0] + offset = -2047 # the input data is in ADC levels, represented with 12 bits (values from 0 to 4095). + # To convert the data to uV, you need to first subtract the OFFSET=2047 (half of the represented range) + # and multiply by the VoltageConverter + + nRecCh, nFrames = data.shape + + samplingRate = parameters.get('SamplingFrequency')[0] + + mcs_info = { + "filehandle": rf, + "num_frames": nFrames, + "sampling_frequency": samplingRate, + "num_channels": nRecCh, + "channel_ids": np.arange(nRecCh), + "gain": gain, + "offset": offset, + "dtype": dtype, + } + + return mcs_info From 6d0cd8599ad8251528f6acdd5daaea09c2152421 Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 22 Apr 2024 16:50:30 +0100 Subject: [PATCH 006/248] Fix OFFSET, variable naming and importing h5py --- .../extractors/sinapsrecordingh5extractor.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py index e1dbedebbe..2923011901 100644 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -4,13 +4,6 @@ from ..core.core_tools import define_function_from_class from ..core import BaseRecording, BaseRecordingSegment -try: - import h5py - - HAVE_MCSH5 = True -except ImportError: - HAVE_MCSH5 = False - class SinapsResearchPlatformH5RecordingExtractor(BaseRecording): extractor_name = "SinapsResearchPlatformH5" mode = "file" @@ -18,29 +11,35 @@ class SinapsResearchPlatformH5RecordingExtractor(BaseRecording): def __init__(self, file_path): + try: + import h5py + self.installed = True + except ImportError: + self.installed = False + assert self.installed, self.installation_mesg self._file_path = file_path - mcs_info = openSiNAPSFile(self._file_path) - self._rf = mcs_info["filehandle"] + sinaps_info = openSiNAPSFile(self._file_path) + self._rf = sinaps_info["filehandle"] BaseRecording.__init__( self, - sampling_frequency=mcs_info["sampling_frequency"], - channel_ids=mcs_info["channel_ids"], - dtype=mcs_info["dtype"], + sampling_frequency=sinaps_info["sampling_frequency"], + channel_ids=sinaps_info["channel_ids"], + dtype=sinaps_info["dtype"], ) self.extra_requirements.append("h5py") recording_segment = SiNAPSRecordingSegment( - self._rf, mcs_info["num_frames"], sampling_frequency=mcs_info["sampling_frequency"] + self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"] ) self.add_recording_segment(recording_segment) # set gain - self.set_channel_gains(mcs_info["gain"]) - self.set_channel_offsets(mcs_info["offset"]) + self.set_channel_gains(sinaps_info["gain"]) + self.set_channel_offsets(sinaps_info["offset"]) # set other properties @@ -82,6 +81,9 @@ def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): def openSiNAPSFile(filename): """Open an SiNAPS hdf5 file, read and return the recording info.""" + + import h5py + rf = h5py.File(filename, "r") stream = rf.require_group('RealTimeProcessedData') @@ -90,15 +92,13 @@ def openSiNAPSFile(filename): parameters = rf.require_group('Parameters') gain = parameters.get('VoltageConverter')[0] - offset = -2047 # the input data is in ADC levels, represented with 12 bits (values from 0 to 4095). - # To convert the data to uV, you need to first subtract the OFFSET=2047 (half of the represented range) - # and multiply by the VoltageConverter + offset = -2048 * gain nRecCh, nFrames = data.shape samplingRate = parameters.get('SamplingFrequency')[0] - mcs_info = { + sinaps_info = { "filehandle": rf, "num_frames": nFrames, "sampling_frequency": samplingRate, @@ -109,4 +109,4 @@ def openSiNAPSFile(filename): "dtype": dtype, } - return mcs_info + return sinaps_info From c3dbd28d861674e34257ff9d95886a429a49b10c Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 22 Apr 2024 16:53:40 +0100 Subject: [PATCH 007/248] Add 0 offset to support rescaling --- src/spikeinterface/extractors/sinapsrecordingextractor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py index fd42401b65..05411a8f06 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -68,6 +68,7 @@ def __init__(self, file_path, stream_name="filt"): if groups is not None: self.set_channel_groups(groups) self.set_channel_gains(gain) + self.set_channel_offsets(0) read_sinaps_research_platform = define_function_from_class( From 49a346ab567435977a63df725026a13cb558ebc1 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 30 Apr 2024 14:16:47 +0200 Subject: [PATCH 008/248] Add option to set recording --- src/spikeinterface/core/sortinganalyzer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 85ea9b8438..dd5695860c 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -572,6 +572,11 @@ def load_from_zarr(cls, folder, recording=None): return sorting_analyzer + def set_recording(self, recording): + if self._recording is not None: + raise ValueError("Recording is already set") + self._recording = recording + def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingAnalyzer": """ Internal used by both save_as(), copy() and select_units() which are more or less the same. From dae78136620a9fd4ce3f2f0ce9dfd931dcb3677a Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 14 May 2024 15:32:47 +0200 Subject: [PATCH 009/248] Add recording attributes check, docs, and warning --- src/spikeinterface/core/recording_tools.py | 32 ++++++++++++++ src/spikeinterface/core/sortinganalyzer.py | 30 +++++++++++-- .../core/tests/test_sortinganalyzer.py | 44 +++++++++++++------ 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 2f228432b0..6b3de3ec98 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -910,3 +910,35 @@ def get_rec_attributes(recording): dtype=recording.get_dtype(), ) return rec_attributes + + +def check_recording_attributes_match(recording1, recording2_attributes, skip_properties=True): + """ + Check if two recordings have the same attributes + + Parameters + ---------- + recording1 : BaseRecording + The first recording object + recording2 : BaseRecording + The second recording object + + Returns + ------- + bool + True if the recordings have the same attributes + """ + recording1_attributes = get_rec_attributes(recording1) + recording1_attributes["probegroup"] = recording1.get_probegroup() + recording2_attributes = deepcopy(recording2_attributes) + if skip_properties: + recording1_attributes.pop("properties") + recording2_attributes.pop("properties") + return ( + np.array_equal(recording1_attributes["channel_ids"], recording2_attributes["channel_ids"]) + and recording1_attributes["sampling_frequency"] == recording2_attributes["sampling_frequency"] + and recording1_attributes["num_channels"] == recording2_attributes["num_channels"] + and recording1_attributes["num_samples"] == recording2_attributes["num_samples"] + and recording1_attributes["is_filtered"] == recording2_attributes["is_filtered"] + and recording1_attributes["dtype"] == recording2_attributes["dtype"] + ) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 9dfbabd729..be1c1d1fec 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -20,7 +20,7 @@ from .basesorting import BaseSorting from .base import load_extractor -from .recording_tools import check_probe_do_not_overlap, get_rec_attributes +from .recording_tools import check_probe_do_not_overlap, get_rec_attributes, check_recording_attributes_match from .core_tools import check_json, retrieve_importing_provenance from .job_tools import split_job_kwargs from .numpyextractors import NumpySorting @@ -588,9 +588,33 @@ def load_from_zarr(cls, folder, recording=None): return sorting_analyzer - def set_recording(self, recording): + def set_temporary_recording(self, recording: BaseRecording): + """ + Sets a temporary recording object. This function can be useful to temporarily set + a "cached" recording object that is not saved in the SortingAnalyzer object to speed up + computations. Upon reloading, the SortingAnalyzer object will try to reload the recording + from the original location in a lazy way. + + + Parameters + ---------- + recording : BaseRecording + The recording object to set as temporary recording. + + Raises + ------ + ValueError + _description_ + """ + # check that recording is compatible + assert check_recording_attributes_match( + recording, self.rec_attributes, skip_properties=True + ), "Recording attributes do not match." + assert np.array_equal( + recording.get_channel_locations(), self.get_channel_locations() + ), "Recording channel locations do not match." if self._recording is not None: - raise ValueError("Recording is already set") + warnings.warn("SortingAnalyzer recording is already set. This will overwrite the current recording.") self._recording = recording def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingAnalyzer": diff --git a/src/spikeinterface/core/tests/test_sortinganalyzer.py b/src/spikeinterface/core/tests/test_sortinganalyzer.py index 66b670d956..bdce31c5b2 100644 --- a/src/spikeinterface/core/tests/test_sortinganalyzer.py +++ b/src/spikeinterface/core/tests/test_sortinganalyzer.py @@ -15,7 +15,7 @@ import numpy as np -def get_dataset(): +def _get_dataset(): recording, sorting = generate_ground_truth_recording( durations=[30.0], sampling_frequency=16000.0, @@ -28,8 +28,13 @@ def get_dataset(): return recording, sorting -def test_SortingAnalyzer_memory(tmp_path): - recording, sorting = get_dataset() +@pytest.fixture(scope="module") +def get_dataset(): + return _get_dataset() + + +def test_SortingAnalyzer_memory(tmp_path, get_dataset): + recording, sorting = get_dataset sorting_analyzer = create_sorting_analyzer(sorting, recording, format="memory", sparse=False, sparsity=None) _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) @@ -48,8 +53,8 @@ def test_SortingAnalyzer_memory(tmp_path): assert not sorting_analyzer.return_scaled -def test_SortingAnalyzer_binary_folder(tmp_path): - recording, sorting = get_dataset() +def test_SortingAnalyzer_binary_folder(tmp_path, get_dataset): + recording, sorting = get_dataset folder = tmp_path / "test_SortingAnalyzer_binary_folder" if folder.exists(): @@ -78,8 +83,8 @@ def test_SortingAnalyzer_binary_folder(tmp_path): _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) -def test_SortingAnalyzer_zarr(tmp_path): - recording, sorting = get_dataset() +def test_SortingAnalyzer_zarr(tmp_path, get_dataset): + recording, sorting = get_dataset folder = tmp_path / "test_SortingAnalyzer_zarr.zarr" if folder.exists(): @@ -99,10 +104,21 @@ def test_SortingAnalyzer_zarr(tmp_path): ) -def _check_sorting_analyzers(sorting_analyzer, original_sorting, cache_folder): +def test_SortingAnalyzer_tmp_recording(get_dataset): + recording, sorting = get_dataset + recording_cached = recording.save(mode="memory") - print() - print(sorting_analyzer) + sorting_analyzer = create_sorting_analyzer(sorting, recording, format="memory", sparse=False, sparsity=None) + sorting_analyzer.set_temporary_recording(recording_cached) + + recording_sliced = recording.channel_slice(recording.channel_ids[:-1]) + + # wrong channels + with pytest.raises(AssertionError): + sorting_analyzer.set_temporary_recording(recording_sliced) + + +def _check_sorting_analyzers(sorting_analyzer, original_sorting, cache_folder): register_result_extension(DummyAnalyzerExtension) @@ -257,8 +273,10 @@ def test_extension(): if __name__ == "__main__": tmp_path = Path("test_SortingAnalyzer") - test_SortingAnalyzer_memory(tmp_path) - test_SortingAnalyzer_binary_folder(tmp_path) - test_SortingAnalyzer_zarr(tmp_path) + dataset = _get_dataset() + test_SortingAnalyzer_memory(tmp_path, dataset) + test_SortingAnalyzer_binary_folder(tmp_path, dataset) + test_SortingAnalyzer_zarr(tmp_path, dataset) + test_SortingAnalyzer_tmp_recording(dataset) test_extension() test_extension_params() From b036cf340174f5525923430f43de6fe01615b458 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 14 May 2024 16:23:39 +0200 Subject: [PATCH 010/248] thank you Zach! --- src/spikeinterface/core/recording_tools.py | 7 +++---- src/spikeinterface/core/sortinganalyzer.py | 7 +------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 6b3de3ec98..0f9fa028f3 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -912,7 +912,7 @@ def get_rec_attributes(recording): return rec_attributes -def check_recording_attributes_match(recording1, recording2_attributes, skip_properties=True): +def check_recording_attributes_match(recording1, recording2_attributes, skip_properties=True) -> bool: """ Check if two recordings have the same attributes @@ -920,8 +920,8 @@ def check_recording_attributes_match(recording1, recording2_attributes, skip_pro ---------- recording1 : BaseRecording The first recording object - recording2 : BaseRecording - The second recording object + recording2_attributes : dict + The recording attributes to test against Returns ------- @@ -929,7 +929,6 @@ def check_recording_attributes_match(recording1, recording2_attributes, skip_pro True if the recordings have the same attributes """ recording1_attributes = get_rec_attributes(recording1) - recording1_attributes["probegroup"] = recording1.get_probegroup() recording2_attributes = deepcopy(recording2_attributes) if skip_properties: recording1_attributes.pop("properties") diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index be1c1d1fec..a122933ecc 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -600,11 +600,6 @@ def set_temporary_recording(self, recording: BaseRecording): ---------- recording : BaseRecording The recording object to set as temporary recording. - - Raises - ------ - ValueError - _description_ """ # check that recording is compatible assert check_recording_attributes_match( @@ -614,7 +609,7 @@ def set_temporary_recording(self, recording: BaseRecording): recording.get_channel_locations(), self.get_channel_locations() ), "Recording channel locations do not match." if self._recording is not None: - warnings.warn("SortingAnalyzer recording is already set. This will overwrite the current recording.") + warnings.warn("SortingAnalyzer recording is already set. " "The current recording is temporarily replaced.") self._recording = recording def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingAnalyzer": From 294fa26fcb2bf3dac4d84f5ffc0040fa413c6103 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 May 2024 10:24:31 -0600 Subject: [PATCH 011/248] remove unused imports ensure integer --- src/spikeinterface/core/recording_tools.py | 2 +- src/spikeinterface/postprocessing/amplitude_scalings.py | 2 +- src/spikeinterface/sortingcomponents/matching/naive.py | 2 +- src/spikeinterface/sortingcomponents/peak_detection.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 2f228432b0..3bcb91cc23 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -702,7 +702,7 @@ def get_chunk_with_margin( case zero padding is used, in the second case np.pad is called with mod="reflect". """ - length = rec_segment.get_num_samples() + length = int(rec_segment.get_num_samples()) if channel_indices is None: channel_indices = slice(None) diff --git a/src/spikeinterface/postprocessing/amplitude_scalings.py b/src/spikeinterface/postprocessing/amplitude_scalings.py index e2dcdd8e5a..57a97be16e 100644 --- a/src/spikeinterface/postprocessing/amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/amplitude_scalings.py @@ -2,7 +2,7 @@ import numpy as np -from spikeinterface.core import ChannelSparsity, get_chunk_with_margin +from spikeinterface.core import ChannelSparsity from spikeinterface.core.job_tools import ChunkRecordingExecutor, _shared_job_kwargs_doc, ensure_n_jobs, fix_job_kwargs from spikeinterface.core.template_tools import get_template_extremum_channel diff --git a/src/spikeinterface/sortingcomponents/matching/naive.py b/src/spikeinterface/sortingcomponents/matching/naive.py index c172e90fd8..0dc71d789b 100644 --- a/src/spikeinterface/sortingcomponents/matching/naive.py +++ b/src/spikeinterface/sortingcomponents/matching/naive.py @@ -4,7 +4,7 @@ import numpy as np -from spikeinterface.core import get_noise_levels, get_channel_distances, get_chunk_with_margin, get_random_data_chunks +from spikeinterface.core import get_noise_levels, get_channel_distances, get_random_data_chunks from spikeinterface.sortingcomponents.peak_detection import DetectPeakLocallyExclusive from spikeinterface.core.template import Templates diff --git a/src/spikeinterface/sortingcomponents/peak_detection.py b/src/spikeinterface/sortingcomponents/peak_detection.py index 508a033c41..a67f2ef674 100644 --- a/src/spikeinterface/sortingcomponents/peak_detection.py +++ b/src/spikeinterface/sortingcomponents/peak_detection.py @@ -26,7 +26,6 @@ ) from spikeinterface.postprocessing.unit_localization import get_convolution_weights -from ..core import get_chunk_with_margin from .tools import make_multi_method_doc From 1df89594865caae348b300797c8b1e6c27236407 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 May 2024 10:43:34 -0600 Subject: [PATCH 012/248] segment sum --- src/spikeinterface/core/segmentutils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/segmentutils.py b/src/spikeinterface/core/segmentutils.py index c3881cc1f8..75fd874f78 100644 --- a/src/spikeinterface/core/segmentutils.py +++ b/src/spikeinterface/core/segmentutils.py @@ -181,8 +181,8 @@ def get_traces(self, start_frame, end_frame, channel_indices): if i0 == i1: #  one segment - rec_seg = self.parent_segments[i0] - seg_start = self.cumsum_length[i0] + rec_seg = int(self.parent_segments[i0]) + seg_start = int(self.cumsum_length[i0]) # Cum sum length is a numpy array traces = rec_seg.get_traces(start_frame - seg_start, end_frame - seg_start, channel_indices) else: #  several segments @@ -192,8 +192,8 @@ def get_traces(self, start_frame, end_frame, channel_indices): # limit case continue - rec_seg = self.parent_segments[i] - seg_start = self.cumsum_length[i] + rec_seg = int(self.parent_segments[i]) + seg_start = int(self.cumsum_length[i]) if i == i0: # first traces_chunk = rec_seg.get_traces(start_frame - seg_start, None, channel_indices) From 378c6c1c0f3dbfe0b683fad1db549eed8b66deda Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 May 2024 10:45:24 -0600 Subject: [PATCH 013/248] segment sum --- src/spikeinterface/core/segmentutils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/core/segmentutils.py b/src/spikeinterface/core/segmentutils.py index 75fd874f78..9bc53c11f1 100644 --- a/src/spikeinterface/core/segmentutils.py +++ b/src/spikeinterface/core/segmentutils.py @@ -156,7 +156,8 @@ def __init__(self, parent_segments, sampling_frequency, ignore_times=True): BaseRecordingSegment.__init__(self, **time_kwargs) self.parent_segments = parent_segments self.all_length = [rec_seg.get_num_samples() for rec_seg in self.parent_segments] - self.cumsum_length = np.cumsum([0] + self.all_length) + cumulative_sum_numpy = np.cumsum([0] + self.all_length) # We need to cast to int for overflow concerns + self.cumsum_length = [int(samples_till_segment for samples_till_segment in cumulative_sum_numpy)] self.total_length = int(np.sum(self.all_length)) def get_num_samples(self): @@ -181,8 +182,8 @@ def get_traces(self, start_frame, end_frame, channel_indices): if i0 == i1: #  one segment - rec_seg = int(self.parent_segments[i0]) - seg_start = int(self.cumsum_length[i0]) # Cum sum length is a numpy array + rec_seg = self.parent_segments[i0] + seg_start = self.cumsum_length[i0] traces = rec_seg.get_traces(start_frame - seg_start, end_frame - seg_start, channel_indices) else: #  several segments @@ -192,8 +193,8 @@ def get_traces(self, start_frame, end_frame, channel_indices): # limit case continue - rec_seg = int(self.parent_segments[i]) - seg_start = int(self.cumsum_length[i]) + rec_seg = self.parent_segments[i] + seg_start = self.cumsum_length[i] if i == i0: # first traces_chunk = rec_seg.get_traces(start_frame - seg_start, None, channel_indices) From af29f412b2aef688a3a4b799f0d963620709391a Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 15 May 2024 11:25:27 -0600 Subject: [PATCH 014/248] fix at the root --- src/spikeinterface/core/segmentutils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/spikeinterface/core/segmentutils.py b/src/spikeinterface/core/segmentutils.py index 9bc53c11f1..959b7f8c43 100644 --- a/src/spikeinterface/core/segmentutils.py +++ b/src/spikeinterface/core/segmentutils.py @@ -156,8 +156,7 @@ def __init__(self, parent_segments, sampling_frequency, ignore_times=True): BaseRecordingSegment.__init__(self, **time_kwargs) self.parent_segments = parent_segments self.all_length = [rec_seg.get_num_samples() for rec_seg in self.parent_segments] - cumulative_sum_numpy = np.cumsum([0] + self.all_length) # We need to cast to int for overflow concerns - self.cumsum_length = [int(samples_till_segment for samples_till_segment in cumulative_sum_numpy)] + self.cumsum_length = [0] + [sum(self.all_length[: i + 1]) for i in range(len(self.all_length))] self.total_length = int(np.sum(self.all_length)) def get_num_samples(self): From 0866e3da057ba24bd6b852e501680c608a3c3ccd Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Mon, 20 May 2024 09:48:35 +0200 Subject: [PATCH 015/248] wip Motion object --- .../sortingcomponents/motion_estimation.py | 37 +++++++----------- .../sortingcomponents/motion_utils.py | 34 +++++++++++++---- .../sortingcomponents/tests/common.py | 2 + .../tests/test_motion_estimation.py | 30 +++++++-------- .../tests/test_motion_utils.py | 38 +++++++++++++++++++ .../tests/test_motiopn_utils.py | 3 -- 6 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 src/spikeinterface/sortingcomponents/tests/test_motion_utils.py delete mode 100644 src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion_estimation.py index 4c65f8f44b..6925c7aede 100644 --- a/src/spikeinterface/sortingcomponents/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion_estimation.py @@ -13,7 +13,7 @@ HAVE_TORCH = False from .tools import make_multi_method_doc - +from .motion_utils import Motion @@ -109,19 +109,8 @@ def estimate_motion( Returns ------- - motion: numpy array 2d - Motion estimate in um. - Shape (temporal bins, spatial bins) - motion.shape[0] = temporal_bins.shape[0] - motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) - If upsample_to_histogram_bin, motion.shape[1] corresponds to spatial - bins given by bin_um. - temporal_bins: numpy.array 1d - temporal bins (bin center) - spatial_bins: numpy.array 1d - Windows center. - spatial_bins.shape[0] == motion.shape[1] - If rigid then spatial_bins.shape[0] == 1 + motion: Motion object + The motion object. extra_check: dict Optional output if `output_extra_check=True` This dict contain histogram, pairwise_displacement usefull for ploting. @@ -152,7 +141,7 @@ def estimate_motion( # run method method_class = estimate_motion_methods[method] - motion, temporal_bins = method_class.run( + motion_array, temporal_bins = method_class.run( recording, peaks, peak_locations, @@ -168,29 +157,31 @@ def estimate_motion( ) # replace nan by zeros - motion[np.isnan(motion)] = 0 + motion_array[np.isnan(motion_array)] = 0 if post_clean: - motion = clean_motion_vector( - motion, temporal_bins, bin_duration_s, speed_threshold=speed_threshold, sigma_smooth_s=sigma_smooth_s + motion_array = clean_motion_vector( + motion_array, temporal_bins, bin_duration_s, speed_threshold=speed_threshold, sigma_smooth_s=sigma_smooth_s ) if upsample_to_histogram_bin is None: upsample_to_histogram_bin = not rigid if upsample_to_histogram_bin: - extra_check["motion"] = motion + extra_check["motion_array"] = motion_array extra_check["non_rigid_window_centers"] = non_rigid_window_centers non_rigid_windows = np.array(non_rigid_windows) non_rigid_windows /= non_rigid_windows.sum(axis=0, keepdims=True) non_rigid_window_centers = spatial_bin_edges[:-1] + bin_um / 2 - motion = motion @ non_rigid_windows + motion_array = motion_array @ non_rigid_windows + + # TODO handle multi segment + motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) - # TODO : add Motion object here if output_extra_check: - return motion, temporal_bins, non_rigid_window_centers, extra_check + return motion, extra_check else: - return motion, temporal_bins, non_rigid_window_centers + return motion diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 87a7350e7d..448aefdb9e 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -11,9 +11,11 @@ class Motion: displacement: numpy array 2d or list of Motion estimate in um. - Shape (temporal bins, spatial bins) - motion.shape[0] = temporal_bins.shape[0] - motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) + List is the number of segment. + For each semgent : + * shape (temporal bins, spatial bins) + * motion.shape[0] = temporal_bins.shape[0] + * motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) temporal_bins_s: numpy.array 1d or list of temporal bins (bin center) spatial_bins_um: numpy.array 1d @@ -42,9 +44,9 @@ def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y" self.dim = ["x", "y", "z"].index(direction) def make_interpolators(self): - from scipy.interpolate import RegularGridInterpolator2D + from scipy.interpolate import RegularGridInterpolator self.interpolator = [ - RegularGridInterpolator2D((self.spatial_bins_um, self.temporal_bins_s[j]), self.displacement[j]) + RegularGridInterpolator((self.temporal_bins_s[j], self.spatial_bins_um), self.displacement[j]) for j in range(self.num_segments) ] self.temporal_bounds = [(t[0], t[-1]) for t in self.temporal_bins_s] @@ -72,14 +74,17 @@ def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_inde segment_index = 0 else: raise ValueError("Several segment need segment_index=") + + times_s = np.asarray(times_s) + locations_um = np.asarray(times_s) if locations_um.ndim == 1: locations_um = locations_um else: locations_um = locations_um[:, self.dim] times_s = np.clip(times_s, *self.temporal_bounds[segment_index]) - positions = np.clip(positions, *self.spatial_bounds) - points = np.stack([positions, times_s], axis=1) + locations_um = np.clip(locations_um, *self.spatial_bounds) + points = np.stack([times_s, locations_um,], axis=1) return self.interpolator[segment_index](points) @@ -91,8 +96,23 @@ def to_dict(self): ) def save(self): + # TODO pass @classmethod def load(cls): + # TODO pass + + def __eq__(self, other): + + for segment_index in range(self.num_segments): + if not np.allclose(self.displacement[segment_index], other.displacement[segment_index]): + return False + if not np.allclose(self.temporal_bins_s[segment_index], other.temporal_bins_s[segment_index]): + return False + + if not np.allclose(self.spatial_bins_um, other.spatial_bins_um): + return False + + return True diff --git a/src/spikeinterface/sortingcomponents/tests/common.py b/src/spikeinterface/sortingcomponents/tests/common.py index aacd7576fb..a711b67bda 100644 --- a/src/spikeinterface/sortingcomponents/tests/common.py +++ b/src/spikeinterface/sortingcomponents/tests/common.py @@ -3,6 +3,7 @@ from spikeinterface.core import generate_ground_truth_recording + def make_dataset(): # this replace the MEArec 10s file for testing recording, sorting = generate_ground_truth_recording( @@ -22,3 +23,4 @@ def make_dataset(): seed=2205, ) return recording, sorting + diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index 36d2d34f4d..3519c66228 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -161,30 +161,28 @@ def test_estimate_motion(): ) kwargs.update(cases_kwargs) - motion, temporal_bins, spatial_bins, extra_check = estimate_motion(recording, peaks, peak_locations, **kwargs) + motion, extra_check = estimate_motion(recording, peaks, peak_locations, **kwargs) motions[name] = motion - assert temporal_bins.shape[0] == motion.shape[0] - assert spatial_bins.shape[0] == motion.shape[1] - if cases_kwargs["rigid"]: - assert motion.shape[1] == 1 + assert motion.displacement[0].shape[1] == 1 else: - assert motion.shape[1] > 1 + assert motion.displacement[0].shape[1] > 1 - # Test saving to disk - corrected_rec = InterpolateMotionRecording( - recording, motion, temporal_bins, spatial_bins, border_mode="force_extrapolate" - ) - rec_folder = cache_folder / (name.replace("/", "").replace(" ", "_") + "_recording") - if rec_folder.exists(): - shutil.rmtree(rec_folder) - corrected_rec.save(folder=rec_folder) + # # Test saving to disk + # corrected_rec = InterpolateMotionRecording( + # recording, motion, temporal_bins, spatial_bins, border_mode="force_extrapolate" + # ) + # rec_folder = cache_folder / (name.replace("/", "").replace(" ", "_") + "_recording") + # if rec_folder.exists(): + # shutil.rmtree(rec_folder) + # corrected_rec.save(folder=rec_folder) if DEBUG: fig, ax = plt.subplots() - ax.plot(temporal_bins, motion) + seg_index = 0 + ax.plot(motion.temporal_bins_s[0], motion.displacement[seg_index]) # motion_histogram = extra_check['motion_histogram'] # spatial_hist_bins = extra_check['spatial_hist_bin_edges'] @@ -205,7 +203,7 @@ def test_estimate_motion(): # same params with differents engine should be the same motion0, motion1 = motions["rigid / decentralized / torch"], motions["rigid / decentralized / numpy"] - assert (motion0 == motion1).all() + assert (motion0 == motion1) motion0, motion1 = ( motions["rigid / decentralized / torch / time_horizon_s"], diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py new file mode 100644 index 0000000000..dc826ce773 --- /dev/null +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -0,0 +1,38 @@ + + +# TODO Motion Make some test + +import pytest +import numpy as np + +from spikeinterface.sortingcomponents.motion_utils import Motion + + + + +def test_Motion(): + + temporal_bins_s = np.arange(0., 10., 1.) + spatial_bins_um = np.array([100., 200.]) + + displacement = np.zeros((temporal_bins_s.shape[0], spatial_bins_um.shape[0])) + displacement[:, :] = np.linspace(-20, 20, temporal_bins_s.shape[0])[:, np.newaxis] + + motion = Motion( + displacement, temporal_bins_s, spatial_bins_um, direction="y" + ) + + motion2 = Motion(**motion.to_dict()) + assert motion == motion2 + + displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, ], [120., 80., 150.]) + # print(displacement) + assert displacement.shape[0] == 3 + # check clip + assert displacement[2] == 20. + + + + +if __name__ == "__main__": + test_Motion() \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py deleted file mode 100644 index 9efd26a3d5..0000000000 --- a/src/spikeinterface/sortingcomponents/tests/test_motiopn_utils.py +++ /dev/null @@ -1,3 +0,0 @@ - - -# TODO Motion Make some test \ No newline at end of file From e816f9383aad5b2350d9186d441bd344db1823d3 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Mon, 20 May 2024 15:12:37 +0200 Subject: [PATCH 016/248] WIP : refactor with Motion object --- .../sortingcomponents/motion_interpolation.py | 221 ++++++++---------- .../sortingcomponents/motion_utils.py | 31 +++ .../tests/test_motion_interpolation.py | 45 ++-- .../tests/test_motion_utils.py | 1 + 4 files changed, 157 insertions(+), 141 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 05e9073c1b..b209cb31bc 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -11,23 +11,11 @@ from spikeinterface.preprocessing import get_spatial_interpolation_kernel -# try: -# import numba -# HAVE_NUMBA = True -# except ImportError: -# HAVE_NUMBA = False - - def correct_motion_on_peaks( peaks, peak_locations, sampling_frequency, - # TODO use add Motion motion, - temporal_bins, - spatial_bins, - ### - direction="y", ): """ Given the output of estimate_motion(), apply inverse motion on peak locations. @@ -40,13 +28,8 @@ def correct_motion_on_peaks( peaks location vector sampling_frequency: np.array sampling_frequency of the recording - motion: np.array 2D - motion.shape[0] equal temporal_bins.shape[0] - motion.shape[1] equal 1 when "rigid" motion equal temporal_bins.shape[0] when "non-rigid" - temporal_bins: np.array - Temporal bins in second. - spatial_bins: np.array - Bins for non-rigid motion. If spatial_bins.sahpe[0] == 1 then rigid motion is used. + motion: Motion + The motion object. Returns ------- @@ -55,33 +38,26 @@ def correct_motion_on_peaks( """ corrected_peak_locations = peak_locations.copy() - spike_times = peaks["sample_index"] / sampling_frequency - if spatial_bins.shape[0] == 1: - # rigid motion interpolation 1D - f = scipy.interpolate.interp1d(temporal_bins, motion[:, 0], bounds_error=False, fill_value="extrapolate") - shift = f(spike_times) - corrected_peak_locations[direction] -= shift - else: - # non rigid motion = interpolation 2D - f = scipy.interpolate.RegularGridInterpolator( - (temporal_bins, spatial_bins), motion, method="linear", bounds_error=False, fill_value=None - ) - shift = f(np.c_[spike_times, peak_locations[direction]]) - corrected_peak_locations[direction] -= shift + for segment_index in range(motion.num_segments): + i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) - return corrected_peak_locations + # TODO delegate times to recording object + spike_times = peaks["sample_index"][i0:i1] / sampling_frequency + spike_locs = peak_locations[motion.direction][i0:i1] + spike_displacement = motion.get_displacement_at_time_and_depth(spike_times, spike_locs, segment_index=segment_index) + + corrected_peak_locations[i0:i1][motion.direction] -= spike_displacement + + return corrected_peak_locations + def interpolate_motion_on_traces( traces, times, channel_locations, - # TODO : add Motion object here motion, - temporal_bins, - spatial_bins, - ### - direction=1, + segment_index=None, channel_inds=None, spatial_interpolation_method="kriging", spatial_interpolation_kwargs={}, @@ -97,16 +73,10 @@ def interpolate_motion_on_traces( Trace snippet (num_samples, num_channels) channel_location: np.array 2d Channel location with shape (n, 2) or (n, 3) - motion: np.array 2D - motion.shape[0] equal temporal_bins.shape[0] - motion.shape[1] equal 1 when "rigid" motion - equal temporal_bins.shape[0] when "none rigid" - temporal_bins: np.array - Temporal bins in second. - spatial_bins: None or np.array - Bins for non-rigid motion. If None, rigid motion is used - direction: int in (0, 1, 2) - Dimension of shift in channel_locations. + motion: Motion + The motion object. + segment_index: int or None + The segment index. channel_inds: None or list If not None, interpolate only a subset of channels. spatial_interpolation_method: "idw" | "kriging", default: "kriging" @@ -125,33 +95,56 @@ def interpolate_motion_on_traces( # assert HAVE_NUMBA assert times.shape[0] == traces.shape[0] + if segment_index is None: + if motion.num_segments == 1: + segment_index = 0 + else: + raise ValueError("Several segment need segment_index=") + if channel_inds is None: traces_corrected = np.zeros(traces.shape, dtype=traces.dtype) else: channel_inds = np.asarray(channel_inds) traces_corrected = np.zeros((traces.shape[0], channel_inds.size), dtype=traces.dtype) + + total_num_chans = channel_locations.shape[0] + + # TODO give optional possibility to have smaler times bins than the motion with interpolation + # this would remove the need of _get_closest_ind and searchsorted + # TODO delegate times to recording, at the moment this is 0 based # regroup times by closet temporal_bins - bin_inds = _get_closest_ind(temporal_bins, times) + bin_inds = _get_closest_ind(motion.temporal_bins_s[segment_index], times) # inperpolation kernel will be the same per temporal bin for bin_ind in np.unique(bin_inds): - # TODO use # TODO : add Motion.get_displacement_at_time_and_depth() instead - # Step 1 : channel motion - if spatial_bins.shape[0] == 1: - # rigid motion : same motion for all channels - channel_motions = motion[bin_ind, 0] - else: - # non rigid : interpolation channel motion for this temporal bin - f = scipy.interpolate.interp1d( - spatial_bins, motion[bin_ind, :], kind="linear", axis=0, bounds_error=False, fill_value="extrapolate" - ) - locs = channel_locations[:, direction] - channel_motions = f(locs) + bin_time = motion.temporal_bins_s[segment_index][bin_ind] + + channel_motions = motion.get_displacement_at_time_and_depth( + np.full(total_num_chans, bin_time), + channel_locations[motion.dim], + segment_index=segment_index + ) channel_locations_moved = channel_locations.copy() - channel_locations_moved[:, direction] += channel_motions - # channel_locations_moved[:, direction] -= channel_motions + channel_locations_moved[:, motion.dim] += channel_motions + + # # TODO use # TODO : add Motion.get_displacement_at_time_and_depth() instead + + # # Step 1 : channel motion + # if spatial_bins.shape[0] == 1: + # # rigid motion : same motion for all channels + # channel_motions = motion[bin_ind, 0] + # else: + # # non rigid : interpolation channel motion for this temporal bin + # f = scipy.interpolate.interp1d( + # spatial_bins, motion[bin_ind, :], kind="linear", axis=0, bounds_error=False, fill_value="extrapolate" + # ) + # locs = channel_locations[:, direction] + # channel_motions = f(locs) + # channel_locations_moved = channel_locations.copy() + # channel_locations_moved[:, direction] += channel_motions + # # channel_locations_moved[:, direction] -= channel_motions if channel_inds is not None: channel_locations_moved = channel_locations_moved[channel_inds] @@ -164,8 +157,16 @@ def interpolate_motion_on_traces( **spatial_interpolation_kwargs, ) - i0 = np.searchsorted(bin_inds, bin_ind, side="left") - i1 = np.searchsorted(bin_inds, bin_ind, side="right") + # keep this for DEBUG + # import matplotlib.pyplot as plt + # fig, ax = plt.subplots() + # ax.matshow(drift_kernel) + # ax.set_title(f"bin_ind {bin_ind} - {bin_time}s - {spatial_interpolation_method}") + # plt.show() + + # i0 = np.searchsorted(bin_inds, bin_ind, side="left") + # i1 = np.searchsorted(bin_inds, bin_ind, side="right") + i0, i1 = np.searchsorted(bin_inds, [bin_ind, bin_ind + 1], side="left") # here we use a simple np.matmul even if dirft_kernel can be super sparse. # because the speed for a sparse matmul is not so good when we disable multi threaad (due multi processing @@ -226,16 +227,8 @@ class InterpolateMotionRecording(BasePreprocessor): ---------- recording: Recording The parent recording. - motion: np.array 2D - The motion signal obtained with `estimate_motion()` - motion.shape[0] must correspond to temporal_bins.shape[0] - motion.shape[1] is 1 when "rigid" motion and spatial_bins.shape[0] when "non-rigid" - temporal_bins: np.array - Temporal bins in second. - spatial_bins: None or np.array - Bins for non-rigid motion. If None, rigid motion is used - direction: 0 | 1 | 2, default: 1 - Dimension along which channel_locations are shifted (0 - x, 1 - y, 2 - z) + motion: Motion + The motion object spatial_interpolation_method: "kriging" | "idw" | "nearest", default: "kriging" The spatial interpolation method used to interpolate the channel locations. See `spikeinterface.preprocessing.get_spatial_interpolation_kernel()` for more details. @@ -269,49 +262,55 @@ def __init__( self, recording, motion, - temporal_bins, - spatial_bins, - direction=1, border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=1, num_closest=3, ): - assert recording.get_num_segments() == 1, "correct_motion() is only available for single-segment recordings" + # assert recording.get_num_segments() == 1, "correct_motion() is only available for single-segment recordings" - # force as arrays - temporal_bins = np.asarray(temporal_bins) - motion = np.asarray(motion) - spatial_bins = np.asarray(spatial_bins) + # # force as arrays + # temporal_bins = np.asarray(temporal_bins) + # motion = np.asarray(motion) + # spatial_bins = np.asarray(spatial_bins) channel_locations = recording.get_channel_locations() - assert channel_locations.ndim >= direction, ( - f"'direction' {direction} not available. " f"Channel locations have {channel_locations.ndim} dimensions." + assert channel_locations.ndim >= motion.dim, ( + f"'direction' {motion.direction} not available. " f"Channel locations have {channel_locations.ndim} dimensions." ) spatial_interpolation_kwargs = dict(sigma_um=sigma_um, p=p, num_closest=num_closest) if border_mode == "remove_channels": - locs = channel_locations[:, direction] - l0, l1 = np.min(channel_locations[:, direction]), np.max(channel_locations[:, direction]) + locs = channel_locations[:, motion.dim] + l0, l1 = np.min(locs), np.max(locs) # compute max and min motion (with interpolation) - # and check if channels are inside + # and check if channels are inside for all segment channel_inside = np.ones(locs.shape[0], dtype="bool") - for operator in (np.max, np.min): - if spatial_bins.shape[0] == 1: - best_motions = operator(motion[:, 0]) - else: - # non rigid : interpolation channel motion for this temporal bin - f = scipy.interpolate.interp1d( - spatial_bins, - operator(motion[:, :], axis=0), - kind="linear", - axis=0, - bounds_error=False, - fill_value="extrapolate", + for operator, arg_operator in ((np.max,np.argmax), (np.min, np.argmin)): + for segment_index in range(recording.get_num_segments()): + ind = arg_operator(operator(motion.displacement[segment_index], axis=1)) + bin_time = motion.temporal_bins_s[segment_index][ind] + best_motions = motion.get_displacement_at_time_and_depth( + np.full(locs.shape[0], bin_time), locs, segment_index=segment_index ) - best_motions = f(locs) - channel_inside &= ((locs + best_motions) >= l0) & ((locs + best_motions) <= l1) + channel_inside &= ((locs + best_motions) >= l0) & ((locs + best_motions) <= l1) + + + # if spatial_bins.shape[0] == 1: + # best_motions = operator(motion[:, 0]) + # else: + # # non rigid : interpolation channel motion for this temporal bin + # f = scipy.interpolate.interp1d( + # spatial_bins, + # operator(motion[:, :], axis=0), + # kind="linear", + # axis=0, + # bounds_error=False, + # fill_value="extrapolate", + # ) + # best_motions = f(locs) + # channel_inside &= ((locs + best_motions) >= l0) & ((locs + best_motions) <= l1) (channel_inds,) = np.nonzero(channel_inside) channel_ids = recording.channel_ids[channel_inds] @@ -342,9 +341,6 @@ def __init__( parent_segment, channel_locations, motion, - temporal_bins, - spatial_bins, - direction, spatial_interpolation_method, spatial_interpolation_kwargs, channel_inds, @@ -354,9 +350,6 @@ def __init__( self._kwargs = dict( recording=recording, motion=motion, - temporal_bins=temporal_bins, - spatial_bins=spatial_bins, - direction=direction, border_mode=border_mode, spatial_interpolation_method=spatial_interpolation_method, sigma_um=sigma_um, @@ -370,13 +363,7 @@ def __init__( self, parent_recording_segment, channel_locations, - # TODO : add Motion object here motion, - temporal_bins, - spatial_bins, - ### - - direction, spatial_interpolation_method, spatial_interpolation_kwargs, channel_inds, @@ -384,9 +371,6 @@ def __init__( BasePreprocessorSegment.__init__(self, parent_recording_segment) self.channel_locations = channel_locations self.motion = motion - self.temporal_bins = temporal_bins - self.spatial_bins = spatial_bins - self.direction = direction self.spatial_interpolation_method = spatial_interpolation_method self.spatial_interpolation_kwargs = spatial_interpolation_kwargs self.channel_inds = channel_inds @@ -417,9 +401,6 @@ def get_traces(self, start_frame, end_frame, channel_indices): times, self.channel_locations, self.motion, - self.temporal_bins, - self.spatial_bins, - direction=self.direction, channel_inds=self.channel_inds, spatial_interpolation_method=self.spatial_interpolation_method, spatial_interpolation_kwargs=self.spatial_interpolation_kwargs, diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 448aefdb9e..6bf10372d5 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -2,6 +2,25 @@ + +# @charlie @sam +# here TODO list for motion object +# * simple test for Motion: DONE +# * save/load Motion +# * make better test for Motion object with save/load +# * propagate to estimate_motion : DONE +# * handle multi segment in estimate_motion(): maybe in another PR +# * propagate to motion_interpolation.py: +# * propagate to preprocessing/correct_motion() +# * generate drifting signals for test estimate_motion and interpolate_motion +# * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff) +# * delegate times to recording object in +# * estimate motion +# * correct_motion_on_peaks() +# * interpolate_motion_on_traces() + + + class Motion: """ Motion of the tissue relative the probe. @@ -42,6 +61,18 @@ def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y" self.direction = direction self.dim = ["x", "y", "z"].index(direction) + + def __repr__(self): + nbins = self.spatial_bins_um.shape[0] + if nbins == 1: + rigid_txt = "rigid" + else: + rigid_txt = f"non-rigid - {nbins} spatial bins" + + interval_s = self.temporal_bins_s[0][1] - self.temporal_bins_s[0][0] + txt = f"Motion {rigid_txt} - interval {interval_s}s -{self.num_segments} segments" + return txt + def make_interpolators(self): from scipy.interpolate import RegularGridInterpolator diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index cc3434b782..47f61f9ad6 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -4,6 +4,7 @@ from spikeinterface import download_dataset +from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.motion_interpolation import ( correct_motion_on_peaks, interpolate_motion_on_traces, @@ -20,21 +21,25 @@ def make_fake_motion(rec): - # make a fake motion vector + # make a fake motion object duration = rec.get_total_duration() locs = rec.get_channel_locations() temporal_bins = np.arange(0.5, duration - 0.49, 0.5) spatial_bins = np.arange(locs[:, 1].min(), locs[:, 1].max(), 100) - motion = np.zeros((temporal_bins.size, spatial_bins.size)) - motion[:, :] = np.linspace(-30, 30, temporal_bins.size)[:, None] + displacament = np.zeros((temporal_bins.size, spatial_bins.size)) + displacament[:, :] = np.linspace(-30, 30, temporal_bins.size)[:, None] - return motion, temporal_bins, spatial_bins + motion = Motion([displacament], [temporal_bins], spatial_bins, direction="y") + + return motion def test_correct_motion_on_peaks(): rec, sorting = make_dataset() peaks = sorting.to_spike_vector() - motion, temporal_bins, spatial_bins = make_fake_motion(rec) + print(peaks.dtype) + motion = make_fake_motion(rec) + # print(motion) # fake locations peak_locations = np.zeros((peaks.size), dtype=[("x", "float32"), ("y", "float")]) @@ -44,24 +49,24 @@ def test_correct_motion_on_peaks(): peak_locations, rec.sampling_frequency, motion, - temporal_bins, - spatial_bins, - direction="y", ) # print(corrected_peak_locations) assert np.any(corrected_peak_locations["y"] != 0) # import matplotlib.pyplot as plt # fig, ax = plt.subplots() - # ax.plot(times[peaks['sample_index']], corrected_peak_locations['y']) - # ax.plot(temporal_bins, motion[:, 1]) + # segment_index = 0 + # times = rec.get_times(segment_index=segment_index) + # ax.scatter(times[peaks['sample_index']], corrected_peak_locations['y']) + # ax.plot(motion.temporal_bins_s[segment_index], motion.displacement[segment_index][:, 1]) # plt.show() + def test_interpolate_motion_on_traces(): rec, sorting = make_dataset() - motion, temporal_bins, spatial_bins = make_fake_motion(rec) + motion = make_fake_motion(rec) channel_locations = rec.get_channel_locations() @@ -74,12 +79,10 @@ def test_interpolate_motion_on_traces(): times, channel_locations, motion, - temporal_bins, - spatial_bins, - direction=1, channel_inds=None, spatial_interpolation_method=method, - spatial_interpolation_kwargs={}, + # spatial_interpolation_kwargs={}, + spatial_interpolation_kwargs={"force_extrapolate": True}, ) assert traces.shape == traces_corrected.shape assert traces.dtype == traces_corrected.dtype @@ -87,15 +90,15 @@ def test_interpolate_motion_on_traces(): def test_InterpolateMotionRecording(): rec, sorting = make_dataset() - motion, temporal_bins, spatial_bins = make_fake_motion(rec) + motion = make_fake_motion(rec) - rec2 = InterpolateMotionRecording(rec, motion, temporal_bins, spatial_bins, border_mode="force_extrapolate") + rec2 = InterpolateMotionRecording(rec, motion, border_mode="force_extrapolate") assert rec2.channel_ids.size == 32 - rec2 = InterpolateMotionRecording(rec, motion, temporal_bins, spatial_bins, border_mode="force_zeros") + rec2 = InterpolateMotionRecording(rec, motion, border_mode="force_zeros") assert rec2.channel_ids.size == 32 - rec2 = InterpolateMotionRecording(rec, motion, temporal_bins, spatial_bins, border_mode="remove_channels") + rec2 = InterpolateMotionRecording(rec, motion, border_mode="remove_channels") assert rec2.channel_ids.size == 24 for ch_id in (0, 1, 14, 15, 16, 17, 30, 31): assert ch_id not in rec2.channel_ids @@ -116,6 +119,6 @@ def test_InterpolateMotionRecording(): if __name__ == "__main__": - test_correct_motion_on_peaks() - test_interpolate_motion_on_traces() + # test_correct_motion_on_peaks() + # test_interpolate_motion_on_traces() test_InterpolateMotionRecording() diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index dc826ce773..289e3bfe57 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -21,6 +21,7 @@ def test_Motion(): motion = Motion( displacement, temporal_bins_s, spatial_bins_um, direction="y" ) + print(motion) motion2 = Motion(**motion.to_dict()) assert motion == motion2 From f53b824b389d3c3ea03a6253acb4c779a6a4a522 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Sun, 26 May 2024 22:03:04 +0200 Subject: [PATCH 017/248] Propagate Motion object to preprocessing. --- src/spikeinterface/preprocessing/motion.py | 28 ++++----- .../preprocessing/tests/test_motion.py | 5 +- .../sortingcomponents/motion_utils.py | 63 ++++++++++++++++--- .../tests/test_motion_utils.py | 29 +++++++-- 4 files changed, 92 insertions(+), 33 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 3956cfc17d..a5300ccadc 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -69,7 +69,7 @@ weight_with_amplitude=False, ), "interpolate_motion_kwargs": dict( - direction=1, border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 + border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 ), }, "nonrigid_fast_and_accurate": { @@ -128,7 +128,7 @@ weight_with_amplitude=False, ), "interpolate_motion_kwargs": dict( - direction=1, border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 + border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 ), }, # This preset is a super fast rigid estimation with center of mass @@ -153,7 +153,7 @@ rigid=True, ), "interpolate_motion_kwargs": dict( - direction=1, border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 + border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 ), }, # This preset try to mimic kilosort2.5 motion estimator @@ -187,7 +187,7 @@ win_shape="rect", ), "interpolate_motion_kwargs": dict( - direction=1, border_mode="force_extrapolate", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 + border_mode="force_extrapolate", spatial_interpolation_method="kriging", sigma_um=20.0, p=2 ), }, # empty preset @@ -380,22 +380,17 @@ def correct_motion( np.save(folder / "peak_locations.npy", peak_locations) t0 = time.perf_counter() - motion, temporal_bins, spatial_bins = estimate_motion(recording, peaks, peak_locations, **estimate_motion_kwargs) + motion = estimate_motion(recording, peaks, peak_locations, **estimate_motion_kwargs) t1 = time.perf_counter() run_times["estimate_motion"] = t1 - t0 recording_corrected = InterpolateMotionRecording( - recording, motion, temporal_bins, spatial_bins, **interpolate_motion_kwargs + recording, motion, **interpolate_motion_kwargs ) if folder is not None: (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") - - # TODO save Motion - np.save(folder / "temporal_bins.npy", temporal_bins) - np.save(folder / "motion.npy", motion) - if spatial_bins is not None: - np.save(folder / "spatial_bins.npy", spatial_bins) + motion.save(folder / "motion") if output_motion_info: motion_info = dict( @@ -403,9 +398,6 @@ def correct_motion( run_times=run_times, peaks=peaks, peak_locations=peak_locations, - # TODO use Motion - temporal_bins=temporal_bins, - spatial_bins=spatial_bins, motion=motion, ) return recording_corrected, motion_info @@ -424,6 +416,8 @@ def correct_motion( def load_motion_info(folder): + from spikeinterface.sortingcomponents.motion_utils import Motion + folder = Path(folder) motion_info = {} @@ -434,11 +428,13 @@ def load_motion_info(folder): with open(folder / "run_times.json") as f: motion_info["run_times"] = json.load(f) - array_names = ("peaks", "peak_locations", "temporal_bins", "spatial_bins", "motion") + array_names = ("peaks", "peak_locations") for name in array_names: if (folder / f"{name}.npy").exists(): motion_info[name] = np.load(folder / f"{name}.npy") else: motion_info[name] = None + + motion_info["motion"] = Motion.load(folder / "motion") return motion_info diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index 7cea531bb4..c2b8d0024e 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -25,6 +25,7 @@ def test_estimate_and_correct_motion(): folder = cache_folder / "estimate_and_correct_motion" if folder.exists(): shutil.rmtree(folder) + rec_corrected = correct_motion(rec, folder=folder) print(rec_corrected) @@ -33,5 +34,5 @@ def test_estimate_and_correct_motion(): if __name__ == "__main__": - print(correct_motion.__doc__) - # test_estimate_and_correct_motion() + # print(correct_motion.__doc__) + test_estimate_and_correct_motion() diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 6bf10372d5..ddb6c2d8ae 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -1,23 +1,30 @@ +import json +from pathlib import Path import numpy as np +import spikeinterface +from spikeinterface.core.core_tools import check_json + # @charlie @sam # here TODO list for motion object # * simple test for Motion: DONE -# * save/load Motion -# * make better test for Motion object with save/load +# * save/load Motion DONE +# * make simple test for Motion object with save/load DONE # * propagate to estimate_motion : DONE # * handle multi segment in estimate_motion(): maybe in another PR -# * propagate to motion_interpolation.py: -# * propagate to preprocessing/correct_motion() +# * propagate to motion_interpolation.py: ALMOST DONE +# * propagate to preprocessing/correct_motion(): # * generate drifting signals for test estimate_motion and interpolate_motion # * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff) # * delegate times to recording object in # * estimate motion # * correct_motion_on_peaks() # * interpolate_motion_on_traces() +# update plot_motion() dans widget +# @@ -126,14 +133,50 @@ def to_dict(self): spatial_bins_um=self.spatial_bins_um, ) - def save(self): - # TODO - pass + def save(self, folder): + folder = Path(folder) + + folder.mkdir(exist_ok=False, parents=True) + + info_file = folder / f"spikeinterface_info.json" + info = dict( + version=spikeinterface.__version__, + dev_mode=spikeinterface.DEV_MODE, + object="Motion", + num_segments=self.num_segments, + direction=self.direction, + ) + with open(info_file, mode="w") as f: + json.dump(check_json(info), f, indent=4) + + np.save(folder / "spatial_bins_um.npy", self.spatial_bins_um) + + for segment_index in range(self.num_segments): + np.save(folder / f"displacement_seg{segment_index}.npy", self.displacement[segment_index]) + np.save(folder / f"temporal_bins_s_seg{segment_index}.npy", self.temporal_bins_s[segment_index]) @classmethod - def load(cls): - # TODO - pass + def load(cls, folder): + folder = Path(folder) + + info_file = folder / f"spikeinterface_info.json" + if not info_file.exists(): + raise IOError("Motion.load(folder) : the folder do not contain Motion") + + with open(info_file, "r") as f: + info = json.load(f) + if info["object"] != "Motion": + raise IOError("Motion.load(folder) : the folder do not contain Motion") + + direction = info["direction"] + spatial_bins_um = np.load(folder / "spatial_bins_um.npy") + displacement = [] + temporal_bins_s = [] + for segment_index in range(info["num_segments"]): + displacement.append(np.load(folder / f"displacement_seg{segment_index}.npy")) + temporal_bins_s.append(np.load(folder / f"temporal_bins_s_seg{segment_index}.npy")) + + return cls(displacement, temporal_bins_s, spatial_bins_um, direction=direction) def __eq__(self, other): diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 289e3bfe57..289a8a12cb 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -1,13 +1,15 @@ - - -# TODO Motion Make some test - import pytest import numpy as np +import pickle +from pathlib import Path +import shutil from spikeinterface.sortingcomponents.motion_utils import Motion - +if hasattr(pytest, "global_test_folder"): + cache_folder = pytest.global_test_folder / "sortingcomponents" +else: + cache_folder = Path("cache_folder") / "sortingcomponents" def test_Motion(): @@ -23,9 +25,18 @@ def test_Motion(): ) print(motion) + # serialize with pickle before interpolation fit + motion2 = pickle.loads(pickle.dumps(motion)) + assert motion2.interpolator == None + # serialize with pickle after interpolation fit + motion.make_interpolators() + motion2 = pickle.loads(pickle.dumps(motion)) + + # to/from dict motion2 = Motion(**motion.to_dict()) assert motion == motion2 + # do interpolate displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, ], [120., 80., 150.]) # print(displacement) assert displacement.shape[0] == 3 @@ -33,6 +44,14 @@ def test_Motion(): assert displacement[2] == 20. + # save/load to folder + folder = cache_folder / "motion_saved" + if folder.exists(): + shutil.rmtree(folder) + motion.save(folder) + motion2 = Motion.load(folder) + assert motion == motion2 + if __name__ == "__main__": From fd80fd6596faedd4f11c37e8418e29ac01343466 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Tue, 28 May 2024 08:20:32 +0200 Subject: [PATCH 018/248] updata todo --- src/spikeinterface/sortingcomponents/motion_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index ddb6c2d8ae..6a033b6818 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -16,15 +16,15 @@ # * propagate to estimate_motion : DONE # * handle multi segment in estimate_motion(): maybe in another PR # * propagate to motion_interpolation.py: ALMOST DONE -# * propagate to preprocessing/correct_motion(): +# * propagate to preprocessing/correct_motion(): ALMOST DONE # * generate drifting signals for test estimate_motion and interpolate_motion # * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff) # * delegate times to recording object in # * estimate motion # * correct_motion_on_peaks() # * interpolate_motion_on_traces() +# propagate to benchmark estimate motion # update plot_motion() dans widget -# From 597a094ba99293ba24594cb5871d00caed0b14f8 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Wed, 29 May 2024 16:06:30 +0100 Subject: [PATCH 019/248] Add grid option to Motion with a test --- .../sortingcomponents/motion_utils.py | 134 ++++++++++++------ .../tests/test_motion_utils.py | 20 ++- 2 files changed, 103 insertions(+), 51 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 6a033b6818..71cde08689 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -1,13 +1,10 @@ import json from pathlib import Path -import numpy as np - +import numpy as np import spikeinterface from spikeinterface.core.core_tools import check_json - - # @charlie @sam # here TODO list for motion object # * simple test for Motion: DONE @@ -27,18 +24,16 @@ # update plot_motion() dans widget - class Motion: """ Motion of the tissue relative the probe. Parameters ---------- - displacement: numpy array 2d or list of Motion estimate in um. List is the number of segment. - For each semgent : + For each semgent : * shape (temporal bins, spatial bins) * motion.shape[0] = temporal_bins.shape[0] * motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) @@ -48,9 +43,12 @@ class Motion: Windows center. spatial_bins_um.shape[0] == displacement.shape[1] If rigid then spatial_bins_um.shape[0] == 1 - + interpolation_method : str + How to determine the displacement between bin centers? See the docs + for scipy.interpolate.RegularGridInterpolator for options. """ - def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y"): + + def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y", interpolation_method="linear"): if isinstance(displacement, np.ndarray): self.displacement = [displacement] assert isinstance(temporal_bins_s, np.ndarray) @@ -64,47 +62,68 @@ def __init__(self, displacement, temporal_bins_s, spatial_bins_um, direction="y" self.spatial_bins_um = spatial_bins_um self.num_segments = len(self.displacement) - self.interpolator = None - + self.interpolators = None + self.interpolation_method = interpolation_method + self.direction = direction self.dim = ["x", "y", "z"].index(direction) - + self.check_properties() + + def check_properties(self): + assert all(d.ndim == 2 for d in self.displacement) + assert all(t.ndim == 1 for t in self.temporal_bins_s) + assert all(self.spatial_bins_um.shape == (d.shape[1],) for d in self.displacement) + def __repr__(self): nbins = self.spatial_bins_um.shape[0] if nbins == 1: rigid_txt = "rigid" else: rigid_txt = f"non-rigid - {nbins} spatial bins" - + interval_s = self.temporal_bins_s[0][1] - self.temporal_bins_s[0][0] - txt = f"Motion {rigid_txt} - interval {interval_s}s -{self.num_segments} segments" + txt = f"Motion {rigid_txt} - interval {interval_s}s - {self.num_segments} segments" return txt - def make_interpolators(self): from scipy.interpolate import RegularGridInterpolator - self.interpolator = [ - RegularGridInterpolator((self.temporal_bins_s[j], self.spatial_bins_um), self.displacement[j]) + + self.interpolators = [ + RegularGridInterpolator( + (self.temporal_bins_s[j], self.spatial_bins_um), self.displacement[j], method=self.interpolation_method + ) for j in range(self.num_segments) ] self.temporal_bounds = [(t[0], t[-1]) for t in self.temporal_bins_s] self.spatial_bounds = (self.spatial_bins_um.min(), self.spatial_bins_um.max()) - - def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_index=None): - """ + def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_index=None, grid=False): + """Evaluate the motion estimate at times and positions + + Evaluate the motion estimate, returning the (linearly interpolated) estimated displacement + at the given times and locations. Parameters ---------- times_s: np.array - - locations_um: np.array - - segment_index: - + Either this is a one-dimensional array (a vector of positions along self.dimension), or + else a 2d array with the 2 or 3 spatial dimensions indexed along axis=1. + segment_index: int, optional + grid : bool + If grid=False, the default, then times_s and locations_um should have the same one-dimensional + shape, and the returned displacement[i] is the displacement at time times_s[i] and location + locations_um[i]. + If grid=True, times_s and locations_um determine a grid of positions to evaluate the displacement. + Then the returned displacement[i,j] is the displacement at depth locations_um[i] and time times_s[j]. + + Returns + ------- + displacement : np.array + A displacement per input location, of shape times_s.shape if grid=False and (locations_um.size, times_s.size) + if grid=True. """ - if self.interpolator is None: + if self.interpolators is None: self.make_interpolators() if segment_index is None: @@ -112,30 +131,49 @@ def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_inde segment_index = 0 else: raise ValueError("Several segment need segment_index=") - + times_s = np.asarray(times_s) - locations_um = np.asarray(times_s) + locations_um = np.asarray(locations_um) if locations_um.ndim == 1: locations_um = locations_um - else: + elif locations_um.ndim == 2: locations_um = locations_um[:, self.dim] - times_s = np.clip(times_s, *self.temporal_bounds[segment_index]) - locations_um = np.clip(locations_um, *self.spatial_bounds) - points = np.stack([times_s, locations_um,], axis=1) + else: + assert False + + times_s = times_s.clip(*self.temporal_bounds[segment_index]) + locations_um = locations_um.clip(*self.spatial_bounds) + + if grid: + # construct a grid over which to evaluate the displacement + locations_um, times_s = np.meshgrid(locations_um, times_s, indexing="ij") + out_shape = times_s.shape + locations_um = locations_um.ravel() + times_s = times_s.ravel() + else: + # usual case: input is a point cloud + assert locations_um.shape == times_s.shape + assert times_s.ndim == 1 + out_shape = times_s.shape + + points = np.column_stack((times_s, locations_um)) + displacement = self.interpolators[segment_index](points) + # reshape to grid domain shape if necessary + displacement = displacement.reshape(out_shape) - return self.interpolator[segment_index](points) + return displacement def to_dict(self): return dict( displacement=self.displacement, temporal_bins_s=self.temporal_bins_s, spatial_bins_um=self.spatial_bins_um, + interpolation_method=self.interpolation_method, ) - + def save(self, folder): folder = Path(folder) - folder.mkdir(exist_ok=False, parents=True) info_file = folder / f"spikeinterface_info.json" @@ -145,6 +183,7 @@ def save(self, folder): object="Motion", num_segments=self.num_segments, direction=self.direction, + interpolation_method=self.interpolation_method, ) with open(info_file, mode="w") as f: json.dump(check_json(info), f, indent=4) @@ -160,33 +199,40 @@ def load(cls, folder): folder = Path(folder) info_file = folder / f"spikeinterface_info.json" + err_msg = f"Motion.load(folder): the folder {folder} does not contain a Motion object." if not info_file.exists(): - raise IOError("Motion.load(folder) : the folder do not contain Motion") - + raise IOError(err_msg) + with open(info_file, "r") as f: info = json.load(f) - if info["object"] != "Motion": - raise IOError("Motion.load(folder) : the folder do not contain Motion") + if "object" not in info or info["object"] != "Motion": + raise IOError(err_msg) direction = info["direction"] + interpolation_method = info["interpolation_method"] spatial_bins_um = np.load(folder / "spatial_bins_um.npy") displacement = [] temporal_bins_s = [] for segment_index in range(info["num_segments"]): displacement.append(np.load(folder / f"displacement_seg{segment_index}.npy")) temporal_bins_s.append(np.load(folder / f"temporal_bins_s_seg{segment_index}.npy")) - - return cls(displacement, temporal_bins_s, spatial_bins_um, direction=direction) - def __eq__(self, other): + return cls( + displacement, + temporal_bins_s, + spatial_bins_um, + direction=direction, + interpolation_method=interpolation_method, + ) + def __eq__(self, other): for segment_index in range(self.num_segments): if not np.allclose(self.displacement[segment_index], other.displacement[segment_index]): return False if not np.allclose(self.temporal_bins_s[segment_index], other.temporal_bins_s[segment_index]): return False - + if not np.allclose(self.spatial_bins_um, other.spatial_bins_um): return False - + return True diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 289a8a12cb..a170245d7d 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -1,9 +1,9 @@ -import pytest -import numpy as np import pickle -from pathlib import Path import shutil +from pathlib import Path +import numpy as np +import pytest from spikeinterface.sortingcomponents.motion_utils import Motion if hasattr(pytest, "global_test_folder"): @@ -27,22 +27,29 @@ def test_Motion(): # serialize with pickle before interpolation fit motion2 = pickle.loads(pickle.dumps(motion)) - assert motion2.interpolator == None + assert motion2.interpolator is None # serialize with pickle after interpolation fit motion.make_interpolators() + assert motion2.interpolator is not None motion2 = pickle.loads(pickle.dumps(motion)) + assert motion2.interpolator is not None # to/from dict motion2 = Motion(**motion.to_dict()) assert motion == motion2 + assert motion2.interpolator is None # do interpolate - displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, ], [120., 80., 150.]) + displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11], [120., 80., 150.]) # print(displacement) assert displacement.shape[0] == 3 # check clip assert displacement[2] == 20. + # interpolate grid + displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, 15, 19], [150., 80.], grid=True) + assert displacement.shape == (2, 5) + assert displacement[0, 2] == 20. # save/load to folder folder = cache_folder / "motion_saved" @@ -53,6 +60,5 @@ def test_Motion(): assert motion == motion2 - if __name__ == "__main__": - test_Motion() \ No newline at end of file + test_Motion() From e6678cbe99333254ae614560241908fd4d11745c Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Wed, 29 May 2024 16:41:19 +0100 Subject: [PATCH 020/248] Dtype handling in interpolation; Flexible time bins; fast time bins logic --- .../sortingcomponents/motion_interpolation.py | 232 ++++++++++-------- 1 file changed, 128 insertions(+), 104 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index b209cb31bc..4a5a2b0c47 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -1,14 +1,12 @@ from __future__ import annotations import numpy as np -import scipy.interpolate -from tqdm import tqdm - -import scipy.spatial - from spikeinterface.core.core_tools import define_function_from_class -from spikeinterface.preprocessing.basepreprocessor import BasePreprocessor, BasePreprocessorSegment from spikeinterface.preprocessing import get_spatial_interpolation_kernel +from spikeinterface.preprocessing.basepreprocessor import ( + BasePreprocessor, BasePreprocessorSegment) + +from .filter import fix_dtype def correct_motion_on_peaks( @@ -39,7 +37,7 @@ def correct_motion_on_peaks( corrected_peak_locations = peak_locations.copy() for segment_index in range(motion.num_segments): - i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) + i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) # TODO delegate times to recording object spike_times = peaks["sample_index"][i0:i1] / sampling_frequency @@ -50,7 +48,7 @@ def correct_motion_on_peaks( corrected_peak_locations[i0:i1][motion.direction] -= spike_displacement return corrected_peak_locations - + def interpolate_motion_on_traces( traces, @@ -59,6 +57,7 @@ def interpolate_motion_on_traces( motion, segment_index=None, channel_inds=None, + interpolation_time_bin_centers_s=None, spatial_interpolation_method="kriging", spatial_interpolation_kwargs={}, ): @@ -71,6 +70,8 @@ def interpolate_motion_on_traces( ---------- traces : np.array Trace snippet (num_samples, num_channels) + times : np.array + Sample times in seconds for the frames of the traces snippet channel_location: np.array 2d Channel location with shape (n, 2) or (n, 3) motion: Motion @@ -79,6 +80,9 @@ def interpolate_motion_on_traces( The segment index. channel_inds: None or list If not None, interpolate only a subset of channels. + interpolation_time_bin_centers_s : None or np.array + Manually specify the time bins which the interpolation happens + in for this segment. If None, these are the motion estimate's time bins. spatial_interpolation_method: "idw" | "kriging", default: "kriging" The spatial interpolation method used to interpolate the channel locations: * idw : Inverse Distance Weighing @@ -88,9 +92,8 @@ def interpolate_motion_on_traces( Returns ------- - channel_motions: np.array - Shift over time by channel - Shape (times.shape[0], channel_location.shape[0]) + traces_corrected: np.array + Motion-corrected trace snippet, (num_samples, num_channels) """ # assert HAVE_NUMBA assert times.shape[0] == traces.shape[0] @@ -106,53 +109,45 @@ def interpolate_motion_on_traces( else: channel_inds = np.asarray(channel_inds) traces_corrected = np.zeros((traces.shape[0], channel_inds.size), dtype=traces.dtype) - - total_num_chans = channel_locations.shape[0] - # TODO give optional possibility to have smaler times bins than the motion with interpolation - # this would remove the need of _get_closest_ind and searchsorted + total_num_chans = channel_locations.shape[0] - # TODO delegate times to recording, at the moment this is 0 based - # regroup times by closet temporal_bins - bin_inds = _get_closest_ind(motion.temporal_bins_s[segment_index], times) + # -- determine the blocks of frames that will land in the same interpolation time bin + time_bins = interpolation_time_bin_centers_s + if time_bins is None: + time_bins = motion.temporal_bins_s[segment_index] + bin_s = time_bins[1] - time_bins + bins_start = time_bins[0] - 0.5 * bin_s + # nearest bin center for each frame? + bin_inds = (times - bins_start) // bin_s + # the time bins may not cover the whole set of times in the recording, + # so we need to clip these indices to the valid range + np.clip(bin_inds, 0, time_bins.size, out=bin_inds) + + # -- what are the possibilities here anyway? + bins_here = np.arange(bin_inds[0], bin_inds[-1] + 1) # inperpolation kernel will be the same per temporal bin - for bin_ind in np.unique(bin_inds): - - bin_time = motion.temporal_bins_s[segment_index][bin_ind] - + interp_times = np.empty(total_num_chans) + current_start_index = 0 + for bin_ind in bins_here: + bin_time = time_bins[bin_ind] + interp_times.fill(bin_time) channel_motions = motion.get_displacement_at_time_and_depth( - np.full(total_num_chans, bin_time), + interp_times, channel_locations[motion.dim], - segment_index=segment_index + segment_index=segment_index, ) channel_locations_moved = channel_locations.copy() channel_locations_moved[:, motion.dim] += channel_motions - # # TODO use # TODO : add Motion.get_displacement_at_time_and_depth() instead - - # # Step 1 : channel motion - # if spatial_bins.shape[0] == 1: - # # rigid motion : same motion for all channels - # channel_motions = motion[bin_ind, 0] - # else: - # # non rigid : interpolation channel motion for this temporal bin - # f = scipy.interpolate.interp1d( - # spatial_bins, motion[bin_ind, :], kind="linear", axis=0, bounds_error=False, fill_value="extrapolate" - # ) - # locs = channel_locations[:, direction] - # channel_motions = f(locs) - # channel_locations_moved = channel_locations.copy() - # channel_locations_moved[:, direction] += channel_motions - # # channel_locations_moved[:, direction] -= channel_motions - if channel_inds is not None: channel_locations_moved = channel_locations_moved[channel_inds] drift_kernel = get_spatial_interpolation_kernel( channel_locations, channel_locations_moved, - dtype="float32", + dtype=traces.dtype, method=spatial_interpolation_method, **spatial_interpolation_kwargs, ) @@ -164,20 +159,21 @@ def interpolate_motion_on_traces( # ax.set_title(f"bin_ind {bin_ind} - {bin_time}s - {spatial_interpolation_method}") # plt.show() - # i0 = np.searchsorted(bin_inds, bin_ind, side="left") - # i1 = np.searchsorted(bin_inds, bin_ind, side="right") - i0, i1 = np.searchsorted(bin_inds, [bin_ind, bin_ind + 1], side="left") + # quickly find the end of this bin, which is also the start of the next + next_start_index = current_start_index + np.searchsorted(bin_inds[current_start_index:], bin_ind + 1, side="left") + in_bin = slice(current_start_index, next_start_index) # here we use a simple np.matmul even if dirft_kernel can be super sparse. # because the speed for a sparse matmul is not so good when we disable multi threaad (due multi processing # in ChunkRecordingExecutor) - traces_corrected[i0:i1] = traces[i0:i1] @ drift_kernel + np.matmul(traces[in_bin], drift_kernel, out=traces_corrected[in_bin]) + current_start_index = next_start_index return traces_corrected # if HAVE_NUMBA: -# # @numba.jit(parallel=False) +# # @numba.jit(parallel=False) # @numba.jit(parallel=True) # def my_sparse_dot(data_in, data_out, sparse_chans, weights): # """ @@ -192,7 +188,7 @@ def interpolate_motion_on_traces( # num_samples = data_in.shape[0] # num_chan_out = data_out.shape[1] # num_sparse = sparse_chans.shape[1] -# # for sample_index in range(num_samples): +# # for sample_index in range(num_samples): # for sample_index in numba.prange(num_samples): # for out_chan in range(num_chan_out): # v = 0 @@ -219,9 +215,18 @@ def _get_closest_ind(array, values): class InterpolateMotionRecording(BasePreprocessor): """ - Recording that corrects motion on-the-fly given a motion vector estimation (rigid or non-rigid). - This internally applies a spatial interpolation on the original traces after reversing the motion. - `estimate_motion()` must be called before this to estimate the motion vector. + Interpolate the input recording's traces to correct for motion, according to the + motion estimate object `motion`. The interpolation is carried out "lazily" / on the fly + by applying a spatial interpolation on the original traces to estimate their values + at the positions of the probe's channels after being shifted inversely to the motion. + + To get a Motion object, use `interpolate_motion()`. + + By default, each frame is spatially interpolated by the motion at the nearest motion + estimation time bin -- in other words, the temporal resolution of the motion correction + is the same as the motion estimation's. However, this behavior can be changed by setting + `interpolation_time_bin_centers_s` or `interpolation_time_bin_size_s` below. In that case, + the motion estimate will be interpolated to match the interpolation time bins. Parameters ---------- @@ -245,10 +250,22 @@ class InterpolateMotionRecording(BasePreprocessor): Number of closest channels used by "idw" method for interpolation. border_mode: "remove_channels" | "force_extrapolate" | "force_zeros", default: "remove_channels" Control how channels are handled on border: - * "remove_channels": remove channels on the border, the recording has less channels * "force_extrapolate": keep all channel and force extrapolation (can lead to strange signal) * "force_zeros": keep all channel but set zeros when outside (force_extrapolate=False) + interpolation_time_bin_centers_s: np.array or list of np.array, optional + Spatially interpolate each frame according to the displacement estimate at its closest + bin center in this array. If not supplied, this is set to the motion estimate's time bin + centers. If it's supplied, the motion estimate is interpolated to these bin centers. + If you have a multi-segment recording, pass a list of these, one per segment. + interpolation_time_bin_size_s: float, optional + Similar to the previous argument: interpolation_time_bin_centers_s will be constructed + by bins spaced by interpolation_time_bin_size_s. This is ignored if interpolation_time_bin_centers_s + is supplied. + dtype : str or np.dtype, optional + Interpolation needs to convert to a floating dtype. If dtype is supplied, that will be used. + If the input recording is already floating and dtype=None, then its dtype is used by default. + If the input recording is integer, then float32 is used by default. Returns ------- @@ -267,14 +284,12 @@ def __init__( sigma_um=20.0, p=1, num_closest=3, + interpolation_time_bin_centers_s=None, + interpolation_time_bin_size_s=None, + dtype=None, ): # assert recording.get_num_segments() == 1, "correct_motion() is only available for single-segment recordings" - # # force as arrays - # temporal_bins = np.asarray(temporal_bins) - # motion = np.asarray(motion) - # spatial_bins = np.asarray(spatial_bins) - channel_locations = recording.get_channel_locations() assert channel_locations.ndim >= motion.dim, ( f"'direction' {motion.direction} not available. " f"Channel locations have {channel_locations.ndim} dimensions." @@ -284,35 +299,21 @@ def __init__( locs = channel_locations[:, motion.dim] l0, l1 = np.min(locs), np.max(locs) - # compute max and min motion (with interpolation) - # and check if channels are inside for all segment + # check if channels stay inside the probe extents for all segments channel_inside = np.ones(locs.shape[0], dtype="bool") - for operator, arg_operator in ((np.max,np.argmax), (np.min, np.argmin)): - for segment_index in range(recording.get_num_segments()): - ind = arg_operator(operator(motion.displacement[segment_index], axis=1)) - bin_time = motion.temporal_bins_s[segment_index][ind] - best_motions = motion.get_displacement_at_time_and_depth( - np.full(locs.shape[0], bin_time), locs, segment_index=segment_index - ) - channel_inside &= ((locs + best_motions) >= l0) & ((locs + best_motions) <= l1) - - - # if spatial_bins.shape[0] == 1: - # best_motions = operator(motion[:, 0]) - # else: - # # non rigid : interpolation channel motion for this temporal bin - # f = scipy.interpolate.interp1d( - # spatial_bins, - # operator(motion[:, :], axis=0), - # kind="linear", - # axis=0, - # bounds_error=False, - # fill_value="extrapolate", - # ) - # best_motions = f(locs) - # channel_inside &= ((locs + best_motions) >= l0) & ((locs + best_motions) <= l1) - - (channel_inds,) = np.nonzero(channel_inside) + for segment_index in range(recording.get_num_segments()): + # evaluate the positions of all channels over all time bins + channel_locations = motion.get_displacement_at_time_and_depth( + times_s=motion.temporal_bins_s[segment_index], + locations_um=locs, + grid=True, + ) + # check if these remain inside of the probe + seg_inside = channel_locations.clip(l0, l1) == channel_locations + seg_inside = seg_inside.all(axis=1) + channel_inside &= seg_inside + + channel_inds = np.flatnonzero(channel_inside) channel_ids = recording.channel_ids[channel_inds] spatial_interpolation_kwargs["force_extrapolate"] = False elif border_mode == "force_extrapolate": @@ -326,7 +327,10 @@ def __init__( else: raise ValueError("Wrong border_mode") - BasePreprocessor.__init__(self, recording, channel_ids=channel_ids) + if dtype is None and recording.dtype.kind != "f": + dtype = "float32" + dtype_ = fix_dtype(recording, dtype) + BasePreprocessor.__init__(self, recording, channel_ids=channel_ids, dtype=dtype_) if border_mode == "remove_channels": # change the wiring of the probe @@ -336,7 +340,23 @@ def __init__( contact_vector["device_channel_indices"] = np.arange(len(channel_ids), dtype="int64") self.set_property("contact_vector", contact_vector) - for parent_segment in recording._recording_segments: + # handle manual interpolation_time_bin_centers_s + # the case where interpolation_time_bin_size_s is set is handled per-segment below + if interpolation_time_bin_centers_s is None: + if interpolation_time_bin_size_s is None: + interpolation_time_bin_centers_s = motion.temporal_bins_s + + for segment_index, parent_segment in enumerate(recording._recording_segments): + # finish the per-segment part of the time bin logic + if interpolation_time_bin_centers_s is None: + # in this case, interpolation_time_bin_size_s is set. + s_end = parent_segment.get_num_samples() + t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end])) + halfbin = interpolation_time_bin_size_s / 2. + segment_interpolation_time_bins_s = np.arange(t_start + halfbin, t_end, interpolation_time_bin_size_s) + else: + segment_interpolation_time_bins_s = interpolation_time_bin_centers_s[segment_index] + rec_segment = InterpolateMotionRecordingSegment( parent_segment, channel_locations, @@ -344,6 +364,9 @@ def __init__( spatial_interpolation_method, spatial_interpolation_kwargs, channel_inds, + segment_index, + segment_interpolation_time_bins_s, + dtype=dtype_, ) self.add_recording_segment(rec_segment) @@ -355,6 +378,8 @@ def __init__( sigma_um=sigma_um, p=p, num_closest=num_closest, + interpolation_time_bin_centers_s=interpolation_time_bin_centers_s, + dtype=dtype_.str, ) @@ -367,49 +392,48 @@ def __init__( spatial_interpolation_method, spatial_interpolation_kwargs, channel_inds, + segment_index, + interpolation_time_bin_centers_s, + dtype="float32", ): BasePreprocessorSegment.__init__(self, parent_recording_segment) self.channel_locations = channel_locations - self.motion = motion self.spatial_interpolation_method = spatial_interpolation_method self.spatial_interpolation_kwargs = spatial_interpolation_kwargs self.channel_inds = channel_inds + self.segment_index = segment_index + self.interpolation_time_bin_centers_s = interpolation_time_bin_centers_s + self.dtype = dtype def get_traces(self, start_frame, end_frame, channel_indices): - if self.time_vector is not None: + if self.has_time_vector(): raise NotImplementedError( - "time_vector for InterpolateMotionRecording do not work because temporal_bins start from 0" + "InterpolateMotionRecording does not yet support recordings with time_vectors." ) - # times = np.asarray(self.time_vector[start_frame:end_frame]) if start_frame is None: start_frame = 0 if end_frame is None: end_frame = self.get_num_samples() - times = np.arange(end_frame - start_frame, dtype="float64") - times /= self.sampling_frequency - t0 = start_frame / self.sampling_frequency - # if self.t_start is not None: - # t0 = t0 + self.t_start - times += t0 - + times = self.parent_recording_segment.sample_index_to_time(np.arange(start_frame, end_frame)) traces = self.parent_recording_segment.get_traces(start_frame, end_frame, channel_indices=slice(None)) - - trace2 = interpolate_motion_on_traces( + traces = traces.astype(self.dtype) + traces = interpolate_motion_on_traces( traces, times, self.channel_locations, self.motion, channel_inds=self.channel_inds, - spatial_interpolation_method=self.spatial_interpolation_method, spatial_interpolation_kwargs=self.spatial_interpolation_kwargs, + interpolation_time_bin_centers_s=self.interpolation_time_bin_centers_s, + segment_index=self.segment_index, ) if channel_indices is not None: - trace2 = trace2[:, channel_indices] + traces = traces[:, channel_indices] - return trace2 + return traces -interpolate_motion = define_function_from_class(source_class=InterpolateMotionRecording, name="correct_motion") +interpolate_motion = define_function_from_class(source_class=InterpolateMotionRecording, name="interpolate_motion") From 47bb85b3027aa94538714ba06d434a0ee12b369b Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Wed, 29 May 2024 16:41:42 +0100 Subject: [PATCH 021/248] Small docs and cleaning --- .../sortingcomponents/motion_estimation.py | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion_estimation.py index 6925c7aede..3a8b75f8b3 100644 --- a/src/spikeinterface/sortingcomponents/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion_estimation.py @@ -1,9 +1,13 @@ from __future__ import annotations -import numpy as np from tqdm.auto import tqdm, trange + +import numpy as np import scipy.interpolate +from .motion_utils import Motion +from .tools import make_multi_method_doc + try: import torch import torch.nn.functional as F @@ -12,11 +16,6 @@ except ImportError: HAVE_TORCH = False -from .tools import make_multi_method_doc -from .motion_utils import Motion - - - def estimate_motion( recording, @@ -59,7 +58,7 @@ def estimate_motion( **histogram section** direction: "x" | "y" | "z", default: "y" - Dimension on which the motion is estimated + Dimension on which the motion is estimated. "y" is depth along the probe. bin_duration_s: float, default: 10 Bin duration in second bin_um: float, default: 10 @@ -157,7 +156,7 @@ def estimate_motion( ) # replace nan by zeros - motion_array[np.isnan(motion_array)] = 0 + np.nan_to_num(motion_array, copy=False) if post_clean: motion_array = clean_motion_vector( @@ -177,15 +176,12 @@ def estimate_motion( # TODO handle multi segment motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) - if output_extra_check: return motion, extra_check else: return motion - - class DecentralizedRegistration: """ Method developed by the Paninski's group from Columbia university: @@ -339,7 +335,7 @@ def run( extra_check["spatial_hist_bin_edges"] = spatial_hist_bin_edges # temporal bins are bin center - temporal_bins = temporal_hist_bin_edges[:-1] + bin_duration_s // 2.0 + temporal_bins = 0.5 * (temporal_hist_bin_edges[1:] + temporal_hist_bin_edges[:-1]) motion = np.zeros((temporal_bins.size, len(non_rigid_windows)), dtype=np.float64) windows_iter = non_rigid_windows @@ -822,7 +818,6 @@ def compute_pairwise_displacement( """ Compute pairwise displacement """ - from scipy import sparse from scipy import linalg assert conv_engine in ("torch", "numpy"), f"'conv_engine' must be 'torch' or 'numpy'" From 5f8d49ba8f99a49570e135714c1a763b6f2c37c0 Mon Sep 17 00:00:00 2001 From: r_pr Date: Thu, 30 May 2024 13:17:11 +0200 Subject: [PATCH 022/248] WIP: Proposal of format to hold the manual curation information Took 1 hour 17 minutes --- .../curation/curation_format.py | 34 +++ .../curation/tests/test_curation_format.py | 225 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/spikeinterface/curation/curation_format.py create mode 100644 src/spikeinterface/curation/tests/test_curation_format.py diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py new file mode 100644 index 0000000000..ef10fb2c74 --- /dev/null +++ b/src/spikeinterface/curation/curation_format.py @@ -0,0 +1,34 @@ +from itertools import combinations + + +def validate_curation_dict(curation_dict): + """ + Validate that the curation dictionary given as parameter complies with the format + + Parameters + ---------- + curation_dict : dict + + + Returns + ------- + + """ + + unit_set = set(curation_dict['unit_ids']) + labeled_unit_set = set([lbl['unit_id'] for lbl in curation_dict['manual_labels']]) + merged_units_set = set(sum(curation_dict['merged_unit_groups'], [])) + removed_units_set = set(curation_dict['removed_units']) + if not labeled_unit_set.issubset(unit_set): + raise ValueError("Some labeled units are not in the unit list") + if not merged_units_set.issubset(unit_set): + raise ValueError("Some merged units are not in the unit list") + if not removed_units_set.issubset(unit_set): + raise ValueError("Some removed units are not in the unit list") + all_merging_groups = [set(group) for group in curation_dict['merged_unit_groups']] + for gp_1, gp_2 in combinations(all_merging_groups, 2): + if len(gp_1.intersection(gp_2)) != 0: + raise ValueError("Some units belong to multiple merge groups") + if len(removed_units_set.intersection(merged_units_set)) != 0: + raise ValueError("Some units were merged and deleted") + return True \ No newline at end of file diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py new file mode 100644 index 0000000000..0470161220 --- /dev/null +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -0,0 +1,225 @@ +from spikeinterface.curation.curation_format import validate_curation_dict +import pytest + + +"""example = { + 'unit_ids': List[str, int], + 'labels_definition': { + 'category_key1': + {'name': str, + 'labels': List[str], + 'auto_eclusive': bool} + }, + 'manual_labels': [ + {'unit_id': str or int, + 'label_category_key': str, + 'label_category_value': list or str + } + ], + 'merged_unit_groups': List[List[unit_ids]], # one cell goes into at most one list + 'removed_units': List[unit_ids] # Can not be in the merged_units +} +""" + +valid_int = { + 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': 1, + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': 2, + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': 2, + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list + 'removed_units': [31, 42] # Can not be in the merged_units +} + + +valid_str = { + 'unit_ids': ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': "u1", + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': "u2", + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': "u2", + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list + 'removed_units': ["u31", "u42"] # Can not be in the merged_units +} + +# This is a failure example +duplicate_merge = { + 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': 1, + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': 2, + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': 2, + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list + 'removed_units': [31, 42] # Can not be in the merged_units +} + + +# This is a failure example +merged_and_removed = { + 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': 1, + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': 2, + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': 2, + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list + 'removed_units': [3, 31, 42] # Can not be in the merged_units +} + + +unknown_merged_unit = { + 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': 1, + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': 2, + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': 2, + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list + 'removed_units': [31, 42] # Can not be in the merged_units +} + + +unknown_removed_unit = { + 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], + 'labels_definition': { + 'quality': + {'name': 'quality', + 'labels': ['good', 'noise', 'MUA', 'artifact'], + 'auto_eclusive': True}, + 'experimental': + {'name': 'experimental', + 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], + 'auto_eclusive': False} + }, + 'manual_labels': [ + {'unit_id': 1, + 'label_category_key': 'quality', + 'label_category_value': 'good' + }, + {'unit_id': 2, + 'label_category_key': 'quality', + 'label_category_value': 'noise' + }, + {'unit_id': 2, + 'label_category_key': 'experimental', + 'label_category_value': ['chronic', 'headfixed'] + }, + ], + 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list + 'removed_units': [31, 42, 99] # Can not be in the merged_units +} + + +def test_curation_format_validation(): + assert validate_curation_dict(valid_int) + assert validate_curation_dict(valid_str) + with pytest.raises(ValueError): + # Raised because duplicated merged units + validate_curation_dict(duplicate_merge) + with pytest.raises(ValueError): + # Raised because Some units belong to multiple merge groups" + validate_curation_dict(merged_and_removed) + with pytest.raises(ValueError): + # Some merged units are not in the unit list + validate_curation_dict(unknown_merged_unit) + with pytest.raises(ValueError): + # Raise beecause Some removed units are not in the unit list + validate_curation_dict(unknown_removed_unit) From fa2493b17ca57fb8eb72787d87742dddab8204a1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 11:20:12 +0000 Subject: [PATCH 023/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../curation/curation_format.py | 12 +- .../curation/tests/test_curation_format.py | 234 +++++++----------- 2 files changed, 90 insertions(+), 156 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index ef10fb2c74..9b9c862cb6 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -15,20 +15,20 @@ def validate_curation_dict(curation_dict): """ - unit_set = set(curation_dict['unit_ids']) - labeled_unit_set = set([lbl['unit_id'] for lbl in curation_dict['manual_labels']]) - merged_units_set = set(sum(curation_dict['merged_unit_groups'], [])) - removed_units_set = set(curation_dict['removed_units']) + unit_set = set(curation_dict["unit_ids"]) + labeled_unit_set = set([lbl["unit_id"] for lbl in curation_dict["manual_labels"]]) + merged_units_set = set(sum(curation_dict["merged_unit_groups"], [])) + removed_units_set = set(curation_dict["removed_units"]) if not labeled_unit_set.issubset(unit_set): raise ValueError("Some labeled units are not in the unit list") if not merged_units_set.issubset(unit_set): raise ValueError("Some merged units are not in the unit list") if not removed_units_set.issubset(unit_set): raise ValueError("Some removed units are not in the unit list") - all_merging_groups = [set(group) for group in curation_dict['merged_unit_groups']] + all_merging_groups = [set(group) for group in curation_dict["merged_unit_groups"]] for gp_1, gp_2 in combinations(all_merging_groups, 2): if len(gp_1.intersection(gp_2)) != 0: raise ValueError("Some units belong to multiple merge groups") if len(removed_units_set.intersection(merged_units_set)) != 0: raise ValueError("Some units were merged and deleted") - return True \ No newline at end of file + return True diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 0470161220..6a6686f676 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -22,189 +22,123 @@ """ valid_int = { - 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': 1, - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': 2, - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': 2, - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list - 'removed_units': [31, 42] # Can not be in the merged_units + "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list + "removed_units": [31, 42], # Can not be in the merged_units } valid_str = { - 'unit_ids': ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': "u1", - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': "u2", - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': "u2", - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": "u1", "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": "u2", "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": "u2", "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list - 'removed_units': ["u31", "u42"] # Can not be in the merged_units + "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list + "removed_units": ["u31", "u42"], # Can not be in the merged_units } # This is a failure example duplicate_merge = { - 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': 1, - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': 2, - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': 2, - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list - 'removed_units': [31, 42] # Can not be in the merged_units + "merged_unit_groups": [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list + "removed_units": [31, 42], # Can not be in the merged_units } # This is a failure example merged_and_removed = { - 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': 1, - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': 2, - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': 2, - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list - 'removed_units': [3, 31, 42] # Can not be in the merged_units + "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list + "removed_units": [3, 31, 42], # Can not be in the merged_units } unknown_merged_unit = { - 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': 1, - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': 2, - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': 2, - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list - 'removed_units': [31, 42] # Can not be in the merged_units + "merged_unit_groups": [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list + "removed_units": [31, 42], # Can not be in the merged_units } unknown_removed_unit = { - 'unit_ids': [1, 2, 3, 6, 10, 14, 20, 31, 42], - 'labels_definition': { - 'quality': - {'name': 'quality', - 'labels': ['good', 'noise', 'MUA', 'artifact'], - 'auto_eclusive': True}, - 'experimental': - {'name': 'experimental', - 'labels': ['acute', 'chronic', 'headfixed', 'freelymoving'], - 'auto_eclusive': False} + "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], + "labels_definition": { + "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "experimental": { + "name": "experimental", + "labels": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_eclusive": False, + }, }, - 'manual_labels': [ - {'unit_id': 1, - 'label_category_key': 'quality', - 'label_category_value': 'good' - }, - {'unit_id': 2, - 'label_category_key': 'quality', - 'label_category_value': 'noise' - }, - {'unit_id': 2, - 'label_category_key': 'experimental', - 'label_category_value': ['chronic', 'headfixed'] - }, + "manual_labels": [ + {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, + {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, + {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, ], - 'merged_unit_groups': [[3, 6], [10, 14, 20]], # one cell goes into at most one list - 'removed_units': [31, 42, 99] # Can not be in the merged_units + "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list + "removed_units": [31, 42, 99], # Can not be in the merged_units } From cb5e2716cf1181f8dc24943f789a374272a70f88 Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Thu, 30 May 2024 14:10:28 +0100 Subject: [PATCH 024/248] Attach a SiNAPS probe to recording --- .../extractors/sinapsrecordingextractor.py | 42 ++++++++++++------- .../extractors/sinapsrecordingh5extractor.py | 14 +++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py index 05411a8f06..be048d8276 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -1,10 +1,11 @@ from pathlib import Path import numpy as np +from probeinterface import get_probe + from ..core import BinaryRecordingExtractor, ChannelSliceRecording from ..core.core_tools import define_function_from_class - class SinapsResearchPlatformRecordingExtractor(ChannelSliceRecording): extractor_name = "SinapsResearchPlatform" mode = "file" @@ -22,14 +23,15 @@ def __init__(self, file_path, stream_name="filt"): num_electrodes = meta["nbElectrodes"] sampling_frequency = meta["samplingFreq"] - channel_locations = meta["electrodePhysicalPosition"] + probe_type = meta['probeType'] + # channel_locations = meta["electrodePhysicalPosition"] # will be depricated soon by Sam, switching to probeinterface num_shanks = meta["nbShanks"] num_electrodes_per_shank = meta["nbElectrodesShank"] num_bits = int(np.log2(meta["nbADCLevels"])) - channel_groups = [] - for i in range(num_shanks): - channel_groups.extend([i] * num_electrodes_per_shank) + # channel_groups = [] + # for i in range(num_shanks): + # channel_groups.extend([i] * num_electrodes_per_shank) gain_ephys = meta["voltageConverter"] gain_aux = meta["voltageAUXConverter"] @@ -42,34 +44,44 @@ def __init__(self, file_path, stream_name="filt"): if stream_name == "raw": channel_slice = recording.channel_ids[:num_electrodes] renamed_channels = np.arange(num_electrodes) - locations = channel_locations - groups = channel_groups + # locations = channel_locations + # groups = channel_groups gain = gain_ephys elif stream_name == "filt": channel_slice = recording.channel_ids[num_electrodes : 2 * num_electrodes] renamed_channels = np.arange(num_electrodes) - locations = channel_locations - groups = channel_groups + # locations = channel_locations + # groups = channel_groups gain = gain_ephys elif stream_name == "aux": channel_slice = recording.channel_ids[2 * num_electrodes :] hw_chans = meta["hwAUXChannelName"][1:-1].split(",") user_chans = meta["userAuxName"][1:-1].split(",") renamed_channels = hw_chans + user_chans - locations = None - groups = None + # locations = None + # groups = None gain = gain_aux else: raise ValueError("stream_name must be 'raw', 'filt', or 'aux'") ChannelSliceRecording.__init__(self, recording, channel_ids=channel_slice, renamed_channel_ids=renamed_channels) - if locations is not None: - self.set_channel_locations(locations) - if groups is not None: - self.set_channel_groups(groups) + # if locations is not None: + # self.set_channel_locations(locations) + # if groups is not None: + # self.set_channel_groups(groups) + self.set_channel_gains(gain) self.set_channel_offsets(0) + if probe_type == 'p1024s1NHP': + probe = get_probe(manufacturer='sinaps', + probe_name='SiNAPS-p1024s1NHP') + # now wire the probe + channel_indices = np.arange(1024) + probe.set_device_channel_indices(channel_indices) + self.set_probe(probe,in_place=True) + else: + raise ValueError(f"Unknown probe type: {probe_type}") read_sinaps_research_platform = define_function_from_class( source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py index 2923011901..b20444c3c9 100644 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -1,6 +1,8 @@ from pathlib import Path import numpy as np +from probeinterface import get_probe + from ..core.core_tools import define_function_from_class from ..core import BaseRecording, BaseRecordingSegment @@ -45,6 +47,15 @@ def __init__(self, file_path): self._kwargs = {"file_path": str(Path(file_path).absolute())} + # set probe + if sinaps_info['probe_type'] == 'p1024s1NHP': + probe = get_probe(manufacturer='sinaps', + probe_name='SiNAPS-p1024s1NHP') + probe.set_device_channel_indices(np.arange(1024)) + self.set_probe(probe, in_place=True) + else: + raise ValueError(f"Unknown probe type: {sinaps_info['probe_type']}") + def __del__(self): self._rf.close() @@ -98,6 +109,8 @@ def openSiNAPSFile(filename): samplingRate = parameters.get('SamplingFrequency')[0] + probe_type = str(rf.require_group('Advanced Recording Parameters').require_group('Probe').get('probeType').asstr()[...]) + sinaps_info = { "filehandle": rf, "num_frames": nFrames, @@ -107,6 +120,7 @@ def openSiNAPSFile(filename): "gain": gain, "offset": offset, "dtype": dtype, + "probe_type": probe_type, } return sinaps_info From 102116361b56310420d1c5508dbffa3fd37522a4 Mon Sep 17 00:00:00 2001 From: r_pr Date: Thu, 30 May 2024 15:40:53 +0200 Subject: [PATCH 025/248] Feature: Conversion from sortingview format to the new proposed format Took 1 hour 7 minutes --- .../curation/curation_format.py | 76 +++++++++++++++++++ .../curation/tests/test_curation_format.py | 6 ++ 2 files changed, 82 insertions(+) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 9b9c862cb6..1857b970a6 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -15,6 +15,7 @@ def validate_curation_dict(curation_dict): """ + supported_versions = {1} unit_set = set(curation_dict["unit_ids"]) labeled_unit_set = set([lbl["unit_id"] for lbl in curation_dict["manual_labels"]]) merged_units_set = set(sum(curation_dict["merged_unit_groups"], [])) @@ -31,4 +32,79 @@ def validate_curation_dict(curation_dict): raise ValueError("Some units belong to multiple merge groups") if len(removed_units_set.intersection(merged_units_set)) != 0: raise ValueError("Some units were merged and deleted") + if curation_dict["format_version"] not in supported_versions: + raise ValueError(f"Format version ({curation_dict['format_version']}) not supported. " + f"Only {supported_versions} are valid") + # Check the labels exclusivity + for lbl in curation_dict["manual_labels"]: + lbl_key = lbl["label_category_key"] + is_exclusive = curation_dict["labels_definition"][lbl_key]["auto_eclusive"] + if is_exclusive and not isinstance(lbl["label_category_value"], str): + raise ValueError(f"{lbl_key} are mutually exclusive labels. {lbl['label_category_value']} is invalid") + elif not is_exclusive and not isinstance(lbl["label_category_value"], list): + raise ValueError(f"{lbl_key} are not mutually exclusive labels. " + f"{lbl['label_category_value']} should be a lists") return True + + +def convert_from_sortingview(sortingview_dict, destination_format=1): + """ + Converts the sortingview curation format into a curation dictionary + Couple of caveats: + * The list of units is not available in the original sortingview dictionary. We set it to None + * Labels can not be mutually exclusive. + * Labels have no category, so we regroup them under the "all_labels" category + + Parameters + ---------- + sortingview_dict : dict + Dictionary containing the curation information from sortingview + destination_format : int + Version of the format to use. + Default to 1 + + Returns + ------- + curation_dict: dict + A curation dictionary + """ + merge_groups = sortingview_dict["mergeGroups"] + merged_units = sum(merge_groups, []) + if len(merged_units) > 0: + unit_id_type = int if isinstance(merged_units[0], int) else str + else: + unit_id_type = str + all_units = [] + all_labels = [] + manual_labels = [] + general_cat = "all_labels" + for unit_id, l_labels in sortingview_dict["labelsByUnit"].items(): + all_labels.extend(l_labels) + u_id = unit_id_type(unit_id) + all_units.append(u_id) + manual_labels.append({'unit_id': u_id, "label_category_key": general_cat, + "label_category_value": l_labels}) + labels_def = {"all_labels": + {"name": "all_labels", + "labels": all_labels, + "auto_eclusive": False}} + + curation_dict = {"unit_ids": None, + "labels_definition": labels_def, + "manual_labels": manual_labels, + "merged_unit_groups": merge_groups, + "removed_units": [], + "format_version": destination_format} + + return curation_dict + + +if __name__ == "__main__": + import json + with open("src/spikeinterface/curation/tests/sv-sorting-curation-str.json") as jf: + sv_curation = json.load(jf) + cur_d = convert_from_sortingview(sortingview_dict=sv_curation) + + + + diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 6a6686f676..2263ced95c 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -38,6 +38,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units + "format_version": 1 } @@ -58,6 +59,7 @@ ], "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list "removed_units": ["u31", "u42"], # Can not be in the merged_units + "format_version": 1 } # This is a failure example @@ -78,6 +80,7 @@ ], "merged_unit_groups": [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units + "format_version": 1 } @@ -99,6 +102,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [3, 31, 42], # Can not be in the merged_units + "format_version": 1 } @@ -119,6 +123,7 @@ ], "merged_unit_groups": [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units + "format_version": 1 } @@ -139,6 +144,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42, 99], # Can not be in the merged_units + "format_version": 1 } From 8094053712b3736dc4c703ba7dd2fc499135cc80 Mon Sep 17 00:00:00 2001 From: r_pr Date: Thu, 30 May 2024 15:54:16 +0200 Subject: [PATCH 026/248] Renaming curation dictionary keys Took 10 minutes --- .../curation/curation_format.py | 22 ++--- .../curation/tests/test_curation_format.py | 94 +++++++++---------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 1857b970a6..a086d3e963 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -37,13 +37,13 @@ def validate_curation_dict(curation_dict): f"Only {supported_versions} are valid") # Check the labels exclusivity for lbl in curation_dict["manual_labels"]: - lbl_key = lbl["label_category_key"] - is_exclusive = curation_dict["labels_definition"][lbl_key]["auto_eclusive"] - if is_exclusive and not isinstance(lbl["label_category_value"], str): - raise ValueError(f"{lbl_key} are mutually exclusive labels. {lbl['label_category_value']} is invalid") - elif not is_exclusive and not isinstance(lbl["label_category_value"], list): + lbl_key = lbl["label_category"] + is_exclusive = curation_dict["label_definitions"][lbl_key]["auto_exclusive"] + if is_exclusive and not isinstance(lbl["labels"], str): + raise ValueError(f"{lbl_key} are mutually exclusive labels. {lbl['labels']} is invalid") + elif not is_exclusive and not isinstance(lbl["labels"], list): raise ValueError(f"{lbl_key} are not mutually exclusive labels. " - f"{lbl['label_category_value']} should be a lists") + f"{lbl['labels']} should be a lists") return True @@ -82,15 +82,15 @@ def convert_from_sortingview(sortingview_dict, destination_format=1): all_labels.extend(l_labels) u_id = unit_id_type(unit_id) all_units.append(u_id) - manual_labels.append({'unit_id': u_id, "label_category_key": general_cat, - "label_category_value": l_labels}) + manual_labels.append({'unit_id': u_id, "label_category": general_cat, + "labels": l_labels}) labels_def = {"all_labels": {"name": "all_labels", - "labels": all_labels, - "auto_eclusive": False}} + "label_options": all_labels, + "auto_exclusive": False}} curation_dict = {"unit_ids": None, - "labels_definition": labels_def, + "label_definitions": labels_def, "manual_labels": manual_labels, "merged_unit_groups": merge_groups, "removed_units": [], diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 2263ced95c..a626538148 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -4,16 +4,16 @@ """example = { 'unit_ids': List[str, int], - 'labels_definition': { + 'label_definitions': { 'category_key1': {'name': str, - 'labels': List[str], - 'auto_eclusive': bool} + 'label_options': List[str], + 'auto_exclusive': bool} }, 'manual_labels': [ {'unit_id': str or int, - 'label_category_key': str, - 'label_category_value': list or str + 'label_category': str, + 'labels': list or str } ], 'merged_unit_groups': List[List[unit_ids]], # one cell goes into at most one list @@ -23,18 +23,18 @@ valid_int = { "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": 1, "label_category": "quality", "labels": "good"}, + {"unit_id": 2, "label_category": "quality", "labels": "noise"}, + {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units @@ -44,18 +44,18 @@ valid_str = { "unit_ids": ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": "u1", "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": "u2", "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": "u2", "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": "u1", "label_category": "quality", "labels": "good"}, + {"unit_id": "u2", "label_category": "quality", "labels": "noise"}, + {"unit_id": "u2", "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list "removed_units": ["u31", "u42"], # Can not be in the merged_units @@ -65,18 +65,18 @@ # This is a failure example duplicate_merge = { "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": 1, "label_category": "quality", "labels": "good"}, + {"unit_id": 2, "label_category": "quality", "labels": "noise"}, + {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units @@ -87,18 +87,18 @@ # This is a failure example merged_and_removed = { "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": 1, "label_category": "quality", "labels": "good"}, + {"unit_id": 2, "label_category": "quality", "labels": "noise"}, + {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [3, 31, 42], # Can not be in the merged_units @@ -108,18 +108,18 @@ unknown_merged_unit = { "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": 1, "label_category": "quality", "labels": "good"}, + {"unit_id": 2, "label_category": "quality", "labels": "noise"}, + {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units @@ -129,18 +129,18 @@ unknown_removed_unit = { "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "labels_definition": { - "quality": {"name": "quality", "labels": ["good", "noise", "MUA", "artifact"], "auto_eclusive": True}, + "label_definitions": { + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, "experimental": { "name": "experimental", - "labels": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_eclusive": False, + "label_options": ["acute", "chronic", "headfixed", "freelymoving"], + "auto_exclusive": False, }, }, "manual_labels": [ - {"unit_id": 1, "label_category_key": "quality", "label_category_value": "good"}, - {"unit_id": 2, "label_category_key": "quality", "label_category_value": "noise"}, - {"unit_id": 2, "label_category_key": "experimental", "label_category_value": ["chronic", "headfixed"]}, + {"unit_id": 1, "label_category": "quality", "labels": "good"}, + {"unit_id": 2, "label_category": "quality", "labels": "noise"}, + {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42, 99], # Can not be in the merged_units From 5983194a372541380ccbac3031d8a0cd7a13b625 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 May 2024 13:54:45 +0000 Subject: [PATCH 027/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../curation/curation_format.py | 37 ++++++++----------- .../curation/tests/test_curation_format.py | 12 +++--- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index a086d3e963..43c7181baf 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -33,8 +33,9 @@ def validate_curation_dict(curation_dict): if len(removed_units_set.intersection(merged_units_set)) != 0: raise ValueError("Some units were merged and deleted") if curation_dict["format_version"] not in supported_versions: - raise ValueError(f"Format version ({curation_dict['format_version']}) not supported. " - f"Only {supported_versions} are valid") + raise ValueError( + f"Format version ({curation_dict['format_version']}) not supported. " f"Only {supported_versions} are valid" + ) # Check the labels exclusivity for lbl in curation_dict["manual_labels"]: lbl_key = lbl["label_category"] @@ -42,8 +43,7 @@ def validate_curation_dict(curation_dict): if is_exclusive and not isinstance(lbl["labels"], str): raise ValueError(f"{lbl_key} are mutually exclusive labels. {lbl['labels']} is invalid") elif not is_exclusive and not isinstance(lbl["labels"], list): - raise ValueError(f"{lbl_key} are not mutually exclusive labels. " - f"{lbl['labels']} should be a lists") + raise ValueError(f"{lbl_key} are not mutually exclusive labels. " f"{lbl['labels']} should be a lists") return True @@ -82,29 +82,24 @@ def convert_from_sortingview(sortingview_dict, destination_format=1): all_labels.extend(l_labels) u_id = unit_id_type(unit_id) all_units.append(u_id) - manual_labels.append({'unit_id': u_id, "label_category": general_cat, - "labels": l_labels}) - labels_def = {"all_labels": - {"name": "all_labels", - "label_options": all_labels, - "auto_exclusive": False}} - - curation_dict = {"unit_ids": None, - "label_definitions": labels_def, - "manual_labels": manual_labels, - "merged_unit_groups": merge_groups, - "removed_units": [], - "format_version": destination_format} + manual_labels.append({"unit_id": u_id, "label_category": general_cat, "labels": l_labels}) + labels_def = {"all_labels": {"name": "all_labels", "label_options": all_labels, "auto_exclusive": False}} + + curation_dict = { + "unit_ids": None, + "label_definitions": labels_def, + "manual_labels": manual_labels, + "merged_unit_groups": merge_groups, + "removed_units": [], + "format_version": destination_format, + } return curation_dict if __name__ == "__main__": import json + with open("src/spikeinterface/curation/tests/sv-sorting-curation-str.json") as jf: sv_curation = json.load(jf) cur_d = convert_from_sortingview(sortingview_dict=sv_curation) - - - - diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index a626538148..92fc963cef 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -38,7 +38,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } @@ -59,7 +59,7 @@ ], "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list "removed_units": ["u31", "u42"], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } # This is a failure example @@ -80,7 +80,7 @@ ], "merged_unit_groups": [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } @@ -102,7 +102,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [3, 31, 42], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } @@ -123,7 +123,7 @@ ], "merged_unit_groups": [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } @@ -144,7 +144,7 @@ ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42, 99], # Can not be in the merged_units - "format_version": 1 + "format_version": 1, } From 1f396f4f6258b0de9977fb09bc6162723ae5a9ee Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Thu, 30 May 2024 15:30:23 +0100 Subject: [PATCH 028/248] Fix unsigned to signed --- .../extractors/sinapsrecordingh5extractor.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py index b20444c3c9..94c6e74223 100644 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -5,8 +5,10 @@ from ..core.core_tools import define_function_from_class from ..core import BaseRecording, BaseRecordingSegment +from ..preprocessing import UnsignedToSignedRecording -class SinapsResearchPlatformH5RecordingExtractor(BaseRecording): + +class SinapsResearchPlatformH5RecordingExtractor_Unsigned(BaseRecording): extractor_name = "SinapsResearchPlatformH5" mode = "file" name = "sinaps_research_platform_h5" @@ -42,6 +44,7 @@ def __init__(self, file_path): # set gain self.set_channel_gains(sinaps_info["gain"]) self.set_channel_offsets(sinaps_info["offset"]) + self.num_bits = sinaps_info["num_bits"] # set other properties @@ -56,6 +59,7 @@ def __init__(self, file_path): else: raise ValueError(f"Unknown probe type: {sinaps_info['probe_type']}") + def __del__(self): self._rf.close() @@ -85,11 +89,21 @@ def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): traces = self._stream.get('FilteredData')[channel_indices, start_frame:end_frame].T return traces +class SinapsResearchPlatformH5RecordingExtractor(UnsignedToSignedRecording): + extractor_name = "SinapsResearchPlatformH5" + mode = "file" + name = "sinaps_research_platform_h5" + + def __init__(self, file_path): + recording = SinapsResearchPlatformH5RecordingExtractor_Unsigned(file_path) + UnsignedToSignedRecording.__init__(self, recording, bit_depth=recording.num_bits) + read_sinaps_research_platform_h5 = define_function_from_class( source_class=SinapsResearchPlatformH5RecordingExtractor, name="read_sinaps_research_platform_h5" ) + def openSiNAPSFile(filename): """Open an SiNAPS hdf5 file, read and return the recording info.""" @@ -103,13 +117,14 @@ def openSiNAPSFile(filename): parameters = rf.require_group('Parameters') gain = parameters.get('VoltageConverter')[0] - offset = -2048 * gain + offset = 0 nRecCh, nFrames = data.shape samplingRate = parameters.get('SamplingFrequency')[0] probe_type = str(rf.require_group('Advanced Recording Parameters').require_group('Probe').get('probeType').asstr()[...]) + num_bits = int(np.log2(rf.require_group('Advanced Recording Parameters').require_group('DAQ').get('nbADCLevels')[0])) sinaps_info = { "filehandle": rf, @@ -121,6 +136,7 @@ def openSiNAPSFile(filename): "offset": offset, "dtype": dtype, "probe_type": probe_type, + "num_bits": num_bits, } return sinaps_info From 5b82b3fec16ce3f0b8111caa6bef4455d589e85a Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Thu, 30 May 2024 15:46:18 +0100 Subject: [PATCH 029/248] Fix AUX channels which should not be attached to a probe --- .../extractors/sinapsrecordingextractor.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py index be048d8276..048c5d8e8a 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -73,15 +73,16 @@ def __init__(self, file_path, stream_name="filt"): self.set_channel_gains(gain) self.set_channel_offsets(0) - if probe_type == 'p1024s1NHP': - probe = get_probe(manufacturer='sinaps', - probe_name='SiNAPS-p1024s1NHP') - # now wire the probe - channel_indices = np.arange(1024) - probe.set_device_channel_indices(channel_indices) - self.set_probe(probe,in_place=True) - else: - raise ValueError(f"Unknown probe type: {probe_type}") + if (stream_name == 'filt') | (stream_name == 'raw'): + if (probe_type == 'p1024s1NHP'): + probe = get_probe(manufacturer='sinaps', + probe_name='SiNAPS-p1024s1NHP') + # now wire the probe + channel_indices = np.arange(1024) + probe.set_device_channel_indices(channel_indices) + self.set_probe(probe,in_place=True) + else: + raise ValueError(f"Unknown probe type: {probe_type}") read_sinaps_research_platform = define_function_from_class( source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" From d8ff88993e7a5b3de6e955361387f9fbb8f80be6 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Thu, 30 May 2024 15:46:46 +0100 Subject: [PATCH 030/248] Dtype handling --- .../sortingcomponents/motion_interpolation.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 4a5a2b0c47..b080e098f8 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -43,7 +43,9 @@ def correct_motion_on_peaks( spike_times = peaks["sample_index"][i0:i1] / sampling_frequency spike_locs = peak_locations[motion.direction][i0:i1] - spike_displacement = motion.get_displacement_at_time_and_depth(spike_times, spike_locs, segment_index=segment_index) + spike_displacement = motion.get_displacement_at_time_and_depth( + spike_times, spike_locs, segment_index=segment_index + ) corrected_peak_locations[i0:i1][motion.direction] -= spike_displacement @@ -60,6 +62,7 @@ def interpolate_motion_on_traces( interpolation_time_bin_centers_s=None, spatial_interpolation_method="kriging", spatial_interpolation_kwargs={}, + dtype=None, ): """ Apply inverse motion with spatial interpolation on traces. @@ -98,6 +101,11 @@ def interpolate_motion_on_traces( # assert HAVE_NUMBA assert times.shape[0] == traces.shape[0] + if dtype is None: + dtype = traces.dtype + if dtype.kind != "f": + raise ValueError(f"Can't interpolate traces of dtype {traces.dtype}.") + if segment_index is None: if motion.num_segments == 1: segment_index = 0 @@ -147,7 +155,7 @@ def interpolate_motion_on_traces( drift_kernel = get_spatial_interpolation_kernel( channel_locations, channel_locations_moved, - dtype=traces.dtype, + dtype=dtype, method=spatial_interpolation_method, **spatial_interpolation_kwargs, ) @@ -160,7 +168,9 @@ def interpolate_motion_on_traces( # plt.show() # quickly find the end of this bin, which is also the start of the next - next_start_index = current_start_index + np.searchsorted(bin_inds[current_start_index:], bin_ind + 1, side="left") + next_start_index = current_start_index + np.searchsorted( + bin_inds[current_start_index:], bin_ind + 1, side="left" + ) in_bin = slice(current_start_index, next_start_index) # here we use a simple np.matmul even if dirft_kernel can be super sparse. @@ -292,7 +302,8 @@ def __init__( channel_locations = recording.get_channel_locations() assert channel_locations.ndim >= motion.dim, ( - f"'direction' {motion.direction} not available. " f"Channel locations have {channel_locations.ndim} dimensions." + f"'direction' {motion.direction} not available. " + f"Channel locations have {channel_locations.ndim} dimensions." ) spatial_interpolation_kwargs = dict(sigma_um=sigma_um, p=p, num_closest=num_closest) if border_mode == "remove_channels": @@ -327,8 +338,13 @@ def __init__( else: raise ValueError("Wrong border_mode") - if dtype is None and recording.dtype.kind != "f": - dtype = "float32" + if dtype is None: + if recording.dtype.kind == "f": + dtype = recording.dtype + else: + raise ValueError( + f"Can't interpolate traces of recording with non-floating dtype={recording.dtype=}.") + dtype_ = fix_dtype(recording, dtype) BasePreprocessor.__init__(self, recording, channel_ids=channel_ids, dtype=dtype_) @@ -352,7 +368,7 @@ def __init__( # in this case, interpolation_time_bin_size_s is set. s_end = parent_segment.get_num_samples() t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end])) - halfbin = interpolation_time_bin_size_s / 2. + halfbin = interpolation_time_bin_size_s / 2.0 segment_interpolation_time_bins_s = np.arange(t_start + halfbin, t_end, interpolation_time_bin_size_s) else: segment_interpolation_time_bins_s = interpolation_time_bin_centers_s[segment_index] @@ -407,9 +423,7 @@ def __init__( def get_traces(self, start_frame, end_frame, channel_indices): if self.has_time_vector(): - raise NotImplementedError( - "InterpolateMotionRecording does not yet support recordings with time_vectors." - ) + raise NotImplementedError("InterpolateMotionRecording does not yet support recordings with time_vectors.") if start_frame is None: start_frame = 0 From 39f264313d099607addfe95662663b63a3adb8e6 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Thu, 30 May 2024 15:48:30 +0100 Subject: [PATCH 031/248] Dtype handling --- .../sortingcomponents/motion_interpolation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index b080e098f8..229152f4fa 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -103,8 +103,10 @@ def interpolate_motion_on_traces( if dtype is None: dtype = traces.dtype - if dtype.kind != "f": - raise ValueError(f"Can't interpolate traces of dtype {traces.dtype}.") + if dtype.kind != "f": + raise ValueError(f"Can't interpolate_motion with dtype {traces.dtype}.") + if traces.dtype != dtype: + traces = traces.astype(dtype) if segment_index is None: if motion.num_segments == 1: From 5f1b2a02cae462f0e273b7a74cd649b6a69f70b8 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Thu, 30 May 2024 15:48:58 +0100 Subject: [PATCH 032/248] Dtype handling --- src/spikeinterface/sortingcomponents/motion_interpolation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 229152f4fa..b9e11bc9bc 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -104,7 +104,7 @@ def interpolate_motion_on_traces( if dtype is None: dtype = traces.dtype if dtype.kind != "f": - raise ValueError(f"Can't interpolate_motion with dtype {traces.dtype}.") + raise ValueError(f"Can't interpolate_motion with dtype {dtype}.") if traces.dtype != dtype: traces = traces.astype(dtype) From 33e39e4da406d00700111be8f4804d5902cc9297 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Thu, 30 May 2024 16:28:11 +0100 Subject: [PATCH 033/248] Add a simple correctness test --- .../preprocessing/preprocessing_tools.py | 4 +- .../sortingcomponents/motion_interpolation.py | 16 ++++--- .../tests/test_motion_interpolation.py | 42 ++++++++++++------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/spikeinterface/preprocessing/preprocessing_tools.py b/src/spikeinterface/preprocessing/preprocessing_tools.py index c0b80c349b..942478fd71 100644 --- a/src/spikeinterface/preprocessing/preprocessing_tools.py +++ b/src/spikeinterface/preprocessing/preprocessing_tools.py @@ -80,7 +80,7 @@ def get_spatial_interpolation_kernel( elif method == "idw": distances = scipy.spatial.distance.cdist(source_location, target_location, metric="euclidean") - interpolation_kernel = np.zeros((source_location.shape[0], target_location.shape[0]), dtype="float64") + interpolation_kernel = np.zeros((source_location.shape[0], target_location.shape[0]), dtype=dtype) for c in range(target_location.shape[0]): ind_sorted = np.argsort(distances[:, c]) chan_closest = ind_sorted[:num_closest] @@ -97,7 +97,7 @@ def get_spatial_interpolation_kernel( elif method == "nearest": distances = scipy.spatial.distance.cdist(source_location, target_location, metric="euclidean") - interpolation_kernel = np.zeros((source_location.shape[0], target_location.shape[0]), dtype="float64") + interpolation_kernel = np.zeros((source_location.shape[0], target_location.shape[0]), dtype=dtype) for c in range(target_location.shape[0]): ind_closest = np.argmin(distances[:, c]) interpolation_kernel[ind_closest, c] = 1.0 diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index b9e11bc9bc..cbc24c83c3 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -6,7 +6,7 @@ from spikeinterface.preprocessing.basepreprocessor import ( BasePreprocessor, BasePreprocessorSegment) -from .filter import fix_dtype +from ..preprocessing.filter import fix_dtype def correct_motion_on_peaks( @@ -126,10 +126,11 @@ def interpolate_motion_on_traces( time_bins = interpolation_time_bin_centers_s if time_bins is None: time_bins = motion.temporal_bins_s[segment_index] - bin_s = time_bins[1] - time_bins + bin_s = time_bins[1] - time_bins[0] bins_start = time_bins[0] - 0.5 * bin_s # nearest bin center for each frame? bin_inds = (times - bins_start) // bin_s + bin_inds = bin_inds.astype(int) # the time bins may not cover the whole set of times in the recording, # so we need to clip these indices to the valid range np.clip(bin_inds, 0, time_bins.size, out=bin_inds) @@ -145,7 +146,7 @@ def interpolate_motion_on_traces( interp_times.fill(bin_time) channel_motions = motion.get_displacement_at_time_and_depth( interp_times, - channel_locations[motion.dim], + channel_locations[:, motion.dim], segment_index=segment_index, ) channel_locations_moved = channel_locations.copy() @@ -316,13 +317,14 @@ def __init__( channel_inside = np.ones(locs.shape[0], dtype="bool") for segment_index in range(recording.get_num_segments()): # evaluate the positions of all channels over all time bins - channel_locations = motion.get_displacement_at_time_and_depth( + channel_displacements = motion.get_displacement_at_time_and_depth( times_s=motion.temporal_bins_s[segment_index], locations_um=locs, grid=True, ) + channel_locations_moved = locs[:, None] + channel_displacements # check if these remain inside of the probe - seg_inside = channel_locations.clip(l0, l1) == channel_locations + seg_inside = channel_locations_moved.clip(l0, l1) == channel_locations_moved seg_inside = seg_inside.all(axis=1) channel_inside &= seg_inside @@ -422,9 +424,10 @@ def __init__( self.segment_index = segment_index self.interpolation_time_bin_centers_s = interpolation_time_bin_centers_s self.dtype = dtype + self.motion = motion def get_traces(self, start_frame, end_frame, channel_indices): - if self.has_time_vector(): + if self.time_vector is not None: raise NotImplementedError("InterpolateMotionRecording does not yet support recordings with time_vectors.") if start_frame is None: @@ -441,6 +444,7 @@ def get_traces(self, start_frame, end_frame, channel_indices): self.channel_locations, self.motion, channel_inds=self.channel_inds, + spatial_interpolation_method=self.spatial_interpolation_method, spatial_interpolation_kwargs=self.spatial_interpolation_kwargs, interpolation_time_bin_centers_s=self.interpolation_time_bin_centers_s, segment_index=self.segment_index, diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index 47f61f9ad6..b97040a740 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -1,19 +1,15 @@ -import pytest from pathlib import Path -import numpy as np +import numpy as np +import pytest +import spikeinterface.core as sc from spikeinterface import download_dataset - -from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.motion_interpolation import ( - correct_motion_on_peaks, - interpolate_motion_on_traces, - InterpolateMotionRecording, -) - + InterpolateMotionRecording, correct_motion_on_peaks, interpolate_motion, + interpolate_motion_on_traces) +from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.tests.common import make_dataset - if hasattr(pytest, "global_test_folder"): cache_folder = pytest.global_test_folder / "sortingcomponents" else: @@ -26,10 +22,10 @@ def make_fake_motion(rec): locs = rec.get_channel_locations() temporal_bins = np.arange(0.5, duration - 0.49, 0.5) spatial_bins = np.arange(locs[:, 1].min(), locs[:, 1].max(), 100) - displacament = np.zeros((temporal_bins.size, spatial_bins.size)) - displacament[:, :] = np.linspace(-30, 30, temporal_bins.size)[:, None] + displacement = np.zeros((temporal_bins.size, spatial_bins.size)) + displacement[:, :] = np.linspace(-30, 30, temporal_bins.size)[:, None] - motion = Motion([displacament], [temporal_bins], spatial_bins, direction="y") + motion = Motion([displacement], [temporal_bins], spatial_bins, direction="y") return motion @@ -62,7 +58,6 @@ def test_correct_motion_on_peaks(): # plt.show() - def test_interpolate_motion_on_traces(): rec, sorting = make_dataset() @@ -88,6 +83,24 @@ def test_interpolate_motion_on_traces(): assert traces.dtype == traces_corrected.dtype +def test_interpolation_simple(): + # a recording where a 1 moves at 1 chan per second. 30 chans 10 frames. + # there will be 9 chans of drift, so we add 9 chans of padding to the bottom + nt = nc0 = 10 # these need to be the same for this test + nc1 = nc0 + nc0 - 1 + traces = np.zeros((nt, nc1), dtype="float32") + traces[:, :nc0] = np.eye(nc0) + rec = sc.NumpyRecording(traces, sampling_frequency=1) + rec.set_dummy_probe_from_locations(np.c_[np.zeros(nc1), np.arange(nc1)]) + + true_motion = Motion(np.arange(nt)[:, None], 0.5 + np.arange(nt), np.zeros(1)) + rec_corrected = interpolate_motion(rec, true_motion, spatial_interpolation_method="nearest") + traces_corrected = rec_corrected.get_traces() + assert traces_corrected.shape == (nc0, nc0) + assert np.array_equal(traces_corrected[:, 0], np.ones(nt)) + assert np.array_equal(traces_corrected[:, 1:], np.zeros((nt, nc0 - 1))) + + def test_InterpolateMotionRecording(): rec, sorting = make_dataset() motion = make_fake_motion(rec) @@ -121,4 +134,5 @@ def test_InterpolateMotionRecording(): if __name__ == "__main__": # test_correct_motion_on_peaks() # test_interpolate_motion_on_traces() + test_interpolation_simple() test_InterpolateMotionRecording() From 2526a1b8980edd6c1aed46f8b1795cc63bdaadbd Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Fri, 31 May 2024 11:15:00 +0100 Subject: [PATCH 034/248] Fix _kwargs in extractors --- .../extractors/sinapsrecordingextractor.py | 3 ++- .../extractors/sinapsrecordingh5extractor.py | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py index 048c5d8e8a..c54ed8ddcd 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -83,12 +83,13 @@ def __init__(self, file_path, stream_name="filt"): self.set_probe(probe,in_place=True) else: raise ValueError(f"Unknown probe type: {probe_type}") + + self._kwargs = {"file_path": str(file_path.absolute())} read_sinaps_research_platform = define_function_from_class( source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" ) - def parse_sinaps_meta(meta_file): meta_dict = {} with open(meta_file) as f: diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py index 94c6e74223..96def456dd 100644 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -46,10 +46,6 @@ def __init__(self, file_path): self.set_channel_offsets(sinaps_info["offset"]) self.num_bits = sinaps_info["num_bits"] - # set other properties - - self._kwargs = {"file_path": str(Path(file_path).absolute())} - # set probe if sinaps_info['probe_type'] == 'p1024s1NHP': probe = get_probe(manufacturer='sinaps', @@ -58,6 +54,11 @@ def __init__(self, file_path): self.set_probe(probe, in_place=True) else: raise ValueError(f"Unknown probe type: {sinaps_info['probe_type']}") + + + # set other properties + + self._kwargs = {"file_path": str(Path(file_path).absolute())} def __del__(self): @@ -98,6 +99,8 @@ def __init__(self, file_path): recording = SinapsResearchPlatformH5RecordingExtractor_Unsigned(file_path) UnsignedToSignedRecording.__init__(self, recording, bit_depth=recording.num_bits) + self._kwargs = {"file_path": str(Path(file_path).absolute())} + read_sinaps_research_platform_h5 = define_function_from_class( source_class=SinapsResearchPlatformH5RecordingExtractor, name="read_sinaps_research_platform_h5" From 221afde68b3f22587a5483e7b642804c8f7599d0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 14:45:13 +0000 Subject: [PATCH 035/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/preprocessing/motion.py | 6 ++---- .../preprocessing/tests/test_motion.py | 2 +- .../sortingcomponents/motion_interpolation.py | 6 ++---- .../sortingcomponents/tests/common.py | 2 -- .../tests/test_motion_estimation.py | 2 +- .../tests/test_motion_interpolation.py | 7 +++++-- .../sortingcomponents/tests/test_motion_utils.py | 16 +++++++--------- 7 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index a5300ccadc..8b89e2f545 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -384,9 +384,7 @@ def correct_motion( t1 = time.perf_counter() run_times["estimate_motion"] = t1 - t0 - recording_corrected = InterpolateMotionRecording( - recording, motion, **interpolate_motion_kwargs - ) + recording_corrected = InterpolateMotionRecording(recording, motion, **interpolate_motion_kwargs) if folder is not None: (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") @@ -434,7 +432,7 @@ def load_motion_info(folder): motion_info[name] = np.load(folder / f"{name}.npy") else: motion_info[name] = None - + motion_info["motion"] = Motion.load(folder / "motion") return motion_info diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index d678b2d565..f42a64b90b 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -25,7 +25,7 @@ def test_estimate_and_correct_motion(): folder = cache_folder / "estimate_and_correct_motion" if folder.exists(): shutil.rmtree(folder) - + rec_corrected = correct_motion(rec, folder=folder) print(rec_corrected) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index cbc24c83c3..d0bbbddd71 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -3,8 +3,7 @@ import numpy as np from spikeinterface.core.core_tools import define_function_from_class from spikeinterface.preprocessing import get_spatial_interpolation_kernel -from spikeinterface.preprocessing.basepreprocessor import ( - BasePreprocessor, BasePreprocessorSegment) +from spikeinterface.preprocessing.basepreprocessor import BasePreprocessor, BasePreprocessorSegment from ..preprocessing.filter import fix_dtype @@ -346,8 +345,7 @@ def __init__( if recording.dtype.kind == "f": dtype = recording.dtype else: - raise ValueError( - f"Can't interpolate traces of recording with non-floating dtype={recording.dtype=}.") + raise ValueError(f"Can't interpolate traces of recording with non-floating dtype={recording.dtype=}.") dtype_ = fix_dtype(recording, dtype) BasePreprocessor.__init__(self, recording, channel_ids=channel_ids, dtype=dtype_) diff --git a/src/spikeinterface/sortingcomponents/tests/common.py b/src/spikeinterface/sortingcomponents/tests/common.py index 84d532d3aa..01e4445a13 100644 --- a/src/spikeinterface/sortingcomponents/tests/common.py +++ b/src/spikeinterface/sortingcomponents/tests/common.py @@ -3,7 +3,6 @@ from spikeinterface.core import generate_ground_truth_recording - def make_dataset(): # this replace the MEArec 10s file for testing recording, sorting = generate_ground_truth_recording( @@ -23,4 +22,3 @@ def make_dataset(): seed=2205, ) return recording, sorting - diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index e842d876a2..945aa6a09e 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -200,7 +200,7 @@ def test_estimate_motion(): # same params with differents engine should be the same motion0, motion1 = motions["rigid / decentralized / torch"], motions["rigid / decentralized / numpy"] - assert (motion0 == motion1) + assert motion0 == motion1 motion0, motion1 = ( motions["rigid / decentralized / torch / time_horizon_s"], diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index b97040a740..1de0337ec0 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -5,8 +5,11 @@ import spikeinterface.core as sc from spikeinterface import download_dataset from spikeinterface.sortingcomponents.motion_interpolation import ( - InterpolateMotionRecording, correct_motion_on_peaks, interpolate_motion, - interpolate_motion_on_traces) + InterpolateMotionRecording, + correct_motion_on_peaks, + interpolate_motion, + interpolate_motion_on_traces, +) from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.tests.common import make_dataset diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index a170245d7d..8a62ef324b 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -14,15 +14,13 @@ def test_Motion(): - temporal_bins_s = np.arange(0., 10., 1.) - spatial_bins_um = np.array([100., 200.]) + temporal_bins_s = np.arange(0.0, 10.0, 1.0) + spatial_bins_um = np.array([100.0, 200.0]) displacement = np.zeros((temporal_bins_s.shape[0], spatial_bins_um.shape[0])) displacement[:, :] = np.linspace(-20, 20, temporal_bins_s.shape[0])[:, np.newaxis] - motion = Motion( - displacement, temporal_bins_s, spatial_bins_um, direction="y" - ) + motion = Motion(displacement, temporal_bins_s, spatial_bins_um, direction="y") print(motion) # serialize with pickle before interpolation fit @@ -40,16 +38,16 @@ def test_Motion(): assert motion2.interpolator is None # do interpolate - displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11], [120., 80., 150.]) + displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11], [120.0, 80.0, 150.0]) # print(displacement) assert displacement.shape[0] == 3 # check clip - assert displacement[2] == 20. + assert displacement[2] == 20.0 # interpolate grid - displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, 15, 19], [150., 80.], grid=True) + displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11, 15, 19], [150.0, 80.0], grid=True) assert displacement.shape == (2, 5) - assert displacement[0, 2] == 20. + assert displacement[0, 2] == 20.0 # save/load to folder folder = cache_folder / "motion_saved" From 39fd14555c51a2e2510616d88953c039dcc27b76 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 11:29:37 -0400 Subject: [PATCH 036/248] Motion est/tests --- src/spikeinterface/preprocessing/motion.py | 8 +-- .../preprocessing/tests/test_motion.py | 9 +-- .../sortingcomponents/motion_utils.py | 8 +-- .../tests/test_motion_estimation.py | 55 ++++++++----------- .../tests/test_motion_utils.py | 8 +-- 5 files changed, 36 insertions(+), 52 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 8b89e2f545..9af21a76f2 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -1,15 +1,13 @@ from __future__ import annotations +import json import time from pathlib import Path import numpy as np -import json -import copy - -from spikeinterface.core import get_noise_levels, fix_job_kwargs -from spikeinterface.core.job_tools import _shared_job_kwargs_doc +from spikeinterface.core import fix_job_kwargs, get_noise_levels from spikeinterface.core.core_tools import SIJsonEncoder +from spikeinterface.core.job_tools import _shared_job_kwargs_doc motion_options_preset = { # This preset should be the most acccurate diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index f42a64b90b..2f045b7a68 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -1,14 +1,11 @@ -import pytest -from pathlib import Path - import shutil +from pathlib import Path +import numpy as np +import pytest from spikeinterface.core import generate_recording - from spikeinterface.preprocessing import correct_motion, load_motion_info -import numpy as np - if hasattr(pytest, "global_test_folder"): cache_folder = pytest.global_test_folder / "preprocessing" else: diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 71cde08689..9537b5bf1c 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -12,10 +12,10 @@ # * make simple test for Motion object with save/load DONE # * propagate to estimate_motion : DONE # * handle multi segment in estimate_motion(): maybe in another PR -# * propagate to motion_interpolation.py: ALMOST DONE -# * propagate to preprocessing/correct_motion(): ALMOST DONE -# * generate drifting signals for test estimate_motion and interpolate_motion -# * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff) +# * propagate to motion_interpolation.py: DONE +# * propagate to preprocessing/correct_motion(): DONE +# * generate drifting signals for test estimate_motion and interpolate_motion: SIMPLE ONE DONE? +# * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff): DONE # * delegate times to recording object in # * estimate motion # * correct_motion_on_peaks() diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index 945aa6a09e..87534ec1bf 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -1,18 +1,15 @@ -import pytest -from pathlib import Path import shutil +from pathlib import Path import numpy as np - -from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.sortingcomponents.motion_estimation import estimate_motion - - -from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording +import pytest from spikeinterface.core.node_pipeline import ExtractDenseWaveforms - -from spikeinterface.sortingcomponents.peak_localization import LocalizeCenterOfMass - +from spikeinterface.sortingcomponents.motion_estimation import estimate_motion +from spikeinterface.sortingcomponents.motion_interpolation import \ + InterpolateMotionRecording +from spikeinterface.sortingcomponents.peak_detection import detect_peaks +from spikeinterface.sortingcomponents.peak_localization import \ + LocalizeCenterOfMass from spikeinterface.sortingcomponents.tests.common import make_dataset if hasattr(pytest, "global_test_folder"): @@ -199,33 +196,25 @@ def test_estimate_motion(): plt.show() # same params with differents engine should be the same - motion0, motion1 = motions["rigid / decentralized / torch"], motions["rigid / decentralized / numpy"] + motion0 = motions["rigid / decentralized / torch"] + motion1 = motions["rigid / decentralized / numpy"] assert motion0 == motion1 - motion0, motion1 = ( - motions["rigid / decentralized / torch / time_horizon_s"], - motions["rigid / decentralized / numpy / time_horizon_s"], - ) - # TODO : later torch and numpy used to be the same - # assert np.testing.assert_almost_equal(motion0, motion1) + motion0 = motions["rigid / decentralized / torch / time_horizon_s"] + motion1 = motions["rigid / decentralized / numpy / time_horizon_s"], + np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) - motion0, motion1 = motions["non-rigid / decentralized / torch"], motions["non-rigid / decentralized / numpy"] - # TODO : later torch and numpy used to be the same - # assert np.testing.assert_almost_equal(motion0, motion1) + motion0 = motions["non-rigid / decentralized / torch"] + motion1 = motions["non-rigid / decentralized / numpy"] + np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) - motion0, motion1 = ( - motions["non-rigid / decentralized / torch / time_horizon_s"], - motions["non-rigid / decentralized / numpy / time_horizon_s"], - ) - # TODO : later torch and numpy used to be the same - # assert np.testing.assert_almost_equal(motion0, motion1) + motion0 = motions["non-rigid / decentralized / torch / time_horizon_s"] + motion1 = motions["non-rigid / decentralized / numpy / time_horizon_s"], + np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) - motion0, motion1 = ( - motions["non-rigid / decentralized / torch / spatial_prior"], - motions["non-rigid / decentralized / numpy / spatial_prior"], - ) - # TODO : later torch and numpy used to be the same - # assert np.testing.assert_almost_equal(motion0, motion1) + motion0 = motions["non-rigid / decentralized / torch / spatial_prior"] + motion1 = motions["non-rigid / decentralized / numpy / spatial_prior"] + np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) if __name__ == "__main__": diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 8a62ef324b..2fbbea0a25 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -25,17 +25,17 @@ def test_Motion(): # serialize with pickle before interpolation fit motion2 = pickle.loads(pickle.dumps(motion)) - assert motion2.interpolator is None + assert motion2.interpolators is None # serialize with pickle after interpolation fit motion.make_interpolators() - assert motion2.interpolator is not None + assert motion2.interpolators is not None motion2 = pickle.loads(pickle.dumps(motion)) - assert motion2.interpolator is not None + assert motion2.interpolators is not None # to/from dict motion2 = Motion(**motion.to_dict()) assert motion == motion2 - assert motion2.interpolator is None + assert motion2.interpolators is None # do interpolate displacement = motion.get_displacement_at_time_and_depth([2, 4.4, 11], [120.0, 80.0, 150.0]) From 63b851c6842c61c448d151d3049e77faaaebd75e Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 11:38:18 -0400 Subject: [PATCH 037/248] Delegate to sample_index_to_time() in estimation --- .../sortingcomponents/motion_estimation.py | 28 +++++++++---------- .../sortingcomponents/motion_utils.py | 2 +- .../tests/test_motion_estimation.py | 4 +-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion_estimation.py index 3a8b75f8b3..bede0a19bb 100644 --- a/src/spikeinterface/sortingcomponents/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion_estimation.py @@ -683,16 +683,15 @@ def make_2d_motion_histogram( spatial_bin_edges 1d array with spatial bin edges """ - fs = recording.get_sampling_frequency() - num_samples = recording.get_num_samples(segment_index=0) - bin_sample_size = int(bin_duration_s * fs) - sample_bin_edges = np.arange(0, num_samples + bin_sample_size, bin_sample_size) - temporal_bin_edges = sample_bin_edges / fs + n_samples = recording.get_num_samples() + mint_s = recording.sample_index_to_time(0) + maxt_s = recording.sample_index_to_time(n_samples) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) if spatial_bin_edges is None: spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) arr = np.zeros((peaks.size, 2), dtype="float64") - arr[:, 0] = peaks["sample_index"] + arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) arr[:, 1] = peak_locations[direction] if weight_with_amplitude: @@ -700,11 +699,11 @@ def make_2d_motion_histogram( else: weights = None - motion_histogram, edges = np.histogramdd(arr, bins=(sample_bin_edges, spatial_bin_edges), weights=weights) + motion_histogram, edges = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges), weights=weights) # average amplitude in each bin if weight_with_amplitude: - bin_counts, _ = np.histogramdd(arr, bins=(sample_bin_edges, spatial_bin_edges)) + bin_counts, _ = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges)) bin_counts[bin_counts == 0] = 1 motion_histogram = motion_histogram / bin_counts @@ -759,11 +758,10 @@ def make_3d_motion_histograms( spatial_bin_edges 1d array with spatial bin edges """ - fs = recording.get_sampling_frequency() - num_samples = recording.get_num_samples(segment_index=0) - bin_sample_size = int(bin_duration_s * fs) - sample_bin_edges = np.arange(0, num_samples + bin_sample_size, bin_sample_size) - temporal_bin_edges = sample_bin_edges / fs + n_samples = recording.get_num_samples() + mint_s = recording.sample_index_to_time(0) + maxt_s = recording.sample_index_to_time(n_samples) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) if spatial_bin_edges is None: spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) @@ -778,14 +776,14 @@ def make_3d_motion_histograms( ) arr = np.zeros((peaks.size, 3), dtype="float64") - arr[:, 0] = peaks["sample_index"] + arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) arr[:, 1] = peak_locations[direction] arr[:, 2] = abs_peaks_log_norm motion_histograms, edges = np.histogramdd( arr, bins=( - sample_bin_edges, + temporal_bin_edges, spatial_bin_edges, amplitude_bin_edges, ), diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 9537b5bf1c..0f19c2a2de 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -17,7 +17,7 @@ # * generate drifting signals for test estimate_motion and interpolate_motion: SIMPLE ONE DONE? # * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff): DONE # * delegate times to recording object in -# * estimate motion +# * estimate motion: DONE # * correct_motion_on_peaks() # * interpolate_motion_on_traces() # propagate to benchmark estimate motion diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index 87534ec1bf..7eea4e0bdd 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -201,7 +201,7 @@ def test_estimate_motion(): assert motion0 == motion1 motion0 = motions["rigid / decentralized / torch / time_horizon_s"] - motion1 = motions["rigid / decentralized / numpy / time_horizon_s"], + motion1 = motions["rigid / decentralized / numpy / time_horizon_s"] np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) motion0 = motions["non-rigid / decentralized / torch"] @@ -209,7 +209,7 @@ def test_estimate_motion(): np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) motion0 = motions["non-rigid / decentralized / torch / time_horizon_s"] - motion1 = motions["non-rigid / decentralized / numpy / time_horizon_s"], + motion1 = motions["non-rigid / decentralized / numpy / time_horizon_s"] np.testing.assert_array_almost_equal(motion0.displacement, motion1.displacement) motion0 = motions["non-rigid / decentralized / torch / spatial_prior"] From d99d05bbd68e7582c917b6281674b6c5975ca3b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 15:39:52 +0000 Subject: [PATCH 038/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sortingcomponents/tests/test_motion_estimation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index 7eea4e0bdd..d916102376 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -5,11 +5,9 @@ import pytest from spikeinterface.core.node_pipeline import ExtractDenseWaveforms from spikeinterface.sortingcomponents.motion_estimation import estimate_motion -from spikeinterface.sortingcomponents.motion_interpolation import \ - InterpolateMotionRecording +from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.sortingcomponents.peak_localization import \ - LocalizeCenterOfMass +from spikeinterface.sortingcomponents.peak_localization import LocalizeCenterOfMass from spikeinterface.sortingcomponents.tests.common import make_dataset if hasattr(pytest, "global_test_folder"): From d7b6a598a7e99b64053eaea1b686c9e81f2d1427 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 12:04:51 -0400 Subject: [PATCH 039/248] Add a test of time bin changing at interpolatino time --- .../sortingcomponents/motion_interpolation.py | 10 +++++--- .../tests/test_motion_estimation.py | 6 ++--- .../tests/test_motion_interpolation.py | 23 +++++++++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index d0bbbddd71..889e89446d 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -3,7 +3,8 @@ import numpy as np from spikeinterface.core.core_tools import define_function_from_class from spikeinterface.preprocessing import get_spatial_interpolation_kernel -from spikeinterface.preprocessing.basepreprocessor import BasePreprocessor, BasePreprocessorSegment +from spikeinterface.preprocessing.basepreprocessor import ( + BasePreprocessor, BasePreprocessorSegment) from ..preprocessing.filter import fix_dtype @@ -285,7 +286,7 @@ class InterpolateMotionRecording(BasePreprocessor): Recording after motion correction """ - name = "correct_motion" + name = "interpolate_motion" def __init__( self, @@ -299,6 +300,7 @@ def __init__( interpolation_time_bin_centers_s=None, interpolation_time_bin_size_s=None, dtype=None, + **spatial_interpolation_kwargs, ): # assert recording.get_num_segments() == 1, "correct_motion() is only available for single-segment recordings" @@ -307,7 +309,9 @@ def __init__( f"'direction' {motion.direction} not available. " f"Channel locations have {channel_locations.ndim} dimensions." ) - spatial_interpolation_kwargs = dict(sigma_um=sigma_um, p=p, num_closest=num_closest) + spatial_interpolation_kwargs = dict( + sigma_um=sigma_um, p=p, num_closest=num_closest, **spatial_interpolation_kwargs + ) if border_mode == "remove_channels": locs = channel_locations[:, motion.dim] l0, l1 = np.min(locs), np.max(locs) diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index d916102376..88908c5cc4 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -1,13 +1,12 @@ -import shutil from pathlib import Path import numpy as np import pytest from spikeinterface.core.node_pipeline import ExtractDenseWaveforms from spikeinterface.sortingcomponents.motion_estimation import estimate_motion -from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.sortingcomponents.peak_localization import LocalizeCenterOfMass +from spikeinterface.sortingcomponents.peak_localization import \ + LocalizeCenterOfMass from spikeinterface.sortingcomponents.tests.common import make_dataset if hasattr(pytest, "global_test_folder"): @@ -153,7 +152,6 @@ def test_estimate_motion(): ) kwargs.update(cases_kwargs) - job_kwargs = dict(progress_bar=False) motion, extra_check = estimate_motion(recording, peaks, peak_locations, **kwargs) motions[name] = motion diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index 1de0337ec0..ffed3e72fc 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -5,11 +5,8 @@ import spikeinterface.core as sc from spikeinterface import download_dataset from spikeinterface.sortingcomponents.motion_interpolation import ( - InterpolateMotionRecording, - correct_motion_on_peaks, - interpolate_motion, - interpolate_motion_on_traces, -) + InterpolateMotionRecording, correct_motion_on_peaks, interpolate_motion, + interpolate_motion_on_traces) from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.tests.common import make_dataset @@ -103,6 +100,22 @@ def test_interpolation_simple(): assert np.array_equal(traces_corrected[:, 0], np.ones(nt)) assert np.array_equal(traces_corrected[:, 1:], np.zeros((nt, nc0 - 1))) + # let's try a new version where we interpolate too slowly + rec_corrected = interpolate_motion( + rec, true_motion, spatial_interpolation_method="nearest", num_closest=2, interpolation_time_bin_size_s=2 + ) + traces_corrected = rec_corrected.get_traces() + assert traces_corrected.shape == (nc0, nc0) + # what happens with nearest here? + # well... due to rounding towards the nearest even number, the motion (which at + # these time bin centers is 0.5, 2.5, 4.5, ...) flips the signal's nearest + # neighbor back and forth between the first and second channels + assert np.all(traces_corrected[::2, 0] == 1) + assert np.all(traces_corrected[1::2, 0] == 0) + assert np.all(traces_corrected[1::2, 1] == 1) + assert np.all(traces_corrected[::2, 1] == 0) + assert np.all(traces_corrected[:, 2:] == 0) + def test_InterpolateMotionRecording(): rec, sorting = make_dataset() From 9907372a2934e71a29a6b641b56a431fa9c1340b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 16:05:19 +0000 Subject: [PATCH 040/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sortingcomponents/motion_interpolation.py | 3 +-- .../sortingcomponents/tests/test_motion_estimation.py | 3 +-- .../sortingcomponents/tests/test_motion_interpolation.py | 7 +++++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 889e89446d..1a827a7b5b 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -3,8 +3,7 @@ import numpy as np from spikeinterface.core.core_tools import define_function_from_class from spikeinterface.preprocessing import get_spatial_interpolation_kernel -from spikeinterface.preprocessing.basepreprocessor import ( - BasePreprocessor, BasePreprocessorSegment) +from spikeinterface.preprocessing.basepreprocessor import BasePreprocessor, BasePreprocessorSegment from ..preprocessing.filter import fix_dtype diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py index 88908c5cc4..7c25bc8923 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py @@ -5,8 +5,7 @@ from spikeinterface.core.node_pipeline import ExtractDenseWaveforms from spikeinterface.sortingcomponents.motion_estimation import estimate_motion from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.sortingcomponents.peak_localization import \ - LocalizeCenterOfMass +from spikeinterface.sortingcomponents.peak_localization import LocalizeCenterOfMass from spikeinterface.sortingcomponents.tests.common import make_dataset if hasattr(pytest, "global_test_folder"): diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index ffed3e72fc..3870517d5a 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -5,8 +5,11 @@ import spikeinterface.core as sc from spikeinterface import download_dataset from spikeinterface.sortingcomponents.motion_interpolation import ( - InterpolateMotionRecording, correct_motion_on_peaks, interpolate_motion, - interpolate_motion_on_traces) + InterpolateMotionRecording, + correct_motion_on_peaks, + interpolate_motion, + interpolate_motion_on_traces, +) from spikeinterface.sortingcomponents.motion_utils import Motion from spikeinterface.sortingcomponents.tests.common import make_dataset From 3387592c61663f1e4eed2f097521b5f18011d890 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 12:28:11 -0400 Subject: [PATCH 041/248] Update correct_motion_on_peaks to take a recording and delegate to sample_index_to_time --- .../benchmark/benchmark_motion_estimation.py | 16 +++++++--------- .../sortingcomponents/motion_interpolation.py | 9 ++++----- .../tests/test_motion_interpolation.py | 2 +- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 5d3c9c207a..9df9fe34c3 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -1,22 +1,20 @@ from __future__ import annotations import json +import pickle import time from pathlib import Path -import pickle +import matplotlib.pyplot as plt import numpy as np import scipy.interpolate - from spikeinterface.core import get_noise_levels +from spikeinterface.sortingcomponents.benchmark.benchmark_tools import ( + Benchmark, BenchmarkStudy, _simpleaxis) +from spikeinterface.sortingcomponents.motion_estimation import estimate_motion from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks -from spikeinterface.sortingcomponents.motion_estimation import estimate_motion -from spikeinterface.sortingcomponents.benchmark.benchmark_tools import Benchmark, BenchmarkStudy, _simpleaxis - - -import matplotlib.pyplot as plt +from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.widgets import plot_probe_map # import MEArec as mr @@ -670,7 +668,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # peak_locations_corrected = correct_motion_on_peaks( # self.selected_peaks, # self.peak_locations, -# self.recording.sampling_frequency, +# self.recording, # self.motion, # self.temporal_bins, # self.spatial_bins, diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 889e89446d..c65e94ee9a 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -12,7 +12,7 @@ def correct_motion_on_peaks( peaks, peak_locations, - sampling_frequency, + rec, motion, ): """ @@ -35,18 +35,16 @@ def correct_motion_on_peaks( Motion-corrected peak locations """ corrected_peak_locations = peak_locations.copy() + times_s = rec.sample_index_to_time(peaks["sample_index"]) for segment_index in range(motion.num_segments): i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) - # TODO delegate times to recording object - spike_times = peaks["sample_index"][i0:i1] / sampling_frequency + spike_times = times_s[i0:i1] spike_locs = peak_locations[motion.direction][i0:i1] - spike_displacement = motion.get_displacement_at_time_and_depth( spike_times, spike_locs, segment_index=segment_index ) - corrected_peak_locations[i0:i1][motion.direction] -= spike_displacement return corrected_peak_locations @@ -403,6 +401,7 @@ def __init__( interpolation_time_bin_centers_s=interpolation_time_bin_centers_s, dtype=dtype_.str, ) + self._kwargs.update(spatial_interpolation_kwargs) class InterpolateMotionRecordingSegment(BasePreprocessorSegment): diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index ffed3e72fc..cbfaa8adfb 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -43,7 +43,7 @@ def test_correct_motion_on_peaks(): corrected_peak_locations = correct_motion_on_peaks( peaks, peak_locations, - rec.sampling_frequency, + rec, motion, ) # print(corrected_peak_locations) From 97bda65474dbc78d22776e59a11facfd8653cc6c Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 12:28:46 -0400 Subject: [PATCH 042/248] Update todo list --- src/spikeinterface/sortingcomponents/motion_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 0f19c2a2de..1edf484aa4 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -18,8 +18,8 @@ # * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff): DONE # * delegate times to recording object in # * estimate motion: DONE -# * correct_motion_on_peaks() -# * interpolate_motion_on_traces() +# * correct_motion_on_peaks(): DONE +# * interpolate_motion_on_traces(): DONE # propagate to benchmark estimate motion # update plot_motion() dans widget From accdd0af708176d4495c9138f30251b6eb7dc86c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 16:29:17 +0000 Subject: [PATCH 043/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sortingcomponents/benchmark/benchmark_motion_estimation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 9df9fe34c3..4e8bf71044 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -9,8 +9,7 @@ import numpy as np import scipy.interpolate from spikeinterface.core import get_noise_levels -from spikeinterface.sortingcomponents.benchmark.benchmark_tools import ( - Benchmark, BenchmarkStudy, _simpleaxis) +from spikeinterface.sortingcomponents.benchmark.benchmark_tools import Benchmark, BenchmarkStudy, _simpleaxis from spikeinterface.sortingcomponents.motion_estimation import estimate_motion from spikeinterface.sortingcomponents.peak_detection import detect_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks From dd0edc8d71e906f3ba94a50940c5c5932682a265 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Fri, 31 May 2024 13:57:24 -0400 Subject: [PATCH 044/248] Fix test --- .../sortingcomponents/tests/test_motion_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 2fbbea0a25..84dda89d0d 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -21,15 +21,15 @@ def test_Motion(): displacement[:, :] = np.linspace(-20, 20, temporal_bins_s.shape[0])[:, np.newaxis] motion = Motion(displacement, temporal_bins_s, spatial_bins_um, direction="y") - print(motion) + assert motion.interpolators is None # serialize with pickle before interpolation fit motion2 = pickle.loads(pickle.dumps(motion)) assert motion2.interpolators is None # serialize with pickle after interpolation fit - motion.make_interpolators() + motion2.make_interpolators() assert motion2.interpolators is not None - motion2 = pickle.loads(pickle.dumps(motion)) + motion2 = pickle.loads(pickle.dumps(motion2)) assert motion2.interpolators is not None # to/from dict From abda223a4ee14e43d8a7cfe56a6b53aa972b0960 Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Sat, 1 Jun 2024 09:45:41 -0400 Subject: [PATCH 045/248] Clean/doc --- src/spikeinterface/preprocessing/motion.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 9af21a76f2..c2abf65692 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -275,11 +275,8 @@ def correct_motion( recording_corrected: Recording The motion corrected recording motion_info: dict - Optional output if `output_motion_info=True` + Optional output if `output_motion_info=True`. The key "motion" holds the Motion object. """ - - # TODO : Use motion object - # local import are important because "sortingcomponents" is not important by default from spikeinterface.sortingcomponents.peak_detection import detect_peaks, detect_peak_methods from spikeinterface.sortingcomponents.peak_selection import select_peaks From 0fde0d2eabd992e42a2b4ca4f8455dca89fb3766 Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 3 Jun 2024 11:48:40 +0100 Subject: [PATCH 046/248] Run black locally --- .../extractors/sinapsrecordingextractor.py | 22 ++++++------ .../extractors/sinapsrecordingh5extractor.py | 36 ++++++++++--------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py index c54ed8ddcd..1f35407c33 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractor.py @@ -6,6 +6,7 @@ from ..core import BinaryRecordingExtractor, ChannelSliceRecording from ..core.core_tools import define_function_from_class + class SinapsResearchPlatformRecordingExtractor(ChannelSliceRecording): extractor_name = "SinapsResearchPlatform" mode = "file" @@ -23,7 +24,7 @@ def __init__(self, file_path, stream_name="filt"): num_electrodes = meta["nbElectrodes"] sampling_frequency = meta["samplingFreq"] - probe_type = meta['probeType'] + probe_type = meta["probeType"] # channel_locations = meta["electrodePhysicalPosition"] # will be depricated soon by Sam, switching to probeinterface num_shanks = meta["nbShanks"] num_electrodes_per_shank = meta["nbElectrodesShank"] @@ -66,30 +67,31 @@ def __init__(self, file_path, stream_name="filt"): ChannelSliceRecording.__init__(self, recording, channel_ids=channel_slice, renamed_channel_ids=renamed_channels) # if locations is not None: - # self.set_channel_locations(locations) + # self.set_channel_locations(locations) # if groups is not None: - # self.set_channel_groups(groups) - + # self.set_channel_groups(groups) + self.set_channel_gains(gain) self.set_channel_offsets(0) - if (stream_name == 'filt') | (stream_name == 'raw'): - if (probe_type == 'p1024s1NHP'): - probe = get_probe(manufacturer='sinaps', - probe_name='SiNAPS-p1024s1NHP') + if (stream_name == "filt") | (stream_name == "raw"): + if probe_type == "p1024s1NHP": + probe = get_probe(manufacturer="sinaps", probe_name="SiNAPS-p1024s1NHP") # now wire the probe channel_indices = np.arange(1024) probe.set_device_channel_indices(channel_indices) - self.set_probe(probe,in_place=True) + self.set_probe(probe, in_place=True) else: raise ValueError(f"Unknown probe type: {probe_type}") - + self._kwargs = {"file_path": str(file_path.absolute())} + read_sinaps_research_platform = define_function_from_class( source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" ) + def parse_sinaps_meta(meta_file): meta_dict = {} with open(meta_file) as f: diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py index 96def456dd..dbfcb239fa 100644 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py @@ -17,6 +17,7 @@ def __init__(self, file_path): try: import h5py + self.installed = True except ImportError: self.installed = False @@ -47,36 +48,34 @@ def __init__(self, file_path): self.num_bits = sinaps_info["num_bits"] # set probe - if sinaps_info['probe_type'] == 'p1024s1NHP': - probe = get_probe(manufacturer='sinaps', - probe_name='SiNAPS-p1024s1NHP') + if sinaps_info["probe_type"] == "p1024s1NHP": + probe = get_probe(manufacturer="sinaps", probe_name="SiNAPS-p1024s1NHP") probe.set_device_channel_indices(np.arange(1024)) self.set_probe(probe, in_place=True) else: raise ValueError(f"Unknown probe type: {sinaps_info['probe_type']}") - # set other properties self._kwargs = {"file_path": str(Path(file_path).absolute())} - def __del__(self): self._rf.close() + class SiNAPSRecordingSegment(BaseRecordingSegment): def __init__(self, rf, num_frames, sampling_frequency): BaseRecordingSegment.__init__(self, sampling_frequency=sampling_frequency) self._rf = rf self._num_samples = int(num_frames) - self._stream = self._rf.require_group('RealTimeProcessedData') + self._stream = self._rf.require_group("RealTimeProcessedData") def get_num_samples(self): return self._num_samples def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): if isinstance(channel_indices, slice): - traces = self._stream.get('FilteredData')[channel_indices, start_frame:end_frame].T + traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T else: # channel_indices is np.ndarray if np.array(channel_indices).size > 1 and np.any(np.diff(channel_indices) < 0): @@ -84,12 +83,13 @@ def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): # to be indexed out of order sorted_channel_indices = np.sort(channel_indices) resorted_indices = np.array([list(sorted_channel_indices).index(ch) for ch in channel_indices]) - recordings = self._stream.get('FilteredData')[sorted_channel_indices, start_frame:end_frame].T + recordings = self._stream.get("FilteredData")[sorted_channel_indices, start_frame:end_frame].T traces = recordings[:, resorted_indices] else: - traces = self._stream.get('FilteredData')[channel_indices, start_frame:end_frame].T + traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T return traces + class SinapsResearchPlatformH5RecordingExtractor(UnsignedToSignedRecording): extractor_name = "SinapsResearchPlatformH5" mode = "file" @@ -109,25 +109,29 @@ def __init__(self, file_path): def openSiNAPSFile(filename): """Open an SiNAPS hdf5 file, read and return the recording info.""" - + import h5py rf = h5py.File(filename, "r") - stream = rf.require_group('RealTimeProcessedData') + stream = rf.require_group("RealTimeProcessedData") data = stream.get("FilteredData") dtype = data.dtype - parameters = rf.require_group('Parameters') - gain = parameters.get('VoltageConverter')[0] + parameters = rf.require_group("Parameters") + gain = parameters.get("VoltageConverter")[0] offset = 0 nRecCh, nFrames = data.shape - samplingRate = parameters.get('SamplingFrequency')[0] + samplingRate = parameters.get("SamplingFrequency")[0] - probe_type = str(rf.require_group('Advanced Recording Parameters').require_group('Probe').get('probeType').asstr()[...]) - num_bits = int(np.log2(rf.require_group('Advanced Recording Parameters').require_group('DAQ').get('nbADCLevels')[0])) + probe_type = str( + rf.require_group("Advanced Recording Parameters").require_group("Probe").get("probeType").asstr()[...] + ) + num_bits = int( + np.log2(rf.require_group("Advanced Recording Parameters").require_group("DAQ").get("nbADCLevels")[0]) + ) sinaps_info = { "filehandle": rf, From ab696e63065d82009ee949570a551583dfa9ffb3 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 4 Jun 2024 10:15:03 +0200 Subject: [PATCH 047/248] Fix tests --- .../sortingcomponents/benchmark/benchmark_motion_estimation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 2d62547778..86428cf1ee 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -5,7 +5,6 @@ import pickle import time -import matplotlib.pyplot as plt import numpy as np from spikeinterface.core import get_noise_levels @@ -289,6 +288,7 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): ax.set_ylim(0, lim) def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)): + import matplotlib.pyplot as plt if case_keys is None: case_keys = list(self.cases.keys()) From 7cfc6f99b8b5d10cb06d18fc0858a14c92d25271 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 4 Jun 2024 10:18:21 +0200 Subject: [PATCH 048/248] Fix imports in tests --- .../sortingcomponents/benchmark/benchmark_motion_estimation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 86428cf1ee..7428629c4a 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -10,7 +10,8 @@ from spikeinterface.core import get_noise_levels from spikeinterface.sortingcomponents.benchmark.benchmark_tools import Benchmark, BenchmarkStudy, _simpleaxis from spikeinterface.sortingcomponents.motion_estimation import estimate_motion -from spikeinterface.sortingcomponents.peak_detection import detect_peaks, select_peaks +from spikeinterface.sortingcomponents.peak_detection import detect_peaks +from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks from spikeinterface.widgets import plot_probe_map From 64453c6f532e5bc88b8615ec87c7562a0d16dace Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 5 Jun 2024 14:07:51 +0200 Subject: [PATCH 049/248] Update on the curation format. --- doc/modules/curation.rst | 105 ++++++++++++ .../curation/curation_format.py | 50 ++++-- .../curation/tests/test_curation_format.py | 155 ++++++------------ 3 files changed, 187 insertions(+), 123 deletions(-) diff --git a/doc/modules/curation.rst b/doc/modules/curation.rst index 401ceea5dc..83410494ba 100644 --- a/doc/modules/curation.rst +++ b/doc/modules/curation.rst @@ -41,6 +41,111 @@ The merging and splitting operations are handled by the :py:class:`~spikeinterfa # here is the final clean sorting clean_sorting = cs.sorting +Manual curation format +---------------------- + +SpikeInterface internally support a manual curation format JSON based. +When a mnual curation is necessary, modifying in place a dataset is a bad practice. +Instead, to keep the reproducibility in the spike sorting piepline, we introduce a manual curation format, +simple and JSON based. This format defines at the moment : merges + deletions + manual tags. +The simple file can be kept along side the output of a sorter and applied on the result to have a "clean" result. + +This format has two part: + + * **definition** with the folowing keys: + + * "format_version" : format specification + * "unit_ids" : give the list of unit_ds + * "label_definitions" : list of label category and possible labels per category. + Every category can be *exclusive=True* onely one label or *exclusive=False* several labels possible + + * **manual output** curation with the folowing keys: + + * "manual_labels" + * "merged_unit_groups" + * "removed_units" + +Here the description of the format with a simple example: + +.. code-block:: json + + + { + # the first part of the format is the definitation + "format_version": "1", + "unit_ids": [ + "u1", + "u2", + "u3", + "u6", + "u10", + "u14", + "u20", + "u31", + "u42" + ], + "label_definitions": { + "quality": { + "name": "quality", + "label_options": [ + "good", + "noise", + "MUA", + "artifact" + ], + "exclusive": true + }, + "experimental": { + "name": "experimental", + "label_options": [ + "acute", + "chronic", + "headfixed", + "freelymoving" + ], + "exclusive": false + } + }, + # the second part of the format is manual action + "manual_labels": [ + { + "unit_id": "u1", + "label_category": "quality", + "labels": "good" + }, + { + "unit_id": "u2", + "label_category": "quality", + "labels": "noise" + }, + { + "unit_id": "u2", + "label_category": "experimental", + "labels": [ + "chronic", + "headfixed" + ] + } + ], + "merged_unit_groups": [ + [ + "u3", + "u6" + ], + [ + "u10", + "u14", + "u20" + ] + ], + "removed_units": [ + "u31", + "u42" + ] + } + + + Automatic curation tools ------------------------ diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 43c7181baf..d25efaa6d8 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -1,10 +1,15 @@ from itertools import combinations +supported_curation_format_versions = {"1"} + + def validate_curation_dict(curation_dict): """ Validate that the curation dictionary given as parameter complies with the format + The function do not return anything. This raise an error if something is wring in the format. + Parameters ---------- curation_dict : dict @@ -12,39 +17,52 @@ def validate_curation_dict(curation_dict): Returns ------- + Nothing. + """ - supported_versions = {1} + # format + if "format_version" not in curation_dict: + raise ValueError("No version_format") + + if curation_dict["format_version"] not in supported_curation_format_versions: + raise ValueError( + f"Format version ({curation_dict['format_version']}) not supported. " f"Only {supported_curation_format_versions} are valid" + ) + + # unit_ids unit_set = set(curation_dict["unit_ids"]) labeled_unit_set = set([lbl["unit_id"] for lbl in curation_dict["manual_labels"]]) merged_units_set = set(sum(curation_dict["merged_unit_groups"], [])) removed_units_set = set(curation_dict["removed_units"]) if not labeled_unit_set.issubset(unit_set): - raise ValueError("Some labeled units are not in the unit list") + raise ValueError("Curation format: some labeled units are not in the unit list") if not merged_units_set.issubset(unit_set): - raise ValueError("Some merged units are not in the unit list") + raise ValueError("Curation format: some merged units are not in the unit list") if not removed_units_set.issubset(unit_set): - raise ValueError("Some removed units are not in the unit list") + raise ValueError("Curation format: some removed units are not in the unit list") + all_merging_groups = [set(group) for group in curation_dict["merged_unit_groups"]] for gp_1, gp_2 in combinations(all_merging_groups, 2): if len(gp_1.intersection(gp_2)) != 0: raise ValueError("Some units belong to multiple merge groups") if len(removed_units_set.intersection(merged_units_set)) != 0: raise ValueError("Some units were merged and deleted") - if curation_dict["format_version"] not in supported_versions: - raise ValueError( - f"Format version ({curation_dict['format_version']}) not supported. " f"Only {supported_versions} are valid" - ) + # Check the labels exclusivity for lbl in curation_dict["manual_labels"]: - lbl_key = lbl["label_category"] - is_exclusive = curation_dict["label_definitions"][lbl_key]["auto_exclusive"] - if is_exclusive and not isinstance(lbl["labels"], str): - raise ValueError(f"{lbl_key} are mutually exclusive labels. {lbl['labels']} is invalid") - elif not is_exclusive and not isinstance(lbl["labels"], list): - raise ValueError(f"{lbl_key} are not mutually exclusive labels. " f"{lbl['labels']} should be a lists") - return True + for label_key in curation_dict["label_definitions"].keys(): + if label_key in lbl: + unit_id = lbl["unit_id"] + label_value = lbl[label_key] + if not isinstance(label_value, list): + raise ValueError(f"Curation format: manual_labels {unit_id} is invalid shoudl be a list") + + is_exclusive = curation_dict["label_definitions"][label_key]["exclusive"] + + if is_exclusive and not len(label_value) <=1: + raise ValueError(f"Curation format: manual_labels {unit_id} {label_key} are exclusive labels. {label_value} is invalid") def convert_from_sortingview(sortingview_dict, destination_format=1): @@ -83,7 +101,7 @@ def convert_from_sortingview(sortingview_dict, destination_format=1): u_id = unit_id_type(unit_id) all_units.append(u_id) manual_labels.append({"unit_id": u_id, "label_category": general_cat, "labels": l_labels}) - labels_def = {"all_labels": {"name": "all_labels", "label_options": all_labels, "auto_exclusive": False}} + labels_def = {"all_labels": {"name": "all_labels", "label_options": all_labels, "exclusive": False}} curation_dict = { "unit_ids": None, diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 92fc963cef..6d3700b94a 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -1,6 +1,7 @@ from spikeinterface.curation.curation_format import validate_curation_dict import pytest +import json """example = { 'unit_ids': List[str, int], @@ -8,12 +9,11 @@ 'category_key1': {'name': str, 'label_options': List[str], - 'auto_exclusive': bool} + 'exclusive': bool} }, 'manual_labels': [ {'unit_id': str or int, - 'label_category': str, - 'labels': list or str + category_key1': List[str], } ], 'merged_unit_groups': List[List[unit_ids]], # one cell goes into at most one list @@ -21,136 +21,64 @@ } """ -valid_int = { + +curation_ids_int = { + "format_version": "1", "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, + "putative_type": {"name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral" ], "exclusive": False}, }, - }, "manual_labels": [ - {"unit_id": 1, "label_category": "quality", "labels": "good"}, - {"unit_id": 2, "label_category": "quality", "labels": "noise"}, - {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, + {"unit_id": 1, "quality": ["good"]}, + {"unit_id": 2, "quality": ["noise", ], "putative_type":["excitatory", "pyramidal"]}, + {"unit_id": 3, "putative_type": ["inhibitory"]}, ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1, + } - -valid_str = { +curation_ids_str = { + "format_version": "1", "unit_ids": ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, + "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, + "putative_type": {"name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral" ], "exclusive": False}, }, - }, "manual_labels": [ - {"unit_id": "u1", "label_category": "quality", "labels": "good"}, - {"unit_id": "u2", "label_category": "quality", "labels": "noise"}, - {"unit_id": "u2", "label_category": "experimental", "labels": ["chronic", "headfixed"]}, + {"unit_id": "u1", "quality": ["good"]}, + {"unit_id": "u2", "quality": ["noise", ], "putative_type":["excitatory", "pyramidal"]}, + {"unit_id": "u3", "putative_type": ["inhibitory"]}, ], "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list "removed_units": ["u31", "u42"], # Can not be in the merged_units - "format_version": 1, } -# This is a failure example -duplicate_merge = { - "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, - }, - }, - "manual_labels": [ - {"unit_id": 1, "label_category": "quality", "labels": "good"}, - {"unit_id": 2, "label_category": "quality", "labels": "noise"}, - {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, - ], - "merged_unit_groups": [[3, 6, 10], [10, 14, 20]], # one cell goes into at most one list - "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1, -} +# This is a failure example with duplicated merge +duplicate_merge = curation_ids_int.copy() +duplicate_merge["merged_unit_groups"] = [[3, 6, 10], [10, 14, 20]] -# This is a failure example -merged_and_removed = { - "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, - }, - }, - "manual_labels": [ - {"unit_id": 1, "label_category": "quality", "labels": "good"}, - {"unit_id": 2, "label_category": "quality", "labels": "noise"}, - {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, - ], - "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list - "removed_units": [3, 31, 42], # Can not be in the merged_units - "format_version": 1, -} +# This is a failure example with unit 3 both in removed and merged +merged_and_removed = curation_ids_int.copy() +merged_and_removed["merged_unit_groups"] = [[3, 6], [10, 14, 20]] +merged_and_removed["removed_units"] = [3, 31, 42] +# this is a failure because unit 99 is not in the initial list +unknown_merged_unit = curation_ids_int.copy() +unknown_merged_unit["merged_unit_groups"] = [[3, 6, 99], [10, 14, 20]] -unknown_merged_unit = { - "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, - }, - }, - "manual_labels": [ - {"unit_id": 1, "label_category": "quality", "labels": "good"}, - {"unit_id": 2, "label_category": "quality", "labels": "noise"}, - {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, - ], - "merged_unit_groups": [[3, 6, 99], [10, 14, 20]], # one cell goes into at most one list - "removed_units": [31, 42], # Can not be in the merged_units - "format_version": 1, -} +# this is a failure because unit 99 is not in the initial list +unknown_removed_unit = curation_ids_int.copy() +unknown_removed_unit["removed_units"] = [31, 42, 99] -unknown_removed_unit = { - "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], - "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "auto_exclusive": True}, - "experimental": { - "name": "experimental", - "label_options": ["acute", "chronic", "headfixed", "freelymoving"], - "auto_exclusive": False, - }, - }, - "manual_labels": [ - {"unit_id": 1, "label_category": "quality", "labels": "good"}, - {"unit_id": 2, "label_category": "quality", "labels": "noise"}, - {"unit_id": 2, "label_category": "experimental", "labels": ["chronic", "headfixed"]}, - ], - "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list - "removed_units": [31, 42, 99], # Can not be in the merged_units - "format_version": 1, -} +def test_curation_format_validation(): + validate_curation_dict(curation_ids_int) + validate_curation_dict(curation_ids_str) -def test_curation_format_validation(): - assert validate_curation_dict(valid_int) - assert validate_curation_dict(valid_str) with pytest.raises(ValueError): # Raised because duplicated merged units validate_curation_dict(duplicate_merge) @@ -163,3 +91,16 @@ def test_curation_format_validation(): with pytest.raises(ValueError): # Raise beecause Some removed units are not in the unit list validate_curation_dict(unknown_removed_unit) + + +def test_to_from_json(): + + json.loads(json.dumps(curation_ids_int, indent=4)) + json.loads(json.dumps(curation_ids_str, indent=4)) + + + + +if __name__ == "__main__": + test_curation_format_validation() + # test_to_from_json() From 5a4630bd05bc3b494635ada94cee6dafb7f303f1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:10:27 +0000 Subject: [PATCH 050/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../curation/curation_format.py | 13 +++++--- .../curation/tests/test_curation_format.py | 32 ++++++++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index d25efaa6d8..b32fca5ab9 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -18,7 +18,7 @@ def validate_curation_dict(curation_dict): Returns ------- Nothing. - + """ @@ -28,10 +28,11 @@ def validate_curation_dict(curation_dict): if curation_dict["format_version"] not in supported_curation_format_versions: raise ValueError( - f"Format version ({curation_dict['format_version']}) not supported. " f"Only {supported_curation_format_versions} are valid" + f"Format version ({curation_dict['format_version']}) not supported. " + f"Only {supported_curation_format_versions} are valid" ) - # unit_ids + # unit_ids unit_set = set(curation_dict["unit_ids"]) labeled_unit_set = set([lbl["unit_id"] for lbl in curation_dict["manual_labels"]]) merged_units_set = set(sum(curation_dict["merged_unit_groups"], [])) @@ -61,8 +62,10 @@ def validate_curation_dict(curation_dict): is_exclusive = curation_dict["label_definitions"][label_key]["exclusive"] - if is_exclusive and not len(label_value) <=1: - raise ValueError(f"Curation format: manual_labels {unit_id} {label_key} are exclusive labels. {label_value} is invalid") + if is_exclusive and not len(label_value) <= 1: + raise ValueError( + f"Curation format: manual_labels {unit_id} {label_key} are exclusive labels. {label_value} is invalid" + ) def convert_from_sortingview(sortingview_dict, destination_format=1): diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 6d3700b94a..a5a1418f32 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -27,16 +27,25 @@ "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], "label_definitions": { "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, - "putative_type": {"name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral" ], "exclusive": False}, + "putative_type": { + "name": "putative_type", + "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral"], + "exclusive": False, }, + }, "manual_labels": [ {"unit_id": 1, "quality": ["good"]}, - {"unit_id": 2, "quality": ["noise", ], "putative_type":["excitatory", "pyramidal"]}, + { + "unit_id": 2, + "quality": [ + "noise", + ], + "putative_type": ["excitatory", "pyramidal"], + }, {"unit_id": 3, "putative_type": ["inhibitory"]}, ], "merged_unit_groups": [[3, 6], [10, 14, 20]], # one cell goes into at most one list "removed_units": [31, 42], # Can not be in the merged_units - } curation_ids_str = { @@ -44,11 +53,21 @@ "unit_ids": ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], "label_definitions": { "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, - "putative_type": {"name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral" ], "exclusive": False}, + "putative_type": { + "name": "putative_type", + "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral"], + "exclusive": False, }, + }, "manual_labels": [ {"unit_id": "u1", "quality": ["good"]}, - {"unit_id": "u2", "quality": ["noise", ], "putative_type":["excitatory", "pyramidal"]}, + { + "unit_id": "u2", + "quality": [ + "noise", + ], + "putative_type": ["excitatory", "pyramidal"], + }, {"unit_id": "u3", "putative_type": ["inhibitory"]}, ], "merged_unit_groups": [["u3", "u6"], ["u10", "u14", "u20"]], # one cell goes into at most one list @@ -78,7 +97,6 @@ def test_curation_format_validation(): validate_curation_dict(curation_ids_int) validate_curation_dict(curation_ids_str) - with pytest.raises(ValueError): # Raised because duplicated merged units validate_curation_dict(duplicate_merge) @@ -99,8 +117,6 @@ def test_to_from_json(): json.loads(json.dumps(curation_ids_str, indent=4)) - - if __name__ == "__main__": test_curation_format_validation() # test_to_from_json() From 8ef0633a8248e1aba2c9f49afbd88ac0a4b0b333 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 5 Jun 2024 14:40:28 +0200 Subject: [PATCH 051/248] more tests for converting curation format --- src/spikeinterface/curation/__init__.py | 4 ++ .../curation/curation_format.py | 50 ++++++++++--------- .../curation/tests/test_curation_format.py | 28 ++++++++++- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/spikeinterface/curation/__init__.py b/src/spikeinterface/curation/__init__.py index 9c6e17edb5..db67479123 100644 --- a/src/spikeinterface/curation/__init__.py +++ b/src/spikeinterface/curation/__init__.py @@ -11,4 +11,8 @@ from .mergeunitssorting import MergeUnitsSorting, merge_units_sorting from .splitunitsorting import SplitUnitSorting, split_unit_sorting +# curation format +from .curation_format import validate_curation_dict + from .sortingview_curation import apply_sortingview_curation + diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index d25efaa6d8..b30a030c5f 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -31,17 +31,20 @@ def validate_curation_dict(curation_dict): f"Format version ({curation_dict['format_version']}) not supported. " f"Only {supported_curation_format_versions} are valid" ) - # unit_ids - unit_set = set(curation_dict["unit_ids"]) + # unit_ids labeled_unit_set = set([lbl["unit_id"] for lbl in curation_dict["manual_labels"]]) merged_units_set = set(sum(curation_dict["merged_unit_groups"], [])) removed_units_set = set(curation_dict["removed_units"]) - if not labeled_unit_set.issubset(unit_set): - raise ValueError("Curation format: some labeled units are not in the unit list") - if not merged_units_set.issubset(unit_set): - raise ValueError("Curation format: some merged units are not in the unit list") - if not removed_units_set.issubset(unit_set): - raise ValueError("Curation format: some removed units are not in the unit list") + + if curation_dict["unit_ids"] is not None: + # old format v0 did not contain unit_ids so this can contains None + unit_set = set(curation_dict["unit_ids"]) + if not labeled_unit_set.issubset(unit_set): + raise ValueError("Curation format: some labeled units are not in the unit list") + if not merged_units_set.issubset(unit_set): + raise ValueError("Curation format: some merged units are not in the unit list") + if not removed_units_set.issubset(unit_set): + raise ValueError("Curation format: some removed units are not in the unit list") all_merging_groups = [set(group) for group in curation_dict["merged_unit_groups"]] for gp_1, gp_2 in combinations(all_merging_groups, 2): @@ -65,9 +68,9 @@ def validate_curation_dict(curation_dict): raise ValueError(f"Curation format: manual_labels {unit_id} {label_key} are exclusive labels. {label_value} is invalid") -def convert_from_sortingview(sortingview_dict, destination_format=1): +def convert_from_sortingview_curation_format_v0(sortingview_dict, destination_format="1"): """ - Converts the sortingview curation format into a curation dictionary + Converts the old sortingview curation format (v0) into a curation dictionary new format (v1) Couple of caveats: * The list of units is not available in the original sortingview dictionary. We set it to None * Labels can not be mutually exclusive. @@ -77,15 +80,18 @@ def convert_from_sortingview(sortingview_dict, destination_format=1): ---------- sortingview_dict : dict Dictionary containing the curation information from sortingview - destination_format : int + destination_format : str Version of the format to use. - Default to 1 + Default to "1" Returns ------- curation_dict: dict A curation dictionary """ + + assert destination_format == "1" + merge_groups = sortingview_dict["mergeGroups"] merged_units = sum(merge_groups, []) if len(merged_units) > 0: @@ -96,28 +102,24 @@ def convert_from_sortingview(sortingview_dict, destination_format=1): all_labels = [] manual_labels = [] general_cat = "all_labels" - for unit_id, l_labels in sortingview_dict["labelsByUnit"].items(): + for unit_id_, l_labels in sortingview_dict["labelsByUnit"].items(): all_labels.extend(l_labels) - u_id = unit_id_type(unit_id) - all_units.append(u_id) - manual_labels.append({"unit_id": u_id, "label_category": general_cat, "labels": l_labels}) - labels_def = {"all_labels": {"name": "all_labels", "label_options": all_labels, "exclusive": False}} + # recorver the correct type for unit_id + unit_id = unit_id_type(unit_id_) + all_units.append(unit_id) + manual_labels.append({"unit_id": unit_id, general_cat: l_labels}) + labels_def = {"all_labels": {"name": "all_labels", "label_options": list(set(all_labels)), "exclusive": False}} curation_dict = { + "format_version": destination_format, "unit_ids": None, "label_definitions": labels_def, "manual_labels": manual_labels, "merged_unit_groups": merge_groups, "removed_units": [], - "format_version": destination_format, + } return curation_dict -if __name__ == "__main__": - import json - - with open("src/spikeinterface/curation/tests/sv-sorting-curation-str.json") as jf: - sv_curation = json.load(jf) - cur_d = convert_from_sortingview(sortingview_dict=sv_curation) diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 6d3700b94a..632ebc2fba 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -1,8 +1,13 @@ -from spikeinterface.curation.curation_format import validate_curation_dict import pytest +from pathlib import Path import json +from spikeinterface.curation.curation_format import validate_curation_dict, convert_from_sortingview_curation_format_v0 + + + + """example = { 'unit_ids': List[str, int], 'label_definitions': { @@ -99,8 +104,27 @@ def test_to_from_json(): json.loads(json.dumps(curation_ids_str, indent=4)) +def test_convert_from_sortingview_curation_format_v0(): + + parent_folder = Path(__file__).parent + for filename in ( + "sv-sorting-curation.json", + "sv-sorting-curation-int.json", + "sv-sorting-curation-str.json", + "sv-sorting-curation-false-positive.json", + ): + + json_file = parent_folder / filename + with open(json_file, "r") as f: + curation_v0 = json.load(f) + # print(curation_v0) + curation_v1 = convert_from_sortingview_curation_format_v0(curation_v0) + # print(curation_v1) + validate_curation_dict(curation_v1) + if __name__ == "__main__": - test_curation_format_validation() + # test_curation_format_validation() # test_to_from_json() + test_convert_from_sortingview_curation_format_v0() From 85dcea7d745b0ac3daa7036dea0602dc9b460092 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:42:56 +0000 Subject: [PATCH 052/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/curation/__init__.py | 1 - src/spikeinterface/curation/curation_format.py | 3 --- src/spikeinterface/curation/tests/test_curation_format.py | 3 --- 3 files changed, 7 deletions(-) diff --git a/src/spikeinterface/curation/__init__.py b/src/spikeinterface/curation/__init__.py index db67479123..f541ff8ca5 100644 --- a/src/spikeinterface/curation/__init__.py +++ b/src/spikeinterface/curation/__init__.py @@ -15,4 +15,3 @@ from .curation_format import validate_curation_dict from .sortingview_curation import apply_sortingview_curation - diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 85a689dfd0..3535bb67a5 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -120,9 +120,6 @@ def convert_from_sortingview_curation_format_v0(sortingview_dict, destination_fo "manual_labels": manual_labels, "merged_unit_groups": merge_groups, "removed_units": [], - } return curation_dict - - diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index d1d5120ffb..1945e4ca02 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -6,8 +6,6 @@ from spikeinterface.curation.curation_format import validate_curation_dict, convert_from_sortingview_curation_format_v0 - - """example = { 'unit_ids': List[str, int], 'label_definitions': { @@ -141,7 +139,6 @@ def test_convert_from_sortingview_curation_format_v0(): validate_curation_dict(curation_v1) - if __name__ == "__main__": # test_curation_format_validation() # test_to_from_json() From 127b42611b9a54a7ad9084905ba5ccc63f0931ac Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:48:52 +0100 Subject: [PATCH 053/248] Fix all numpydoc validate PR01 in preprocessing docstrings --- src/spikeinterface/preprocessing/astype.py | 16 ++++++++-- .../preprocessing/depth_order.py | 2 +- .../preprocessing/detect_bad_channels.py | 30 +++++++++---------- src/spikeinterface/preprocessing/filter.py | 21 +++++++++++-- .../preprocessing/normalize_scale.py | 2 +- .../preprocessing/phase_shift.py | 3 ++ src/spikeinterface/preprocessing/resample.py | 3 ++ .../preprocessing/silence_periods.py | 3 +- 8 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/preprocessing/astype.py b/src/spikeinterface/preprocessing/astype.py index da1435130c..4b0d5f9e55 100644 --- a/src/spikeinterface/preprocessing/astype.py +++ b/src/spikeinterface/preprocessing/astype.py @@ -14,8 +14,20 @@ class AstypeRecording(BasePreprocessor): For recording with an unsigned dtype, please use the `unsigned_to_signed` preprocessing function. - If `round` is True, will round the values to the nearest integer. - If `round` is None, will round in the case of float to integer conversion. + Parameters + ---------- + dtype : None | str | dtype, default: None + dtype of the output recording. + recording : Recording + The recording extractor to be converted. + round : Bool + If True, will round the values to the nearest integer. + If None, will round in the case of float to integer conversion. + + Returns + ------- + astype_recording : AstypeRecording + The converted recording extractor object """ name = "astype" diff --git a/src/spikeinterface/preprocessing/depth_order.py b/src/spikeinterface/preprocessing/depth_order.py index 9569459080..f08f6404da 100644 --- a/src/spikeinterface/preprocessing/depth_order.py +++ b/src/spikeinterface/preprocessing/depth_order.py @@ -12,7 +12,7 @@ class DepthOrderRecording(ChannelSliceRecording): Parameters ---------- - recording : BaseRecording + parent_recording : BaseRecording The recording to re-order. channel_ids : list/array or None If given, a subset of channels to order locations for diff --git a/src/spikeinterface/preprocessing/detect_bad_channels.py b/src/spikeinterface/preprocessing/detect_bad_channels.py index 276a8ac0b4..218c9cb822 100644 --- a/src/spikeinterface/preprocessing/detect_bad_channels.py +++ b/src/spikeinterface/preprocessing/detect_bad_channels.py @@ -57,29 +57,29 @@ def detect_bad_channels( The method to be used for bad channel detection std_mad_threshold : float, default: 5 The standard deviation/mad multiplier threshold - psd_hf_threshold (coeherence+psd) : float, default: 0.02 - An absolute threshold (uV^2/Hz) used as a cutoff for noise channels. + psd_hf_threshold : float, default: 0.02 + Coeherence+psd. An absolute threshold (uV^2/Hz) used as a cutoff for noise channels. Channels with average power at >80% Nyquist larger than this threshold will be labeled as noise - dead_channel_threshold (coeherence+psd) : float, default: -0.5 - Threshold for channel coherence below which channels are labeled as dead - noisy_channel_threshold (coeherence+psd) : float, default: 1 + dead_channel_threshold : float, default: -0.5 + Coeherence+psd. Threshold for channel coherence below which channels are labeled as dead + noisy_channel_threshold : float, default: 1 Threshold for channel coherence above which channels are labeled as noisy (together with psd condition) - outside_channel_threshold (coeherence+psd) : float, default: -0.75 - Threshold for channel coherence above which channels at the edge of the recording are marked as outside + outside_channel_threshold : float, default: -0.75 + Coeherence+psd. Threshold for channel coherence above which channels at the edge of the recording are marked as outside of the brain - outside_channels_location (coeherence+psd) : "top" | "bottom" | "both", default: "top" - Location of the outside channels. If "top", only the channels at the top of the probe can be + outside_channels_location : "top" | "bottom" | "both", default: "top" + Coeherence+psd. Location of the outside channels. If "top", only the channels at the top of the probe can be marked as outside channels. If "bottom", only the channels at the bottom of the probe can be marked as outside channels. If "both", both the channels at the top and bottom of the probe can be marked as outside channels - n_neighbors (coeherence+psd) : int, default: 11 - Number of channel neighbors to compute median filter (needs to be odd) - nyquist_threshold (coeherence+psd) : float, default: 0.8 - Frequency with respect to Nyquist (Fn=1) above which the mean of the PSD is calculated and compared + n_neighbors : int, default: 11 + Coeherence+psd. Number of channel neighbors to compute median filter (needs to be odd) + nyquist_threshold : float, default: 0.8 + Coeherence+psd. Frequency with respect to Nyquist (Fn=1) above which the mean of the PSD is calculated and compared with psd_hf_threshold - direction (coeherence+psd) : "x" | "y" | "z", default: "y" - The depth dimension + direction : "x" | "y" | "z", default: "y" + Coeherence+psd. The depth dimension highpass_filter_cutoff : float, default: 300 If the recording is not filtered, the cutoff frequency of the highpass filter chunk_duration_s : float, default: 0.5 diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index 3f1a155d0d..84ac542acc 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -43,11 +43,16 @@ class FilterRecording(BasePreprocessor): Filter form of the filter coefficients: - second-order sections ("sos") - numerator/denominator : ("ba") - coef : array or None, default: None + coeff : array | None, default: None Filter coefficients in the filter_mode form. dtype : dtype or None, default: None The dtype of the returned traces. If None, the dtype of the parent recording is used - {} + add_reflect_padding : Bool, default False + If True, uses a left and right margin during calculation. + ftype : str | None, default: "butter" + The type of IIR filter to design, used in `scipy.signal.iirfilter`. + filter_order : int, default: 5 + The order of the filter, used in `scipy.signal.iirfilter`. Returns ------- @@ -178,7 +183,9 @@ class BandpassFilterRecording(FilterRecording): Margin in ms on border to avoid border effect dtype : dtype or None The dtype of the returned traces. If None, the dtype of the parent recording is used - {} + **filter_kwargs : dict + Keyword arguments for `spikeinterface.preprocessing.FilterRecording` class. + Returns ------- filter_recording : BandpassFilterRecording @@ -212,6 +219,9 @@ class HighpassFilterRecording(FilterRecording): Margin in ms on border to avoid border effect dtype : dtype or None The dtype of the returned traces. If None, the dtype of the parent recording is used + **filter_kwargs : dict + Keyword arguments for `spikeinterface.preprocessing.FilterRecording` class. + {} Returns ------- @@ -240,6 +250,11 @@ class NotchFilterRecording(BasePreprocessor): The target frequency in Hz of the notch filter q : int The quality factor of the notch filter + dtype : None | dtype, default: None + dtype of recording. If None, will take from `recording` + margin_ms : float, default: 5.0 + Margin in ms on border to avoid border effect + {} Returns ------- diff --git a/src/spikeinterface/preprocessing/normalize_scale.py b/src/spikeinterface/preprocessing/normalize_scale.py index 44b9ac9937..e537be4694 100644 --- a/src/spikeinterface/preprocessing/normalize_scale.py +++ b/src/spikeinterface/preprocessing/normalize_scale.py @@ -54,7 +54,7 @@ class NormalizeByQuantileRecording(BasePreprocessor): Median for the output distribution q1 : float, default: 0.01 Lower quantile used for measuring the scale - q1 : float, default: 0.99 + q2 : float, default: 0.99 Upper quantile used for measuring the mode : "by_channel" | "pool_channel", default: "by_channel" If "by_channel" each channel is rescaled independently. diff --git a/src/spikeinterface/preprocessing/phase_shift.py b/src/spikeinterface/preprocessing/phase_shift.py index 41c18e2f38..ac308c975d 100644 --- a/src/spikeinterface/preprocessing/phase_shift.py +++ b/src/spikeinterface/preprocessing/phase_shift.py @@ -31,6 +31,9 @@ class PhaseShiftRecording(BasePreprocessor): inter_sample_shift : None or numpy array, default: None If "inter_sample_shift" is not in recording properties, we can externally provide one. + dtype : None | str | dtype, default: None + Dtype of input and output `recording` objects. + Returns ------- diff --git a/src/spikeinterface/preprocessing/resample.py b/src/spikeinterface/preprocessing/resample.py index cc110118a5..54a602b7c0 100644 --- a/src/spikeinterface/preprocessing/resample.py +++ b/src/spikeinterface/preprocessing/resample.py @@ -34,6 +34,9 @@ class ResampleRecording(BasePreprocessor): The dtype of the returned traces. If None, the dtype of the parent recording is used. skip_checks : bool, default: False If True, checks on sampling frequencies and cutoff filter frequencies are skipped + margin_ms : float, default: 100.0 + Margin in ms on border to avoid border effect + Returns ------- diff --git a/src/spikeinterface/preprocessing/silence_periods.py b/src/spikeinterface/preprocessing/silence_periods.py index 5f70bfbb40..88c7e2109c 100644 --- a/src/spikeinterface/preprocessing/silence_periods.py +++ b/src/spikeinterface/preprocessing/silence_periods.py @@ -25,7 +25,8 @@ class SilencedPeriodsRecording(BasePreprocessor): One list per segment of tuples (start_frame, end_frame) to silence noise_levels : array Noise levels if already computed - + seed : int | None, default: None + Random seed for `get_noise_levels` mode : "zeros" | "noise, default: "zeros" Determines what periods are replaced by. Can be one of the following: From 1d10bdc986456c02a05853f02f96b7e562130920 Mon Sep 17 00:00:00 2001 From: Garcia Samuel Date: Wed, 5 Jun 2024 15:10:43 +0200 Subject: [PATCH 054/248] Merci Zach Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- doc/modules/curation.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/modules/curation.rst b/doc/modules/curation.rst index 83410494ba..2c5bb84071 100644 --- a/doc/modules/curation.rst +++ b/doc/modules/curation.rst @@ -44,10 +44,10 @@ The merging and splitting operations are handled by the :py:class:`~spikeinterfa Manual curation format ---------------------- -SpikeInterface internally support a manual curation format JSON based. -When a mnual curation is necessary, modifying in place a dataset is a bad practice. -Instead, to keep the reproducibility in the spike sorting piepline, we introduce a manual curation format, -simple and JSON based. This format defines at the moment : merges + deletions + manual tags. +SpikeInterface internally supports a JSON-based manual curation format. +When manual curation is necessary, modifying a dataset in place is a bad practice. +Instead, to ensure the reproducibility of the spike sorting pipelines, we have introduced a simple and JSON-based manual curation format. +This format defines at the moment : merges + deletions + manual tags. The simple file can be kept along side the output of a sorter and applied on the result to have a "clean" result. This format has two part: @@ -55,8 +55,8 @@ This format has two part: * **definition** with the folowing keys: * "format_version" : format specification - * "unit_ids" : give the list of unit_ds - * "label_definitions" : list of label category and possible labels per category. + * "unit_ids" : the list of unit_ds + * "label_definitions" : list of label categories and possible labels per category. Every category can be *exclusive=True* onely one label or *exclusive=False* several labels possible * **manual output** curation with the folowing keys: @@ -65,7 +65,7 @@ This format has two part: * "merged_unit_groups" * "removed_units" -Here the description of the format with a simple example: +Here is the description of the format with a simple example: .. code-block:: json From 710eade8d75b65b9db5ad74624acae69b3ae1ce8 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 5 Jun 2024 15:40:35 +0200 Subject: [PATCH 055/248] curation_label_to_dataframe() --- src/spikeinterface/curation/__init__.py | 2 +- .../curation/curation_format.py | 41 +++++++++++++++++++ .../curation/tests/test_curation_format.py | 22 ++++++---- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/curation/__init__.py b/src/spikeinterface/curation/__init__.py index db67479123..1cfcfe2db2 100644 --- a/src/spikeinterface/curation/__init__.py +++ b/src/spikeinterface/curation/__init__.py @@ -12,7 +12,7 @@ from .splitunitsorting import SplitUnitSorting, split_unit_sorting # curation format -from .curation_format import validate_curation_dict +from .curation_format import validate_curation_dict, curation_label_to_dataframe from .sortingview_curation import apply_sortingview_curation diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 85a689dfd0..0ce4264010 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -126,3 +126,44 @@ def convert_from_sortingview_curation_format_v0(sortingview_dict, destination_fo return curation_dict +def curation_label_to_dataframe(curation_dict): + """ + Transform the curation dict into a pandas dataframe. + For label category with exclusive=True : a column is created and values are the unique label. + For label category with exclusive=False : one column per possible is created and values are boolean. + + If exclusive=False and the same label appear several times then it raises an error. + + Parameters + ---------- + curation_dict : dict + A curation dictionary + + Returns + ------- + labels : pd.DataFrame + dataframe with labels. + """ + import pandas as pd + labels = pd.DataFrame(index=curation_dict["unit_ids"]) + + for label_key, label_def in curation_dict["label_definitions"].items(): + if label_def["exclusive"]: + assert label_key not in labels.columns, f"{label_key} is already a column" + labels[label_key] = pd.Series(dtype=str) + labels[label_key][:] = "" + for lbl in curation_dict["manual_labels"]: + value = lbl.get(label_key, []) + if len(value) == 1: + labels.at[lbl["unit_id"], label_key] = value[0] + else: + for label_opt in label_def["label_options"]: + assert label_opt not in labels.columns, f"{label_opt} is already a column" + labels[label_opt] = pd.Series(dtype=bool) + labels[label_opt][:] = False + for lbl in curation_dict["manual_labels"]: + values = lbl.get(label_key, []) + for value in values: + labels.at[lbl["unit_id"], value] = True + + return labels diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index d1d5120ffb..3a4b2a7ec5 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -3,7 +3,7 @@ from pathlib import Path import json -from spikeinterface.curation.curation_format import validate_curation_dict, convert_from_sortingview_curation_format_v0 +from spikeinterface.curation.curation_format import validate_curation_dict, convert_from_sortingview_curation_format_v0, curation_label_to_dataframe @@ -12,7 +12,7 @@ 'unit_ids': List[str, int], 'label_definitions': { 'category_key1': - {'name': str, + { 'label_options': List[str], 'exclusive': bool} }, @@ -31,9 +31,8 @@ "format_version": "1", "unit_ids": [1, 2, 3, 6, 10, 14, 20, 31, 42], "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, + "quality": {"label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, "putative_type": { - "name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral"], "exclusive": False, }, @@ -57,9 +56,8 @@ "format_version": "1", "unit_ids": ["u1", "u2", "u3", "u6", "u10", "u14", "u20", "u31", "u42"], "label_definitions": { - "quality": {"name": "quality", "label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, + "quality": {"label_options": ["good", "noise", "MUA", "artifact"], "exclusive": True}, "putative_type": { - "name": "putative_type", "label_options": ["excitatory", "inhibitory", "pyramidal", "mitral"], "exclusive": False, }, @@ -140,9 +138,19 @@ def test_convert_from_sortingview_curation_format_v0(): # print(curation_v1) validate_curation_dict(curation_v1) +def test_curation_label_to_dataframe(): + + df = curation_label_to_dataframe(curation_ids_int) + assert "quality" in df.columns + assert "excitatory" in df.columns + print(df) + + df = curation_label_to_dataframe(curation_ids_str) + # print(df) if __name__ == "__main__": # test_curation_format_validation() # test_to_from_json() - test_convert_from_sortingview_curation_format_v0() + # test_convert_from_sortingview_curation_format_v0() + test_curation_label_to_dataframe() From 52857ccfc6a00aa3daef1e7d130cb498caecfc99 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:42:01 +0000 Subject: [PATCH 056/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/curation/curation_format.py | 5 +++-- src/spikeinterface/curation/tests/test_curation_format.py | 8 +++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index e6fbaac4f7..82921a56b5 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -132,7 +132,7 @@ def curation_label_to_dataframe(curation_dict): For label category with exclusive=False : one column per possible is created and values are boolean. If exclusive=False and the same label appear several times then it raises an error. - + Parameters ---------- curation_dict : dict @@ -144,6 +144,7 @@ def curation_label_to_dataframe(curation_dict): dataframe with labels. """ import pandas as pd + labels = pd.DataFrame(index=curation_dict["unit_ids"]) for label_key, label_def in curation_dict["label_definitions"].items(): @@ -164,5 +165,5 @@ def curation_label_to_dataframe(curation_dict): values = lbl.get(label_key, []) for value in values: labels.at[lbl["unit_id"], value] = True - + return labels diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 802d7ce49c..c691543414 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -3,7 +3,11 @@ from pathlib import Path import json -from spikeinterface.curation.curation_format import validate_curation_dict, convert_from_sortingview_curation_format_v0, curation_label_to_dataframe +from spikeinterface.curation.curation_format import ( + validate_curation_dict, + convert_from_sortingview_curation_format_v0, + curation_label_to_dataframe, +) """example = { @@ -136,6 +140,7 @@ def test_convert_from_sortingview_curation_format_v0(): # print(curation_v1) validate_curation_dict(curation_v1) + def test_curation_label_to_dataframe(): df = curation_label_to_dataframe(curation_ids_int) @@ -146,6 +151,7 @@ def test_curation_label_to_dataframe(): df = curation_label_to_dataframe(curation_ids_str) # print(df) + if __name__ == "__main__": # test_curation_format_validation() # test_to_from_json() From 22ff94dff957f874c395aa3b7c10edc85b1a20ae Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 5 Jun 2024 15:45:05 +0200 Subject: [PATCH 057/248] TODO for alessio in the code. --- src/spikeinterface/curation/sortingview_curation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/curation/sortingview_curation.py b/src/spikeinterface/curation/sortingview_curation.py index b31b8c39d5..267f1e423b 100644 --- a/src/spikeinterface/curation/sortingview_curation.py +++ b/src/spikeinterface/curation/sortingview_curation.py @@ -5,7 +5,8 @@ from .curationsorting import CurationSorting - +# @alessio +# TODO later : this should be reimplemented using the new curation format def apply_sortingview_curation( sorting, uri_or_json, exclude_labels=None, include_labels=None, skip_merge=False, verbose=False ): From a9c5aadc3e3a8833c782abe6ba3a4b2734d013f8 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 5 Jun 2024 15:50:01 +0200 Subject: [PATCH 058/248] oups --- doc/modules/curation.rst | 36 +++++++++---------- .../curation/tests/test_curation_format.py | 4 ++- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/doc/modules/curation.rst b/doc/modules/curation.rst index 2c5bb84071..46fdcc6d65 100644 --- a/doc/modules/curation.rst +++ b/doc/modules/curation.rst @@ -69,7 +69,6 @@ Here is the description of the format with a simple example: .. code-block:: json - { # the first part of the format is the definitation "format_version": "1", @@ -86,7 +85,6 @@ Here is the description of the format with a simple example: ], "label_definitions": { "quality": { - "name": "quality", "label_options": [ "good", "noise", @@ -95,13 +93,12 @@ Here is the description of the format with a simple example: ], "exclusive": true }, - "experimental": { - "name": "experimental", + "putative_type": { "label_options": [ - "acute", - "chronic", - "headfixed", - "freelymoving" + "excitatory", + "inhibitory", + "pyramidal", + "mitral" ], "exclusive": false } @@ -110,20 +107,24 @@ Here is the description of the format with a simple example: "manual_labels": [ { "unit_id": "u1", - "label_category": "quality", - "labels": "good" + "quality": [ + "good" + ] }, { "unit_id": "u2", - "label_category": "quality", - "labels": "noise" + "quality": [ + "noise" + ], + "putative_type": [ + "excitatory", + "pyramidal" + ] }, { - "unit_id": "u2", - "label_category": "experimental", - "labels": [ - "chronic", - "headfixed" + "unit_id": "u3", + "putative_type": [ + "inhibitory" ] } ], @@ -146,7 +147,6 @@ Here is the description of the format with a simple example: - Automatic curation tools ------------------------ diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 802d7ce49c..778091664c 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -150,4 +150,6 @@ def test_curation_label_to_dataframe(): # test_curation_format_validation() # test_to_from_json() # test_convert_from_sortingview_curation_format_v0() - test_curation_label_to_dataframe() + # test_curation_label_to_dataframe() + + print(json.dumps(curation_ids_str, indent=4)) From 6eef836a16c1d17d96266d60828d6d7c151382c3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:50:37 +0000 Subject: [PATCH 059/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/curation/sortingview_curation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spikeinterface/curation/sortingview_curation.py b/src/spikeinterface/curation/sortingview_curation.py index 267f1e423b..c4d2a32958 100644 --- a/src/spikeinterface/curation/sortingview_curation.py +++ b/src/spikeinterface/curation/sortingview_curation.py @@ -5,6 +5,7 @@ from .curationsorting import CurationSorting + # @alessio # TODO later : this should be reimplemented using the new curation format def apply_sortingview_curation( From 3e5d1a429d16ba3a80842d1279d821161ee3e939 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 17:51:48 +0100 Subject: [PATCH 060/248] Use pytest fixture instead of unittest, base class and test_amplitude_scalings. --- .../tests/common_extension_tests.py | 23 ++++++++++++++----- .../tests/test_amplitude_scalings.py | 4 ++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index 605997f5f6..706918187f 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -73,11 +73,24 @@ class AnalyzerExtensionCommonTestSuite: extension_class = None extension_function_params_list = None - @classmethod - def setUpClass(cls): - cls.recording, cls.sorting = get_dataset() + @pytest.fixture(autouse=True, scope="class") + def setUpClass(self): + """ + This method sets up the class once at the start of testing. It is + in scope for the lifetime of te class and is reused across all + tests that inherit from this base class to save processing time and + force a small radius. + + When setting attributes on `self` in `scope="class"` a new + class instance is used for each. In this case, we have to set + from the base object `__class__` to ensure the attributes + are available to all subclass instances. + """ + self.__class__.recording, self.__class__.sorting = get_dataset() # sparsity is computed once for all cases to save processing time and force a small radius - cls.sparsity = estimate_sparsity(cls.recording, cls.sorting, method="radius", radius_um=20) + self.__class__.sparsity = estimate_sparsity( + self.__class__.recording, self.__class__.sorting, method="radius", radius_um=20 + ) @property def extension_name(self): @@ -114,12 +127,10 @@ def _check_one(self, sorting_analyzer): some_unit_ids = sorting_analyzer.unit_ids[::2] sliced = sorting_analyzer.select_units(some_unit_ids, format="memory") assert np.array_equal(sliced.unit_ids, sorting_analyzer.unit_ids[::2]) - # print(sliced) def test_extension(self): for sparse in (True, False): for format in ("memory", "binary_folder", "zarr"): - print() print("sparse", sparse, format) sorting_analyzer = self._prepare_sorting_analyzer(format, sparse) self._check_one(sorting_analyzer) diff --git a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py index b59aca16a8..f5ef0db956 100644 --- a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py @@ -7,7 +7,7 @@ from spikeinterface.postprocessing import ComputeAmplitudeScalings -class AmplitudeScalingsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestAmplitudeScalingsExtension(AnalyzerExtensionCommonTestSuite): extension_class = ComputeAmplitudeScalings extension_function_params_list = [ dict(handle_collisions=True), @@ -36,7 +36,7 @@ def test_scaling_values(self): if __name__ == "__main__": - test = AmplitudeScalingsExtensionTest() + test = TestAmplitudeScalingsExtension() test.setUpClass() test.test_extension() test.test_scaling_values() From f0de1fd6357d4ee976a68d03421d549a5581e44e Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 17:55:44 +0100 Subject: [PATCH 061/248] test_correlograms.py --- .../postprocessing/tests/common_extension_tests.py | 2 ++ src/spikeinterface/postprocessing/tests/test_correlograms.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index 706918187f..d8575cbeb2 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -115,6 +115,8 @@ def _check_one(self, sorting_analyzer): else: job_kwargs = dict() + # TODO: a downside of this approach is each parameterisation does + # not get it's own test, but all falls under the same test. for params in self.extension_function_params_list: print(" params", params) ext = sorting_analyzer.compute(self.extension_name, **params, **job_kwargs) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index 6d727e6448..56b4032630 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -16,7 +16,7 @@ from spikeinterface.postprocessing.correlograms import compute_correlograms_on_sorting, _make_bins -class ComputeCorrelogramsTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestComputeCorrelograms(AnalyzerExtensionCommonTestSuite): extension_class = ComputeCorrelograms extension_function_params_list = [ dict(method="numpy"), From 471d0c8f5b7c6e27293acc486b5442c3d2f950f4 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:01:10 +0100 Subject: [PATCH 062/248] test_isi.py --- src/spikeinterface/postprocessing/tests/test_isi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 8626e56453..3eff96ebfb 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -16,7 +16,7 @@ HAVE_NUMBA = False -class ComputeISIHistogramsTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestComputeISIHistograms(AnalyzerExtensionCommonTestSuite): extension_class = ComputeISIHistograms extension_function_params_list = [ dict(method="numpy"), From 38ee32284cb34916a71d4dc79bd964b47f5c2aa3 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:02:18 +0100 Subject: [PATCH 063/248] Add test noise levels note. --- src/spikeinterface/postprocessing/tests/test_noise_levels.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/postprocessing/tests/test_noise_levels.py b/src/spikeinterface/postprocessing/tests/test_noise_levels.py index f334f92fa6..0f9265d00f 100644 --- a/src/spikeinterface/postprocessing/tests/test_noise_levels.py +++ b/src/spikeinterface/postprocessing/tests/test_noise_levels.py @@ -1 +1,3 @@ # "noise_levels" extensions is now in core + +# TODO: can this page now be deleted? From d1a05b9396d8a225c6a2a78381d8f31649090e16 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:05:13 +0100 Subject: [PATCH 064/248] test_principal_component.py --- .../postprocessing/tests/test_principal_component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index d94d7ea586..91b23bab9a 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -11,7 +11,7 @@ DEBUG = False -class PrincipalComponentsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestPrincipalComponentsExtension(AnalyzerExtensionCommonTestSuite): extension_class = ComputePrincipalComponents extension_function_params_list = [ dict(mode="by_channel_local"), From 6853f90addac639acfad29f326c521f6b8ea8fc7 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:22:46 +0100 Subject: [PATCH 065/248] test_spike_amplitudes.py --- .../postprocessing/tests/test_spike_amplitudes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py index 8ff7666371..08f1ed31db 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py @@ -5,7 +5,7 @@ from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite -class ComputeSpikeAmplitudesTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestComputeSpikeAmplitudes(AnalyzerExtensionCommonTestSuite): extension_class = ComputeSpikeAmplitudes extension_function_params_list = [ dict(), @@ -13,7 +13,7 @@ class ComputeSpikeAmplitudesTest(AnalyzerExtensionCommonTestSuite, unittest.Test if __name__ == "__main__": - test = ComputeSpikeAmplitudesTest() + test = TestComputeSpikeAmplitudes() test.setUpClass() test.test_extension() From e8b3c734a7633b3c17372acc1be76a96a88b4e51 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:23:46 +0100 Subject: [PATCH 066/248] Fix missed class renaming in __main__ blocks. --- src/spikeinterface/postprocessing/tests/test_correlograms.py | 2 +- src/spikeinterface/postprocessing/tests/test_isi.py | 2 +- .../postprocessing/tests/test_principal_component.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index 56b4032630..da3a3697eb 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -199,6 +199,6 @@ def test_detect_injected_correlation(): # test_auto_equal_cross_correlograms() # test_detect_injected_correlation() - test = ComputeCorrelogramsTest() + test = TestComputeCorrelograms() test.setUpClass() test.test_extension() diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 3eff96ebfb..e4ce38cea8 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -47,7 +47,7 @@ def _test_ISI(sorting, window_ms: float, bin_ms: float, methods: List[str]): if __name__ == "__main__": - test = ComputeISIHistogramsTest() + test = TestComputeISIHistograms() test.setUpClass() test.test_extension() test.test_compute_ISI() diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 91b23bab9a..d1ee1589db 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -133,7 +133,7 @@ def test_project_new(self): if __name__ == "__main__": - test = PrincipalComponentsExtensionTest() + test = TestPrincipalComponentsExtension() test.setUpClass() test.test_extension() test.test_mode_concatenated() From c43fd1f0f5e4e01cb92a92c4f9bb8bbb44a9a11f Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:43:21 +0100 Subject: [PATCH 067/248] test_spike_locations.py --- .../postprocessing/tests/test_spike_locations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_spike_locations.py b/src/spikeinterface/postprocessing/tests/test_spike_locations.py index d48ff3d84b..09250c9813 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_locations.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_locations.py @@ -5,7 +5,7 @@ from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite -class SpikeLocationsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestSpikeLocationsExtension(AnalyzerExtensionCommonTestSuite): extension_class = ComputeSpikeLocations extension_function_params_list = [ dict( @@ -21,6 +21,6 @@ class SpikeLocationsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.Tes if __name__ == "__main__": - test = SpikeLocationsExtensionTest() + test = TestSpikeLocationsExtension() test.setUpClass() test.test_extension() From 7c587cf30300fc355548991a3e05ae6e3ce47521 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:44:07 +0100 Subject: [PATCH 068/248] test_template_metrics.py --- .../postprocessing/tests/test_template_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_template_metrics.py b/src/spikeinterface/postprocessing/tests/test_template_metrics.py index 360f0f379f..fda8d19da5 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_metrics.py +++ b/src/spikeinterface/postprocessing/tests/test_template_metrics.py @@ -5,7 +5,7 @@ from spikeinterface.postprocessing import ComputeTemplateMetrics -class TemplateMetricsTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestTemplateMetrics(AnalyzerExtensionCommonTestSuite): extension_class = ComputeTemplateMetrics extension_function_params_list = [ dict(), @@ -15,6 +15,6 @@ class TemplateMetricsTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): if __name__ == "__main__": - test = TemplateMetricsTest() + test = TestTemplateMetrics() test.setUpClass() test.test_extension() From 4d2b52a438bbdbbad395772354064b6b337b2cc4 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:44:44 +0100 Subject: [PATCH 069/248] test_template_similarity.py --- .../postprocessing/tests/test_template_similarity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index 534c909592..7e25db14f7 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -9,7 +9,7 @@ from spikeinterface.postprocessing import check_equal_template_with_distribution_overlap, ComputeTemplateSimilarity -class SimilarityExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestSimilarityExtension(AnalyzerExtensionCommonTestSuite): extension_class = ComputeTemplateSimilarity extension_function_params_list = [ dict(method="cosine_similarity"), From 9c4fc69e6b419cd08cd9c07d61d2059d678641f7 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:45:43 +0100 Subject: [PATCH 070/248] test_unit_localization.py --- .../postprocessing/tests/test_unit_localization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_unit_localization.py b/src/spikeinterface/postprocessing/tests/test_unit_localization.py index b23adf5868..fd5df3105c 100644 --- a/src/spikeinterface/postprocessing/tests/test_unit_localization.py +++ b/src/spikeinterface/postprocessing/tests/test_unit_localization.py @@ -3,7 +3,7 @@ from spikeinterface.postprocessing import ComputeUnitLocations -class UnitLocationsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.TestCase): +class TestUnitLocationsExtension(AnalyzerExtensionCommonTestSuite): extension_class = ComputeUnitLocations extension_function_params_list = [ dict(method="center_of_mass", radius_um=100), @@ -15,7 +15,7 @@ class UnitLocationsExtensionTest(AnalyzerExtensionCommonTestSuite, unittest.Test if __name__ == "__main__": - test = UnitLocationsExtensionTest() + test = TestUnitLocationsExtension() test.setUpClass() test.test_extension() # test.tearDown() From e0466a22ebc8d2dd2a827c65db962c198a36fee5 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 18:49:59 +0100 Subject: [PATCH 071/248] Remove now-broken main blocks. --- .../postprocessing/tests/test_amplitude_scalings.py | 7 ------- .../postprocessing/tests/test_correlograms.py | 12 ------------ src/spikeinterface/postprocessing/tests/test_isi.py | 7 ------- .../postprocessing/tests/test_principal_component.py | 11 ----------- .../postprocessing/tests/test_spike_amplitudes.py | 10 ---------- .../postprocessing/tests/test_spike_locations.py | 6 ------ .../postprocessing/tests/test_template_metrics.py | 6 ------ .../postprocessing/tests/test_template_similarity.py | 8 -------- .../postprocessing/tests/test_unit_localization.py | 7 ------- 9 files changed, 74 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py index f5ef0db956..ebc35da348 100644 --- a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py @@ -33,10 +33,3 @@ def test_scaling_values(self): # fig, ax = plt.subplots() # ax.hist(ext.data["amplitude_scalings"]) # plt.show() - - -if __name__ == "__main__": - test = TestAmplitudeScalingsExtension() - test.setUpClass() - test.test_extension() - test.test_scaling_values() diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index da3a3697eb..c7c8e00722 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -190,15 +190,3 @@ def test_detect_injected_correlation(): # ax.set_title(method) # ax.legend() # plt.show() - - -if __name__ == "__main__": - # test_make_bins() - # test_equal_results_correlograms() - # test_flat_cross_correlogram() - # test_auto_equal_cross_correlograms() - # test_detect_injected_correlation() - - test = TestComputeCorrelograms() - test.setUpClass() - test.test_extension() diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index e4ce38cea8..3110cd7c93 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -44,10 +44,3 @@ def _test_ISI(sorting, window_ms: float, bin_ms: float, methods: List[str]): else: assert np.all(ISI == ref_ISI), f"Failed with method={method}" assert np.allclose(bins, ref_bins, atol=1e-10), f"Failed with method={method}" - - -if __name__ == "__main__": - test = TestComputeISIHistograms() - test.setUpClass() - test.test_extension() - test.test_compute_ISI() diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index d1ee1589db..5d616575c9 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -112,7 +112,6 @@ def test_compute_for_all_spikes(self): assert np.array_equal(all_pc1, all_pc2) def test_project_new(self): - from sklearn.decomposition import IncrementalPCA sorting_analyzer = self._prepare_sorting_analyzer(format="memory", sparse=False) @@ -131,16 +130,6 @@ def test_project_new(self): assert new_proj.shape[1] == n_components assert new_proj.shape[2] == ext_pca.data["pca_projection"].shape[2] - -if __name__ == "__main__": - test = TestPrincipalComponentsExtension() - test.setUpClass() - test.test_extension() - test.test_mode_concatenated() - test.test_get_projections() - test.test_compute_for_all_spikes() - test.test_project_new() - # ext = test.sorting_analyzers["sparseTrue_memory"].get_extension("principal_components") # pca = ext.data["pca_projection"] # import matplotlib.pyplot as plt diff --git a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py index 08f1ed31db..3b288c540c 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py @@ -10,13 +10,3 @@ class TestComputeSpikeAmplitudes(AnalyzerExtensionCommonTestSuite): extension_function_params_list = [ dict(), ] - - -if __name__ == "__main__": - test = TestComputeSpikeAmplitudes() - test.setUpClass() - test.test_extension() - - # for k, sorting_analyzer in test.sorting_analyzers.items(): - # print(sorting_analyzer) - # print(sorting_analyzer.get_extension("spike_amplitudes").data["amplitudes"].shape) diff --git a/src/spikeinterface/postprocessing/tests/test_spike_locations.py b/src/spikeinterface/postprocessing/tests/test_spike_locations.py index 09250c9813..223d74046f 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_locations.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_locations.py @@ -18,9 +18,3 @@ class TestSpikeLocationsExtension(AnalyzerExtensionCommonTestSuite): dict(method="monopolar_triangulation"), # , chunk_size=10000, n_jobs=1 dict(method="grid_convolution"), # , chunk_size=10000, n_jobs=1 ] - - -if __name__ == "__main__": - test = TestSpikeLocationsExtension() - test.setUpClass() - test.test_extension() diff --git a/src/spikeinterface/postprocessing/tests/test_template_metrics.py b/src/spikeinterface/postprocessing/tests/test_template_metrics.py index fda8d19da5..96b1635b27 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_metrics.py +++ b/src/spikeinterface/postprocessing/tests/test_template_metrics.py @@ -12,9 +12,3 @@ class TestTemplateMetrics(AnalyzerExtensionCommonTestSuite): dict(upsampling_factor=2), dict(include_multi_channel_metrics=True), ] - - -if __name__ == "__main__": - test = TestTemplateMetrics() - test.setUpClass() - test.test_extension() diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index 7e25db14f7..96a9b5f3ee 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -34,11 +34,3 @@ def test_check_equal_template_with_distribution_overlap(): continue waveforms1 = wf_ext.get_waveforms_one_unit(unit_id1) check_equal_template_with_distribution_overlap(waveforms0, waveforms1) - - -if __name__ == "__main__": - # test = SimilarityExtensionTest() - # test.setUpClass() - # test.test_extension() - - test_check_equal_template_with_distribution_overlap() diff --git a/src/spikeinterface/postprocessing/tests/test_unit_localization.py b/src/spikeinterface/postprocessing/tests/test_unit_localization.py index fd5df3105c..1546a22056 100644 --- a/src/spikeinterface/postprocessing/tests/test_unit_localization.py +++ b/src/spikeinterface/postprocessing/tests/test_unit_localization.py @@ -12,10 +12,3 @@ class TestUnitLocationsExtension(AnalyzerExtensionCommonTestSuite): dict(method="monopolar_triangulation", radius_um=150), dict(method="monopolar_triangulation", radius_um=150, optimizer="minimize_with_log_penality"), ] - - -if __name__ == "__main__": - test = TestUnitLocationsExtension() - test.setUpClass() - test.test_extension() - # test.tearDown() From 83abe6ccafd17b2e97ec3decb20e2ee538f13d13 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 19:12:13 +0100 Subject: [PATCH 072/248] Make common extension tests and test_amplitude_scalings.py use parameterized params. --- .../tests/common_extension_tests.py | 40 +++++++------------ .../tests/test_amplitude_scalings.py | 15 +++---- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index d8575cbeb2..759a15e772 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -70,9 +70,6 @@ class AnalyzerExtensionCommonTestSuite: This also test the select_units() ability. """ - extension_class = None - extension_function_params_list = None - @pytest.fixture(autouse=True, scope="class") def setUpClass(self): """ @@ -87,52 +84,45 @@ class instance is used for each. In this case, we have to set are available to all subclass instances. """ self.__class__.recording, self.__class__.sorting = get_dataset() - # sparsity is computed once for all cases to save processing time and force a small radius + self.__class__.sparsity = estimate_sparsity( self.__class__.recording, self.__class__.sorting, method="radius", radius_um=20 ) - @property - def extension_name(self): - return self.extension_class.extension_name - - def _prepare_sorting_analyzer(self, format, sparse): - # prepare a SortingAnalyzer object with depencies already computed + def _prepare_sorting_analyzer(self, format, sparse, extension_class): + """prepare a SortingAnalyzer object with depencies already computed""" sparsity_ = self.sparsity if sparse else None sorting_analyzer = get_sorting_analyzer( - self.recording, self.sorting, format=format, sparsity=sparsity_, name=self.extension_class.extension_name + self.recording, self.sorting, format=format, sparsity=sparsity_, name=extension_class.extension_name ) sorting_analyzer.compute("random_spikes", max_spikes_per_unit=50, seed=2205) - for dependency_name in self.extension_class.depend_on: + for dependency_name in extension_class.depend_on: if "|" in dependency_name: dependency_name = dependency_name.split("|")[0] sorting_analyzer.compute(dependency_name) return sorting_analyzer - def _check_one(self, sorting_analyzer): - if self.extension_class.need_job_kwargs: + def _check_one(self, sorting_analyzer, extension_class, params): + """""" + if extension_class.need_job_kwargs: job_kwargs = dict(n_jobs=2, chunk_duration="1s", progress_bar=True) else: job_kwargs = dict() - # TODO: a downside of this approach is each parameterisation does - # not get it's own test, but all falls under the same test. - for params in self.extension_function_params_list: - print(" params", params) - ext = sorting_analyzer.compute(self.extension_name, **params, **job_kwargs) - assert len(ext.data) > 0 - main_data = ext.get_data() + ext = sorting_analyzer.compute(extension_class.extension_name, **params, **job_kwargs) + assert len(ext.data) > 0 + main_data = ext.get_data() - ext = sorting_analyzer.get_extension(self.extension_name) + ext = sorting_analyzer.get_extension(extension_class.extension_name) assert ext is not None some_unit_ids = sorting_analyzer.unit_ids[::2] sliced = sorting_analyzer.select_units(some_unit_ids, format="memory") assert np.array_equal(sliced.unit_ids, sorting_analyzer.unit_ids[::2]) - def test_extension(self): + def run_extension_tests(self, extension_class, params): for sparse in (True, False): for format in ("memory", "binary_folder", "zarr"): print("sparse", sparse, format) - sorting_analyzer = self._prepare_sorting_analyzer(format, sparse) - self._check_one(sorting_analyzer) + sorting_analyzer = self._prepare_sorting_analyzer(format, sparse, extension_class) + self._check_one(sorting_analyzer, extension_class, params) diff --git a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py index ebc35da348..2fec970534 100644 --- a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py @@ -1,6 +1,5 @@ -import unittest import numpy as np - +import pytest from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite @@ -8,14 +7,13 @@ class TestAmplitudeScalingsExtension(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeAmplitudeScalings - extension_function_params_list = [ - dict(handle_collisions=True), - dict(handle_collisions=False), - ] + + @pytest.mark.parametrize("params", [dict(handle_collisions=True), dict(handle_collisions=False)]) + def test_extension(self, params): + self.run_extension_tests(ComputeAmplitudeScalings, params) def test_scaling_values(self): - sorting_analyzer = self._prepare_sorting_analyzer("memory", True) + sorting_analyzer = self._prepare_sorting_analyzer("memory", True, ComputeAmplitudeScalings) sorting_analyzer.compute("amplitude_scalings", handle_collisions=False) spikes = sorting_analyzer.sorting.to_spike_vector() @@ -26,7 +24,6 @@ def test_scaling_values(self): mask = spikes["unit_index"] == unit_index scalings = ext.data["amplitude_scalings"][mask] median_scaling = np.median(scalings) - # print(unit_index, median_scaling) np.testing.assert_array_equal(np.round(median_scaling), 1) # import matplotlib.pyplot as plt From 53d3680da8981f85a6d8631e0c0d5edaf325c810 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 19:23:03 +0100 Subject: [PATCH 073/248] move test_correlograms to parameterised method. --- .../postprocessing/tests/test_correlograms.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index c7c8e00722..e9bdec827f 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -14,16 +14,21 @@ from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import ComputeCorrelograms from spikeinterface.postprocessing.correlograms import compute_correlograms_on_sorting, _make_bins +import pytest class TestComputeCorrelograms(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeCorrelograms - extension_function_params_list = [ - dict(method="numpy"), - dict(method="auto"), - ] - if HAVE_NUMBA: - extension_function_params_list.append(dict(method="numba")) + + @pytest.mark.parametrize( + "params", + [ + dict(method="numpy"), + dict(method="auto"), + pytest.param(dict(method="numba"), marks=pytest.mark.skipif("not HAVE_NUMBA")), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeCorrelograms, params) def test_make_bins(): From bcf87d4690eee2b86e4e88e063ad912cc7655223 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 19:32:11 +0100 Subject: [PATCH 074/248] test_isi.py to parameterised method. --- .../postprocessing/tests/test_isi.py | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 3110cd7c93..801e5621c3 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -6,7 +6,7 @@ from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import compute_isi_histograms, ComputeISIHistograms from spikeinterface.postprocessing.isi import _compute_isi_histograms - +import pytest try: import numba @@ -17,30 +17,37 @@ class TestComputeISIHistograms(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeISIHistograms - extension_function_params_list = [ - dict(method="numpy"), - dict(method="auto"), - ] - if HAVE_NUMBA: - extension_function_params_list.append(dict(method="numba")) + + @pytest.mark.parametrize( + "params", + [ + dict(method="numpy"), + dict(method="auto"), + pytest.param(dict(method="numba"), marks=pytest.mark.skipif("not HAVE_NUMBA")), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeISIHistograms, params) def test_compute_ISI(self): + """ + Requires as list because everything tested against Numpy. + But numpy is not tested against anything. + """ methods = ["numpy", "auto"] if HAVE_NUMBA: methods.append("numba") - _test_ISI(self.sorting, window_ms=60.0, bin_ms=1.0, methods=methods) - _test_ISI(self.sorting, window_ms=43.57, bin_ms=1.6421, methods=methods) - + self._test_ISI(self.sorting, window_ms=60.0, bin_ms=1.0, methods=methods) + self._test_ISI(self.sorting, window_ms=43.57, bin_ms=1.6421, methods=methods) -def _test_ISI(sorting, window_ms: float, bin_ms: float, methods: List[str]): - for method in methods: - ISI, bins = _compute_isi_histograms(sorting, window_ms=window_ms, bin_ms=bin_ms, method=method) + def _test_ISI(self, sorting, window_ms: float, bin_ms: float, methods: List[str]): + for method in methods: + ISI, bins = _compute_isi_histograms(sorting, window_ms=window_ms, bin_ms=bin_ms, method=method) - if method == "numpy": - ref_ISI = ISI - ref_bins = bins - else: - assert np.all(ISI == ref_ISI), f"Failed with method={method}" - assert np.allclose(bins, ref_bins, atol=1e-10), f"Failed with method={method}" + if method == "numpy": + ref_ISI = ISI + ref_bins = bins + else: + assert np.all(ISI == ref_ISI), f"Failed with method={method}" + assert np.allclose(bins, ref_bins, atol=1e-10), f"Failed with method={method}" From 0b3432c260317e20856719cacd34b149b4d94ea5 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:01:50 +0100 Subject: [PATCH 075/248] test_principal_component.py to parameterized method. --- .../tests/test_principal_component.py | 171 ++++++++++-------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 5d616575c9..8e46a6b672 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -12,17 +12,23 @@ class TestPrincipalComponentsExtension(AnalyzerExtensionCommonTestSuite): - extension_class = ComputePrincipalComponents - extension_function_params_list = [ - dict(mode="by_channel_local"), - dict(mode="by_channel_global"), - # mode concatenated cannot be tested here because it do not work with sparse=True - ] + + @pytest.mark.parametrize( + "params", + [ + dict(mode="by_channel_local"), + dict(mode="by_channel_global"), + # mode concatenated cannot be tested here because it do not work with sparse=True + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputePrincipalComponents, params=params) def test_mode_concatenated(self): # this is tested outside "extension_function_params_list" because it do not support sparsity! - - sorting_analyzer = self._prepare_sorting_analyzer(format="memory", sparse=False) + sorting_analyzer = self._prepare_sorting_analyzer( + format="memory", sparse=False, extension_class=ComputePrincipalComponents + ) n_components = 3 sorting_analyzer.compute("principal_components", mode="concatenated", n_components=n_components) @@ -33,93 +39,98 @@ def test_mode_concatenated(self): assert pca.ndim == 2 assert pca.shape[1] == n_components - def test_get_projections(self): - - for sparse in (False, True): - - sorting_analyzer = self._prepare_sorting_analyzer(format="memory", sparse=sparse) - num_chans = sorting_analyzer.get_num_channels() - n_components = 2 - - sorting_analyzer.compute("principal_components", mode="by_channel_global", n_components=n_components) - ext = sorting_analyzer.get_extension("principal_components") - - for unit_id in sorting_analyzer.unit_ids: - if not sparse: - one_proj = ext.get_projections_one_unit(unit_id, sparse=False) - assert one_proj.shape[1] == n_components - assert one_proj.shape[2] == num_chans - else: - one_proj = ext.get_projections_one_unit(unit_id, sparse=False) - assert one_proj.shape[1] == n_components - assert one_proj.shape[2] == num_chans - - one_proj, chan_inds = ext.get_projections_one_unit(unit_id, sparse=True) - assert one_proj.shape[1] == n_components - assert one_proj.shape[2] < num_chans - assert one_proj.shape[2] == chan_inds.size - - some_unit_ids = sorting_analyzer.unit_ids[::2] - some_channel_ids = sorting_analyzer.channel_ids[::2] + @pytest.mark.parametrize("sparse", [True, False]) + def test_get_projections(self, sparse): - random_spikes_indices = sorting_analyzer.get_extension("random_spikes").get_data() + sorting_analyzer = self._prepare_sorting_analyzer( + format="memory", sparse=sparse, extension_class=ComputePrincipalComponents + ) + num_chans = sorting_analyzer.get_num_channels() + n_components = 2 - # this should be all spikes all channels - some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=None) - assert some_projections.shape[0] == spike_unit_index.shape[0] - assert spike_unit_index.shape[0] == random_spikes_indices.size - assert some_projections.shape[1] == n_components - assert some_projections.shape[2] == num_chans - - # this should be some spikes all channels - some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=some_unit_ids) - assert some_projections.shape[0] == spike_unit_index.shape[0] - assert spike_unit_index.shape[0] < random_spikes_indices.size - assert some_projections.shape[1] == n_components - assert some_projections.shape[2] == num_chans - assert 1 not in spike_unit_index - - # this should be some spikes some channels - some_projections, spike_unit_index = ext.get_some_projections( - channel_ids=some_channel_ids, unit_ids=some_unit_ids - ) - assert some_projections.shape[0] == spike_unit_index.shape[0] - assert spike_unit_index.shape[0] < random_spikes_indices.size - assert some_projections.shape[1] == n_components - assert some_projections.shape[2] == some_channel_ids.size - assert 1 not in spike_unit_index - - def test_compute_for_all_spikes(self): - - for sparse in (True, False): - sorting_analyzer = self._prepare_sorting_analyzer(format="memory", sparse=sparse) + sorting_analyzer.compute("principal_components", mode="by_channel_global", n_components=n_components) + ext = sorting_analyzer.get_extension("principal_components") - num_spikes = sorting_analyzer.sorting.to_spike_vector().size + for unit_id in sorting_analyzer.unit_ids: + if not sparse: + one_proj = ext.get_projections_one_unit(unit_id, sparse=False) + assert one_proj.shape[1] == n_components + assert one_proj.shape[2] == num_chans + else: + one_proj = ext.get_projections_one_unit(unit_id, sparse=False) + assert one_proj.shape[1] == n_components + assert one_proj.shape[2] == num_chans + + one_proj, chan_inds = ext.get_projections_one_unit(unit_id, sparse=True) + assert one_proj.shape[1] == n_components + assert one_proj.shape[2] < num_chans + assert one_proj.shape[2] == chan_inds.size + + some_unit_ids = sorting_analyzer.unit_ids[::2] + some_channel_ids = sorting_analyzer.channel_ids[::2] + + random_spikes_indices = sorting_analyzer.get_extension("random_spikes").get_data() + + # this should be all spikes all channels + some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=None) + assert some_projections.shape[0] == spike_unit_index.shape[0] + assert spike_unit_index.shape[0] == random_spikes_indices.size + assert some_projections.shape[1] == n_components + assert some_projections.shape[2] == num_chans + + # this should be some spikes all channels + some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=some_unit_ids) + assert some_projections.shape[0] == spike_unit_index.shape[0] + assert spike_unit_index.shape[0] < random_spikes_indices.size + assert some_projections.shape[1] == n_components + assert some_projections.shape[2] == num_chans + assert 1 not in spike_unit_index + + # this should be some spikes some channels + some_projections, spike_unit_index = ext.get_some_projections( + channel_ids=some_channel_ids, unit_ids=some_unit_ids + ) + assert some_projections.shape[0] == spike_unit_index.shape[0] + assert spike_unit_index.shape[0] < random_spikes_indices.size + assert some_projections.shape[1] == n_components + assert some_projections.shape[2] == some_channel_ids.size + assert 1 not in spike_unit_index + + @pytest.mark.parametrize("sparse", [True, False]) + def test_compute_for_all_spikes(self, sparse): + + sorting_analyzer = self._prepare_sorting_analyzer( + format="memory", sparse=sparse, extension_class=ComputePrincipalComponents + ) + + num_spikes = sorting_analyzer.sorting.to_spike_vector().size - n_components = 3 - sorting_analyzer.compute("principal_components", mode="by_channel_local", n_components=n_components) - ext = sorting_analyzer.get_extension("principal_components") + n_components = 3 + sorting_analyzer.compute("principal_components", mode="by_channel_local", n_components=n_components) + ext = sorting_analyzer.get_extension("principal_components") - pc_file1 = cache_folder / "all_pc1.npy" - ext.run_for_all_spikes(pc_file1, chunk_size=10000, n_jobs=1) - all_pc1 = np.load(pc_file1) - assert all_pc1.shape[0] == num_spikes + pc_file1 = cache_folder / "all_pc1.npy" + ext.run_for_all_spikes(pc_file1, chunk_size=10000, n_jobs=1) + all_pc1 = np.load(pc_file1) + assert all_pc1.shape[0] == num_spikes - pc_file2 = cache_folder / "all_pc2.npy" - ext.run_for_all_spikes(pc_file2, chunk_size=10000, n_jobs=2) - all_pc2 = np.load(pc_file2) + pc_file2 = cache_folder / "all_pc2.npy" + ext.run_for_all_spikes(pc_file2, chunk_size=10000, n_jobs=2) + all_pc2 = np.load(pc_file2) - assert np.array_equal(all_pc1, all_pc2) + assert np.array_equal(all_pc1, all_pc2) def test_project_new(self): - sorting_analyzer = self._prepare_sorting_analyzer(format="memory", sparse=False) + sorting_analyzer = self._prepare_sorting_analyzer( + format="memory", sparse=False, extension_class=ComputePrincipalComponents + ) waveforms = sorting_analyzer.get_extension("waveforms").data["waveforms"] n_components = 3 sorting_analyzer.compute("principal_components", mode="by_channel_local", n_components=n_components) - ext_pca = sorting_analyzer.get_extension(self.extension_name) + ext_pca = sorting_analyzer.get_extension(ComputePrincipalComponents.extension_name) num_spike = 100 new_spikes = sorting_analyzer.sorting.to_spike_vector()[:num_spike] From 5d3a66520bbd9bc69cdb919eca714c8aee7a47ef Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:02:19 +0100 Subject: [PATCH 076/248] test_spike_amplitudes.py to parametrized methods. --- .../postprocessing/tests/test_spike_amplitudes.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py index 3b288c540c..3f29b923cd 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py @@ -6,7 +6,6 @@ class TestComputeSpikeAmplitudes(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeSpikeAmplitudes - extension_function_params_list = [ - dict(), - ] + + def test_extension(self): + self.run_extension_tests(ComputeSpikeAmplitudes, params=dict()) From c9fa259b379732c54fe4835808c1a2a95a75898a Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:02:42 +0100 Subject: [PATCH 077/248] test_spike_locations.py to parametrized method. --- .../tests/test_spike_locations.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_spike_locations.py b/src/spikeinterface/postprocessing/tests/test_spike_locations.py index 223d74046f..382b3baf7c 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_locations.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_locations.py @@ -3,18 +3,20 @@ from spikeinterface.postprocessing import ComputeSpikeLocations from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite +import pytest class TestSpikeLocationsExtension(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeSpikeLocations - extension_function_params_list = [ - dict( - method="center_of_mass", spike_retriver_kwargs=dict(channel_from_template=True) - ), # chunk_size=10000, n_jobs=1, - dict(method="center_of_mass", spike_retriver_kwargs=dict(channel_from_template=False)), - dict( - method="center_of_mass", - ), - dict(method="monopolar_triangulation"), # , chunk_size=10000, n_jobs=1 - dict(method="grid_convolution"), # , chunk_size=10000, n_jobs=1 - ] + + @pytest.mark.parametrize( + "params", + [ + dict(method="center_of_mass", spike_retriver_kwargs=dict(channel_from_template=True)), + dict(method="center_of_mass", spike_retriver_kwargs=dict(channel_from_template=False)), + dict(method="center_of_mass"), + dict(method="monopolar_triangulation"), + dict(method="grid_convolution"), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeSpikeLocations, params) From c88f470e3ff9239a5716f912e6be02d9b695630b Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:05:01 +0100 Subject: [PATCH 078/248] test_template_metrics.py a parametrized method. --- .../tests/test_template_metrics.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_template_metrics.py b/src/spikeinterface/postprocessing/tests/test_template_metrics.py index 96b1635b27..f5cf03a5e3 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_metrics.py +++ b/src/spikeinterface/postprocessing/tests/test_template_metrics.py @@ -3,12 +3,18 @@ from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import ComputeTemplateMetrics +import pytest class TestTemplateMetrics(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeTemplateMetrics - extension_function_params_list = [ - dict(), - dict(upsampling_factor=2), - dict(include_multi_channel_metrics=True), - ] + + @pytest.mark.parametrize( + "params", + [ + dict(), + dict(upsampling_factor=2), + dict(include_multi_channel_metrics=True), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeTemplateMetrics, params) From 2ca41e9c34039a92275ed252cc970aa76408fa90 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:16:34 +0100 Subject: [PATCH 079/248] test_template_similarity.py to parametrized method. --- .../tests/test_template_similarity.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index 96a9b5f3ee..dbced65237 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -10,27 +10,23 @@ class TestSimilarityExtension(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeTemplateSimilarity - extension_function_params_list = [ - dict(method="cosine_similarity"), - ] + def test_extension(self): + self.run_extension_tests(ComputeTemplateSimilarity, params=dict(method="cosine_similarity")) -def test_check_equal_template_with_distribution_overlap(): + def test_check_equal_template_with_distribution_overlap(self): - recording, sorting = get_dataset() + sorting_analyzer = self._prepare_sorting_analyzer("memory", None, ComputeTemplateSimilarity) + sorting_analyzer.compute("random_spikes") + sorting_analyzer.compute("waveforms") + sorting_analyzer.compute("templates") - sorting_analyzer = get_sorting_analyzer(recording, sorting, sparsity=None) - sorting_analyzer.compute("random_spikes") - sorting_analyzer.compute("waveforms") - sorting_analyzer.compute("templates") + wf_ext = sorting_analyzer.get_extension("waveforms") - wf_ext = sorting_analyzer.get_extension("waveforms") - - for unit_id0 in sorting_analyzer.unit_ids: - waveforms0 = wf_ext.get_waveforms_one_unit(unit_id0) - for unit_id1 in sorting_analyzer.unit_ids: - if unit_id0 == unit_id1: - continue - waveforms1 = wf_ext.get_waveforms_one_unit(unit_id1) - check_equal_template_with_distribution_overlap(waveforms0, waveforms1) + for unit_id0 in sorting_analyzer.unit_ids: + waveforms0 = wf_ext.get_waveforms_one_unit(unit_id0) + for unit_id1 in sorting_analyzer.unit_ids: + if unit_id0 == unit_id1: + continue + waveforms1 = wf_ext.get_waveforms_one_unit(unit_id1) + check_equal_template_with_distribution_overlap(waveforms0, waveforms1) From fbe04043e70c41b03ded5028b8cfd6259fe4f573 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:18:11 +0100 Subject: [PATCH 080/248] test_unit_localization.py to parameterized method. --- .../tests/test_unit_localization.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_unit_localization.py b/src/spikeinterface/postprocessing/tests/test_unit_localization.py index 1546a22056..6fd589e2f4 100644 --- a/src/spikeinterface/postprocessing/tests/test_unit_localization.py +++ b/src/spikeinterface/postprocessing/tests/test_unit_localization.py @@ -1,14 +1,20 @@ import unittest from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import ComputeUnitLocations +import pytest class TestUnitLocationsExtension(AnalyzerExtensionCommonTestSuite): - extension_class = ComputeUnitLocations - extension_function_params_list = [ - dict(method="center_of_mass", radius_um=100), - dict(method="grid_convolution", radius_um=50), - dict(method="grid_convolution", radius_um=150, weight_method={"mode": "gaussian_2d"}), - dict(method="monopolar_triangulation", radius_um=150), - dict(method="monopolar_triangulation", radius_um=150, optimizer="minimize_with_log_penality"), - ] + + @pytest.mark.parametrize( + "params", + [ + dict(method="center_of_mass", radius_um=100), + dict(method="grid_convolution", radius_um=50), + dict(method="grid_convolution", radius_um=150, weight_method={"mode": "gaussian_2d"}), + dict(method="monopolar_triangulation", radius_um=150), + dict(method="monopolar_triangulation", radius_um=150, optimizer="minimize_with_log_penality"), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeUnitLocations, params=params) From 3ae8479e9d32bcc089343942d9d5d8fa44107c85 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 20:24:09 +0100 Subject: [PATCH 081/248] Tidy up imports. --- .../postprocessing/tests/test_correlograms.py | 4 ---- src/spikeinterface/postprocessing/tests/test_isi.py | 3 +-- .../postprocessing/tests/test_principal_component.py | 9 +-------- .../postprocessing/tests/test_spike_amplitudes.py | 3 --- .../postprocessing/tests/test_spike_locations.py | 3 --- .../postprocessing/tests/test_template_metrics.py | 3 --- .../postprocessing/tests/test_template_similarity.py | 4 ---- .../postprocessing/tests/test_unit_localization.py | 1 - 8 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index e9bdec827f..49d083cedf 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -1,6 +1,4 @@ -import unittest import numpy as np -from typing import List try: import numba @@ -38,13 +36,11 @@ def test_make_bins(): bin_ms = 1.6421 bins, window_size, bin_size = _make_bins(sorting, window_ms, bin_ms) assert bins.size == np.floor(window_ms / bin_ms) + 1 - # print(bins, window_size, bin_size) window_ms = 60.0 bin_ms = 2.0 bins, window_size, bin_size = _make_bins(sorting, window_ms, bin_ms) assert bins.size == np.floor(window_ms / bin_ms) + 1 - # print(bins, window_size, bin_size) def _test_correlograms(sorting, window_ms, bin_ms, methods): diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 801e5621c3..3c7a1e1463 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -1,10 +1,9 @@ -import unittest import numpy as np from typing import List from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite -from spikeinterface.postprocessing import compute_isi_histograms, ComputeISIHistograms +from spikeinterface.postprocessing import ComputeISIHistograms from spikeinterface.postprocessing.isi import _compute_isi_histograms import pytest diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 8e46a6b672..1d150d73da 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -1,16 +1,9 @@ -import unittest import pytest -from pathlib import Path - import numpy as np - -from spikeinterface.postprocessing import ComputePrincipalComponents, compute_principal_components +from spikeinterface.postprocessing import ComputePrincipalComponents from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite, cache_folder -DEBUG = False - - class TestPrincipalComponentsExtension(AnalyzerExtensionCommonTestSuite): @pytest.mark.parametrize( diff --git a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py index 3f29b923cd..a68483a1b2 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_amplitudes.py @@ -1,6 +1,3 @@ -import unittest -import numpy as np - from spikeinterface.postprocessing import ComputeSpikeAmplitudes from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite diff --git a/src/spikeinterface/postprocessing/tests/test_spike_locations.py b/src/spikeinterface/postprocessing/tests/test_spike_locations.py index 382b3baf7c..46a39d23ea 100644 --- a/src/spikeinterface/postprocessing/tests/test_spike_locations.py +++ b/src/spikeinterface/postprocessing/tests/test_spike_locations.py @@ -1,6 +1,3 @@ -import unittest -import numpy as np - from spikeinterface.postprocessing import ComputeSpikeLocations from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite import pytest diff --git a/src/spikeinterface/postprocessing/tests/test_template_metrics.py b/src/spikeinterface/postprocessing/tests/test_template_metrics.py index f5cf03a5e3..694aa083cc 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_metrics.py +++ b/src/spikeinterface/postprocessing/tests/test_template_metrics.py @@ -1,6 +1,3 @@ -import unittest - - from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import ComputeTemplateMetrics import pytest diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index dbced65237..a0f57bf3c5 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -1,9 +1,5 @@ -import unittest - from spikeinterface.postprocessing.tests.common_extension_tests import ( AnalyzerExtensionCommonTestSuite, - get_sorting_analyzer, - get_dataset, ) from spikeinterface.postprocessing import check_equal_template_with_distribution_overlap, ComputeTemplateSimilarity diff --git a/src/spikeinterface/postprocessing/tests/test_unit_localization.py b/src/spikeinterface/postprocessing/tests/test_unit_localization.py index 6fd589e2f4..c40a917a2b 100644 --- a/src/spikeinterface/postprocessing/tests/test_unit_localization.py +++ b/src/spikeinterface/postprocessing/tests/test_unit_localization.py @@ -1,4 +1,3 @@ -import unittest from spikeinterface.postprocessing.tests.common_extension_tests import AnalyzerExtensionCommonTestSuite from spikeinterface.postprocessing import ComputeUnitLocations import pytest From 43a76f831e3808dec87dfd339154ec0f29f0fde5 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 21:54:35 +0100 Subject: [PATCH 082/248] Remove missed __main__. --- src/spikeinterface/postprocessing/tests/test_align_sorting.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_align_sorting.py b/src/spikeinterface/postprocessing/tests/test_align_sorting.py index e5c70ae4b2..fbb54035bb 100644 --- a/src/spikeinterface/postprocessing/tests/test_align_sorting.py +++ b/src/spikeinterface/postprocessing/tests/test_align_sorting.py @@ -40,7 +40,3 @@ def test_align_sorting(): st = sorting.get_unit_spike_train(unit_id) st_clean = sorting_aligned.get_unit_spike_train(unit_id) assert np.array_equal(st, st_clean) - - -if __name__ == "__main__": - test_align_sorting() From 6bbc6274783f8b11ece6c2d342f0a0cc2651785f Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 22:15:07 +0100 Subject: [PATCH 083/248] Extend docstrings for common_extension_tests.py --- .../tests/common_extension_tests.py | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index 759a15e772..6b445aa746 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -61,13 +61,27 @@ def get_sorting_analyzer(recording, sorting, format="memory", sparsity=None, nam class AnalyzerExtensionCommonTestSuite: """ - Common tests with class approach to compute extension on several cases (3 format x 2 sparsity) - - This is done a a list of differents parameters (extension_function_params_list). - - This automatically precompute extension dependencies with default params before running computation. - - This also test the select_units() ability. + Common tests with class approach to compute extension on several cases, + format ("memory", "binary_folder", "zarr") and sparsity (True, False). + Extensions refer to the extension classes that handle the postprocessing, + for example extracting principal components or amplitude scalings. + + This base class provides a fixture which sets a recording + and sorting object onto itself, which are set up once each time + the base class is subclassed in a test environment. The recording + and sorting object are used in the creation of the `sorting_analyzer` + object used to run postprocessing routines. + + When subclassed, a test function that parametrises arguments + that are passed to the `sorting_analyzer.compute()` can be setup. + This must call `run_extension_tests()` which sets up a `sorting_analyzer` + with the relevant format and sparsity. This also automatically precomputes + extension dependencies with default params, Then, `check_one()` is called + which runs the compute function with the passed params and tests that: + + 1) the returned extractor object has data on it + 2) check `sorting_analyzer.get_extension()` does not return None + 3) the correct units are sliced with the `select_units()` function. """ @pytest.fixture(autouse=True, scope="class") @@ -90,20 +104,31 @@ class instance is used for each. In this case, we have to set ) def _prepare_sorting_analyzer(self, format, sparse, extension_class): - """prepare a SortingAnalyzer object with depencies already computed""" + """ + Prepare a SortingAnalyzer object with dependencies already computed + according to format (e.g. "memory", "binary_folder", "zarr") + and sparsity (e.g. True, False). + """ sparsity_ = self.sparsity if sparse else None + sorting_analyzer = get_sorting_analyzer( self.recording, self.sorting, format=format, sparsity=sparsity_, name=extension_class.extension_name ) sorting_analyzer.compute("random_spikes", max_spikes_per_unit=50, seed=2205) + for dependency_name in extension_class.depend_on: if "|" in dependency_name: dependency_name = dependency_name.split("|")[0] sorting_analyzer.compute(dependency_name) + return sorting_analyzer def _check_one(self, sorting_analyzer, extension_class, params): - """""" + """ + Take a prepared sorting analyzer object, compute the extension of interest + with the passed parameters, and check the output is not empty, the extension + exists and `select_units()` method works. + """ if extension_class.need_job_kwargs: job_kwargs = dict(n_jobs=2, chunk_duration="1s", progress_bar=True) else: @@ -121,6 +146,11 @@ def _check_one(self, sorting_analyzer, extension_class, params): assert np.array_equal(sliced.unit_ids, sorting_analyzer.unit_ids[::2]) def run_extension_tests(self, extension_class, params): + """ + Convenience function to perform all checks on the extension + of interest with the passed parameters. Will perform tests + for sparsity and format. + """ for sparse in (True, False): for format in ("memory", "binary_folder", "zarr"): print("sparse", sparse, format) From 8462a0782af255f9ec24c5b5c0689edb9e7282b7 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 22:15:36 +0100 Subject: [PATCH 084/248] Extend docstrings and refactor 'test_align_sorting()'. --- .../tests/test_align_sorting.py | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_align_sorting.py b/src/spikeinterface/postprocessing/tests/test_align_sorting.py index fbb54035bb..3a7befe019 100644 --- a/src/spikeinterface/postprocessing/tests/test_align_sorting.py +++ b/src/spikeinterface/postprocessing/tests/test_align_sorting.py @@ -1,5 +1,3 @@ -import pytest -import shutil from pathlib import Path import pytest @@ -18,8 +16,16 @@ def test_align_sorting(): + """ + `align_sorting()` shifts, in time, the spikes belonging to a unit. + For each unit, an offset is provided and the spike peak index is shifted. + + This test creates a sorting object, then creates an 'unaligned' sorting + object in which the peaks for some of the units are shifted. Next, the `align_sorting()` + function is unused to unshift them, and the original sorting spike train + peak times compared with the corrected sorting train. + """ sorting = generate_sorting(durations=[10.0], seed=0) - print(sorting) unit_ids = sorting.unit_ids @@ -27,16 +33,27 @@ def test_align_sorting(): unit_peak_shifts[unit_ids[-1]] = 5 unit_peak_shifts[unit_ids[-2]] = -5 - # sorting to dict - d = {unit_id: sorting.get_unit_spike_train(unit_id) + unit_peak_shifts[unit_id] for unit_id in sorting.unit_ids} - sorting_unaligned = NumpySorting.from_unit_dict(d, sampling_frequency=sorting.get_sampling_frequency()) - print(sorting_unaligned) + shifted_unit_dict = { + unit_id: sorting.get_unit_spike_train(unit_id) + unit_peak_shifts[unit_id] for unit_id in sorting.unit_ids + } + sorting_unaligned = NumpySorting.from_unit_dict( + shifted_unit_dict, sampling_frequency=sorting.get_sampling_frequency() + ) sorting_aligned = align_sorting(sorting_unaligned, unit_peak_shifts) - print(sorting_aligned) - for start_frame, end_frame in [(None, None), (10000, 50000)]: - for unit_id in unit_ids[-2:]: - st = sorting.get_unit_spike_train(unit_id) - st_clean = sorting_aligned.get_unit_spike_train(unit_id) - assert np.array_equal(st, st_clean) + for unit_id in unit_ids: + spiketrain_orig = sorting.get_unit_spike_train(unit_id) + spiketrain_aligned = sorting_aligned.get_unit_spike_train(unit_id) + spiketrain_unaligned = sorting_unaligned.get_unit_spike_train(unit_id) + + # check the shift induced in the test has changed the + # spiketrain as expected. + if unit_peak_shifts[unit_id] == 0: + assert np.array_equal(spiketrain_orig, spiketrain_unaligned) + else: + assert not np.array_equal(spiketrain_orig, spiketrain_unaligned) + + # Perform the key test, that after correction the spiketrain + # matches the original spiketrain for all units (shifted and unshifted). + assert np.array_equal(spiketrain_orig, spiketrain_aligned) From 9a9cc62326e073827247ff264a010e138a19236e Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 22:42:01 +0100 Subject: [PATCH 085/248] Add docstrings to test_amplitude_scalings.py and test_isi.py --- .../postprocessing/tests/test_amplitude_scalings.py | 12 +++++++++++- src/spikeinterface/postprocessing/tests/test_isi.py | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py index 2fec970534..6ea6b436bf 100644 --- a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py @@ -13,7 +13,17 @@ def test_extension(self, params): self.run_extension_tests(ComputeAmplitudeScalings, params) def test_scaling_values(self): - sorting_analyzer = self._prepare_sorting_analyzer("memory", True, ComputeAmplitudeScalings) + """ + Amplitude finds the scaling factor for each waveform + to best match its unit template. In this test, amplitude scalings + are calculated from the `sorting_analyzer`. In the test environment, + injected waveforms are not scaled from the template and so + should only differ by Gaussian noise. Therefore the median + scaling should be close to 1. + """ + sorting_analyzer = self._prepare_sorting_analyzer( + "memory", sparse=True, extension_class=ComputeAmplitudeScalings + ) sorting_analyzer.compute("amplitude_scalings", handle_collisions=False) spikes = sorting_analyzer.sorting.to_spike_vector() diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 3c7a1e1463..0f9ecb3d7d 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -30,8 +30,10 @@ def test_extension(self, params): def test_compute_ISI(self): """ - Requires as list because everything tested against Numpy. - But numpy is not tested against anything. + This test checks the creation of ISI histograms matches across + "numpy", "auto" and "numba" methods. Does not parameterize as requires + as list because everything tested against Numpy. The Numpy result is not + explicitly tested. """ methods = ["numpy", "auto"] if HAVE_NUMBA: From 063a6e633450d8a740658c87a99971070d10dfc2 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 5 Jun 2024 22:47:56 +0100 Subject: [PATCH 086/248] Add assert and docstring to 'test_template_similarity.py' --- .../postprocessing/tests/test_template_similarity.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index a0f57bf3c5..a4de2a3a90 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -11,7 +11,12 @@ def test_extension(self): self.run_extension_tests(ComputeTemplateSimilarity, params=dict(method="cosine_similarity")) def test_check_equal_template_with_distribution_overlap(self): - + """ + Create a sorting object, extract its waveforms. Compare waveforms + from all pairs of units (excluding a unit against itself) + and check `check_equal_template_with_distribution_overlap()` + correctly determines they are different. + """ sorting_analyzer = self._prepare_sorting_analyzer("memory", None, ComputeTemplateSimilarity) sorting_analyzer.compute("random_spikes") sorting_analyzer.compute("waveforms") @@ -25,4 +30,5 @@ def test_check_equal_template_with_distribution_overlap(self): if unit_id0 == unit_id1: continue waveforms1 = wf_ext.get_waveforms_one_unit(unit_id1) - check_equal_template_with_distribution_overlap(waveforms0, waveforms1) + + assert not check_equal_template_with_distribution_overlap(waveforms0, waveforms1) From 9d296b49a6bf5576750eb8718c363c80bf786c16 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 6 Jun 2024 00:52:08 +0100 Subject: [PATCH 087/248] Add docstring and some small improvement to test_principal_component.py --- .../tests/test_principal_component.py | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 1d150d73da..79166e5400 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -18,7 +18,12 @@ def test_extension(self, params): self.run_extension_tests(ComputePrincipalComponents, params=params) def test_mode_concatenated(self): - # this is tested outside "extension_function_params_list" because it do not support sparsity! + """ + Replicate the "extension_function_params_list" test outside of + AnalyzerExtensionCommonTestSuite because it does not support sparsity. + + Also, add two additional checks on the dimension and n components of the output. + """ sorting_analyzer = self._prepare_sorting_analyzer( format="memory", sparse=False, extension_class=ComputePrincipalComponents ) @@ -34,7 +39,13 @@ def test_mode_concatenated(self): @pytest.mark.parametrize("sparse", [True, False]) def test_get_projections(self, sparse): - + """ + Test the shape of output projection score matrices are + correct when adjusting sparsity and using the + `get_some_projections()` function. We expect them + to hold, for each spike and each channel, the loading + for each of the specified number of components. + """ sorting_analyzer = self._prepare_sorting_analyzer( format="memory", sparse=sparse, extension_class=ComputePrincipalComponents ) @@ -44,6 +55,8 @@ def test_get_projections(self, sparse): sorting_analyzer.compute("principal_components", mode="by_channel_global", n_components=n_components) ext = sorting_analyzer.get_extension("principal_components") + # First, check the created projections have the expected number + # of components and the expected number of channels based on sparsity. for unit_id in sorting_analyzer.unit_ids: if not sparse: one_proj = ext.get_projections_one_unit(unit_id, sparse=False) @@ -56,13 +69,19 @@ def test_get_projections(self, sparse): one_proj, chan_inds = ext.get_projections_one_unit(unit_id, sparse=True) assert one_proj.shape[1] == n_components - assert one_proj.shape[2] < num_chans + num_channels_for_unit = sorting_analyzer.sparsity.unit_id_to_channel_ids[unit_id].size + assert one_proj.shape[2] == num_channels_for_unit assert one_proj.shape[2] == chan_inds.size + # Next, check that the `get_some_projections()` function returns + # projections with the expected shapes when selecting subjsets + # of channel and unit IDs. some_unit_ids = sorting_analyzer.unit_ids[::2] some_channel_ids = sorting_analyzer.channel_ids[::2] random_spikes_indices = sorting_analyzer.get_extension("random_spikes").get_data() + all_num_spikes = sorting_analyzer.sorting.get_total_num_spikes() + unit_ids_num_spikes = np.sum(all_num_spikes[unit_id] for unit_id in some_unit_ids) # this should be all spikes all channels some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=None) @@ -74,7 +93,7 @@ def test_get_projections(self, sparse): # this should be some spikes all channels some_projections, spike_unit_index = ext.get_some_projections(channel_ids=None, unit_ids=some_unit_ids) assert some_projections.shape[0] == spike_unit_index.shape[0] - assert spike_unit_index.shape[0] < random_spikes_indices.size + assert spike_unit_index.shape[0] == unit_ids_num_spikes assert some_projections.shape[1] == n_components assert some_projections.shape[2] == num_chans assert 1 not in spike_unit_index @@ -84,14 +103,19 @@ def test_get_projections(self, sparse): channel_ids=some_channel_ids, unit_ids=some_unit_ids ) assert some_projections.shape[0] == spike_unit_index.shape[0] - assert spike_unit_index.shape[0] < random_spikes_indices.size + assert spike_unit_index.shape[0] == unit_ids_num_spikes assert some_projections.shape[1] == n_components assert some_projections.shape[2] == some_channel_ids.size assert 1 not in spike_unit_index @pytest.mark.parametrize("sparse", [True, False]) def test_compute_for_all_spikes(self, sparse): - + """ + Compute the principal component scores, checking the shape + matches the number of spikes as expected. This is re-run + with n_jobs=2 and output projection score matrices + checked against n_jobs=1. + """ sorting_analyzer = self._prepare_sorting_analyzer( format="memory", sparse=sparse, extension_class=ComputePrincipalComponents ) @@ -114,7 +138,16 @@ def test_compute_for_all_spikes(self, sparse): assert np.array_equal(all_pc1, all_pc2) def test_project_new(self): - + """ + `project_new` projects new (unseen) waveforms onto the PCA components. + First compute principal components from existing waveforms. Then, + generate a new 'spikes' vector that includes sample_index, unit_index + and segment_index alongside some waveforms (the spike vector is required + to generate some corresponding unit IDs for the generated waveforms following + the API of principal_components.py). + + Then, check that the new projection scores matrix is the expected shape. + """ sorting_analyzer = self._prepare_sorting_analyzer( format="memory", sparse=False, extension_class=ComputePrincipalComponents ) From 75ce4d4783440aaaced7c5ced785cd9b9f50fe66 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 6 Jun 2024 01:14:51 +0100 Subject: [PATCH 088/248] Add docstring to test_correlograms.py --- .../postprocessing/tests/test_correlograms.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index 49d083cedf..f3d7617512 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -30,6 +30,10 @@ def test_extension(self, params): def test_make_bins(): + """ + Check the `_make_bins()` function that generates time bins (lags) for + the correllogram creates the expected number of bins. + """ sorting = generate_sorting(num_units=5, sampling_frequency=30000.0, durations=[10.325, 3.5], seed=0) window_ms = 43.57 @@ -79,6 +83,10 @@ def test_equal_results_correlograms(): def test_flat_cross_correlogram(): + """ + Check that the correlogram (num_units x num_units x num_bins) does not + vary too much across time bins (lags), for entries representing two different units. + """ sorting = generate_sorting(num_units=2, sampling_frequency=10000.0, durations=[100000.0], seed=0) methods = ["numpy"] @@ -150,6 +158,11 @@ def test_auto_equal_cross_correlograms(): def test_detect_injected_correlation(): + """ + Inject 1.44 ms of correlation every 13 spikes and compute + cross-correlation. Check that the time bin lag with the peak + correlation lag is 1.44 ms (within tolerance of a sampling period). + """ methods = ["numpy"] if HAVE_NUMBA: methods.append("numba") From 74169b0b7b257460c1e5af81d59d699648b732a2 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 6 Jun 2024 01:17:50 +0100 Subject: [PATCH 089/248] Remove unused / commented debugging code. --- .../tests/test_amplitude_scalings.py | 5 ---- .../postprocessing/tests/test_correlograms.py | 27 ------------------- .../tests/test_principal_component.py | 7 ----- 3 files changed, 39 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py index 6ea6b436bf..0868f5238e 100644 --- a/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/tests/test_amplitude_scalings.py @@ -35,8 +35,3 @@ def test_scaling_values(self): scalings = ext.data["amplitude_scalings"][mask] median_scaling = np.median(scalings) np.testing.assert_array_equal(np.round(median_scaling), 1) - - # import matplotlib.pyplot as plt - # fig, ax = plt.subplots() - # ax.hist(ext.data["amplitude_scalings"]) - # plt.show() diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index f3d7617512..56eac6ef9a 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -93,9 +93,6 @@ def test_flat_cross_correlogram(): if HAVE_NUMBA: methods.append("numba") - # ~ import matplotlib.pyplot as plt - # ~ fig, ax = plt.subplots() - for method in methods: correlograms, bins = compute_correlograms_on_sorting(sorting, window_ms=50.0, bin_ms=1.0, method=method) cc = correlograms[0, 1, :].copy() @@ -103,11 +100,6 @@ def test_flat_cross_correlogram(): assert np.all(cc > (m * 0.90)) assert np.all(cc < (m * 1.10)) - # ~ ax.plot(bins[:-1], cc, label=method) - # ~ ax.legend() - # ~ ax.set_ylim(0, np.max(correlograms) * 1.1) - # ~ plt.show() - def test_auto_equal_cross_correlograms(): """ @@ -146,16 +138,6 @@ def test_auto_equal_cross_correlograms(): else: assert np.array_equal(cc_corrected, ac) - # ~ import matplotlib.pyplot as plt - # ~ fig, ax = plt.subplots() - # ~ ax.plot(bins[:-1], cc, marker='*', color='red', label='cross-corr') - # ~ ax.plot(bins[:-1], cc_corrected, marker='*', color='orange', label='cross-corr corrected') - # ~ ax.plot(bins[:-1], ac, marker='*', color='green', label='auto-corr') - # ~ ax.set_title(method) - # ~ ax.legend() - # ~ ax.set_ylim(0, np.max(correlograms) * 1.1) - # ~ plt.show() - def test_detect_injected_correlation(): """ @@ -195,12 +177,3 @@ def test_detect_injected_correlation(): sampling_period_ms = 1000.0 / sampling_frequency assert abs(peak_location_01_ms) - injected_delta_ms < sampling_period_ms assert abs(peak_location_02_ms) - injected_delta_ms < sampling_period_ms - - # import matplotlib.pyplot as plt - # fig, ax = plt.subplots() - # half_bin_ms = np.mean(np.diff(bins)) / 2. - # ax.plot(bins[:-1]+half_bin_ms, cc_01, marker='*', color='red', label='cross-corr 0>1') - # ax.plot(bins[:-1]+half_bin_ms, cc_10, marker='*', color='orange', label='cross-corr 1>0') - # ax.set_title(method) - # ax.legend() - # plt.show() diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 79166e5400..ebfc781cd9 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -166,10 +166,3 @@ def test_project_new(self): assert new_proj.shape[0] == num_spike assert new_proj.shape[1] == n_components assert new_proj.shape[2] == ext_pca.data["pca_projection"].shape[2] - - # ext = test.sorting_analyzers["sparseTrue_memory"].get_extension("principal_components") - # pca = ext.data["pca_projection"] - # import matplotlib.pyplot as plt - # fig, ax = plt.subplots() - # ax.scatter(pca[:, 0, 0], pca[:, 0, 1]) - # plt.show() From e1c890a8914513b65e2357253419ba563a134de5 Mon Sep 17 00:00:00 2001 From: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> Date: Thu, 6 Jun 2024 12:07:36 +0100 Subject: [PATCH 090/248] Update src/spikeinterface/postprocessing/tests/common_extension_tests.py Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> --- .../postprocessing/tests/common_extension_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index 6b445aa746..6cca483e29 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -136,7 +136,7 @@ def _check_one(self, sorting_analyzer, extension_class, params): ext = sorting_analyzer.compute(extension_class.extension_name, **params, **job_kwargs) assert len(ext.data) > 0 - main_data = ext.get_data() + assert len(main_data) > 0 ext = sorting_analyzer.get_extension(extension_class.extension_name) assert ext is not None From 6277f9f8229d707227313ef8c7d65c001cf14e43 Mon Sep 17 00:00:00 2001 From: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> Date: Thu, 6 Jun 2024 18:09:44 +0100 Subject: [PATCH 091/248] Reformat pytest param exclusion for test_correlograms Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> --- src/spikeinterface/postprocessing/tests/test_correlograms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index 56eac6ef9a..d0b9a1e28b 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -22,7 +22,7 @@ class TestComputeCorrelograms(AnalyzerExtensionCommonTestSuite): [ dict(method="numpy"), dict(method="auto"), - pytest.param(dict(method="numba"), marks=pytest.mark.skipif("not HAVE_NUMBA")), + pytest.param(dict(method="numba"), marks=pytest.mark.skipif(not HAVE_NUMBA, reason="Numba not available")), ], ) def test_extension(self, params): From e599cf66b2d12f77d3cbca64a072d7b66abfa9d9 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 6 Jun 2024 18:10:53 +0100 Subject: [PATCH 092/248] Reformat pytest param exclusion for test_isi. --- src/spikeinterface/postprocessing/tests/test_isi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/test_isi.py b/src/spikeinterface/postprocessing/tests/test_isi.py index 0f9ecb3d7d..444e837cb4 100644 --- a/src/spikeinterface/postprocessing/tests/test_isi.py +++ b/src/spikeinterface/postprocessing/tests/test_isi.py @@ -22,7 +22,7 @@ class TestComputeISIHistograms(AnalyzerExtensionCommonTestSuite): [ dict(method="numpy"), dict(method="auto"), - pytest.param(dict(method="numba"), marks=pytest.mark.skipif("not HAVE_NUMBA")), + pytest.param(dict(method="numba"), marks=pytest.mark.skipif(not HAVE_NUMBA, reason="Numba not available")), ], ) def test_extension(self, params): From e8a006e08b1adc0041340cf707be945bf633f1e1 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 6 Jun 2024 18:21:26 +0100 Subject: [PATCH 093/248] Remove commented code in '_test_correlograms()' --- .../postprocessing/tests/test_correlograms.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/test_correlograms.py b/src/spikeinterface/postprocessing/tests/test_correlograms.py index d0b9a1e28b..eef4af10fc 100644 --- a/src/spikeinterface/postprocessing/tests/test_correlograms.py +++ b/src/spikeinterface/postprocessing/tests/test_correlograms.py @@ -51,22 +51,8 @@ def _test_correlograms(sorting, window_ms, bin_ms, methods): for method in methods: correlograms, bins = compute_correlograms_on_sorting(sorting, window_ms=window_ms, bin_ms=bin_ms, method=method) if method == "numpy": - ref_correlograms = correlograms ref_bins = bins else: - # ~ import matplotlib.pyplot as plt - # ~ for i in range(ref_correlograms.shape[1]): - # ~ for j in range(ref_correlograms.shape[1]): - # ~ fig, ax = plt.subplots() - # ~ ax.plot(bins[:-1], ref_correlograms[i, j, :], color='green', label='numpy') - # ~ ax.plot(bins[:-1], correlograms[i, j, :], color='red', label=method) - # ~ ax.legend() - # ~ ax.set_title(f'{i} {j}') - # ~ plt.show() - - # numba and numyp do not have exactly the same output - # assert np.all(correlograms == ref_correlograms), f"Failed with method={method}" - assert np.allclose(bins, ref_bins, atol=1e-10), f"Failed with method={method}" From 4e617bf6c5b607d154ef5aa6853eb16a51b38686 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Thu, 6 Jun 2024 22:39:39 +0200 Subject: [PATCH 094/248] Moving unit_locations --- .../{test_unit_localization.py => test_unit_locations.py} | 0 .../{unit_localization.py => unit_locations.py} | 0 .../benchmark/tests/test_benchmark_peak_localization.py | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/spikeinterface/postprocessing/tests/{test_unit_localization.py => test_unit_locations.py} (100%) rename src/spikeinterface/postprocessing/{unit_localization.py => unit_locations.py} (100%) diff --git a/src/spikeinterface/postprocessing/tests/test_unit_localization.py b/src/spikeinterface/postprocessing/tests/test_unit_locations.py similarity index 100% rename from src/spikeinterface/postprocessing/tests/test_unit_localization.py rename to src/spikeinterface/postprocessing/tests/test_unit_locations.py diff --git a/src/spikeinterface/postprocessing/unit_localization.py b/src/spikeinterface/postprocessing/unit_locations.py similarity index 100% rename from src/spikeinterface/postprocessing/unit_localization.py rename to src/spikeinterface/postprocessing/unit_locations.py diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_peak_localization.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_peak_localization.py index b6f89dcd36..23060c4ddb 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_peak_localization.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_peak_localization.py @@ -56,14 +56,14 @@ def test_benchmark_peak_localization(create_cache_folder): @pytest.mark.skip() -def test_benchmark_unit_localization(create_cache_folder): +def test_benchmark_unit_locations(create_cache_folder): cache_folder = create_cache_folder job_kwargs = dict(n_jobs=0.8, chunk_duration="100ms") recording, gt_sorting = make_dataset() # create study - study_folder = cache_folder / "study_unit_localization" + study_folder = cache_folder / "study_unit_locations" datasets = {"toy": (recording, gt_sorting)} cases = {} for method in ["center_of_mass", "grid_convolution", "monopolar_triangulation"]: @@ -100,4 +100,4 @@ def test_benchmark_unit_localization(create_cache_folder): if __name__ == "__main__": # test_benchmark_peak_localization() - test_benchmark_unit_localization() + test_benchmark_unit_locations() From 8a24b80024b16bf03177caabf9cea1417bcc3dec Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Thu, 6 Jun 2024 22:48:29 +0200 Subject: [PATCH 095/248] Messing with git --- src/spikeinterface/exporters/report.py | 2 +- src/spikeinterface/postprocessing/__init__.py | 2 +- .../benchmark/benchmark_peak_localization.py | 2 +- src/spikeinterface/sortingcomponents/peak_detection.py | 2 +- src/spikeinterface/sortingcomponents/peak_localization.py | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/exporters/report.py b/src/spikeinterface/exporters/report.py index e12bb9b588..3a4be9213a 100644 --- a/src/spikeinterface/exporters/report.py +++ b/src/spikeinterface/exporters/report.py @@ -111,7 +111,7 @@ def export_report( # global figures fig = plt.figure(figsize=(20, 10)) w = sw.plot_unit_locations(sorting_analyzer, figure=fig, unit_colors=unit_colors) - fig.savefig(output_folder / f"unit_localization.{format}") + fig.savefig(output_folder / f"unit_locations.{format}") if not show_figures: plt.close(fig) diff --git a/src/spikeinterface/postprocessing/__init__.py b/src/spikeinterface/postprocessing/__init__.py index 528f2d3761..ae071a55e0 100644 --- a/src/spikeinterface/postprocessing/__init__.py +++ b/src/spikeinterface/postprocessing/__init__.py @@ -37,7 +37,7 @@ from .spike_locations import compute_spike_locations, ComputeSpikeLocations -from .unit_localization import ( +from .unit_locations import ( compute_unit_locations, ComputeUnitLocations, compute_center_of_mass, diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py index 5c4085af7c..3eda5db3b6 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py @@ -1,6 +1,6 @@ from __future__ import annotations -from spikeinterface.postprocessing.unit_localization import ( +from spikeinterface.postprocessing.unit_locations import ( compute_center_of_mass, compute_monopolar_triangulation, compute_grid_convolution, diff --git a/src/spikeinterface/sortingcomponents/peak_detection.py b/src/spikeinterface/sortingcomponents/peak_detection.py index d23f0fec74..11218a688f 100644 --- a/src/spikeinterface/sortingcomponents/peak_detection.py +++ b/src/spikeinterface/sortingcomponents/peak_detection.py @@ -23,7 +23,7 @@ base_peak_dtype, ) -from spikeinterface.postprocessing.unit_localization import get_convolution_weights +from spikeinterface.postprocessing.unit_locations import get_convolution_weights from ..core import get_chunk_with_margin from .tools import make_multi_method_doc diff --git a/src/spikeinterface/sortingcomponents/peak_localization.py b/src/spikeinterface/sortingcomponents/peak_localization.py index b06f6fac3e..fcae485af9 100644 --- a/src/spikeinterface/sortingcomponents/peak_localization.py +++ b/src/spikeinterface/sortingcomponents/peak_localization.py @@ -21,7 +21,7 @@ from spikeinterface.core import get_channel_distances -from ..postprocessing.unit_localization import ( +from ..postprocessing.unit_locations import ( dtype_localize_by_method, possible_localization_methods, solve_monopolar_triangulation, @@ -163,7 +163,7 @@ class LocalizeCenterOfMass(LocalizeBase): Notes ----- - See spikeinterface.postprocessing.unit_localization. + See spikeinterface.postprocessing.unit_locations. """ need_waveforms = True @@ -225,7 +225,7 @@ class LocalizeMonopolarTriangulation(PipelineNode): Notes ----- This method is from Julien Boussard, Erdem Varol and Charlie Windolf - See spikeinterface.postprocessing.unit_localization. + See spikeinterface.postprocessing.unit_locations. """ need_waveforms = False @@ -316,7 +316,7 @@ class LocalizeGridConvolution(PipelineNode): Notes ----- - See spikeinterface.postprocessing.unit_localization. + See spikeinterface.postprocessing.unit_locations. """ need_waveforms = True From 9019a51369cbcdc4d0684271b6b525715e1a9986 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 7 Jun 2024 09:33:02 +0200 Subject: [PATCH 096/248] Motion object in benchmark motionestimation --- .../benchmark/benchmark_motion_estimation.py | 152 +++++++++--------- .../benchmark/benchmark_tools.py | 6 + .../sortingcomponents/motion_utils.py | 8 + 3 files changed, 92 insertions(+), 74 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 7428629c4a..96b277de6e 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -15,6 +15,8 @@ from spikeinterface.sortingcomponents.peak_localization import localize_peaks from spikeinterface.widgets import plot_probe_map +from spikeinterface.sortingcomponents.motion_utils import Motion + # import MEArec as mr # TODO : plot_peaks @@ -28,8 +30,8 @@ def get_gt_motion_from_unit_displacement( unit_displacements, displacement_sampling_frequency, unit_locations, - temporal_bins, - spatial_bins, + temporal_bins_s, + spatial_bins_um, direction_dim=1, ): import scipy.interpolate @@ -37,21 +39,29 @@ def get_gt_motion_from_unit_displacement( unit_displacements = unit_displacements[:, :, direction_dim] times = np.arange(unit_displacements.shape[0]) / displacement_sampling_frequency f = scipy.interpolate.interp1d(times, unit_displacements, axis=0) - unit_displacements = f(temporal_bins) + unit_displacements = f(temporal_bins_s) # spatial interpolataion of units discplacement - if spatial_bins.shape[0] == 1: + if spatial_bins_um.shape[0] == 1: # rigid - gt_motion = np.mean(unit_displacements, axis=1)[:, None] + gt_displacement = np.mean(unit_displacements, axis=1)[:, None] else: # non rigid - gt_motion = np.zeros((temporal_bins.size, spatial_bins.size)) - for t in range(temporal_bins.shape[0]): + gt_displacement = np.zeros((temporal_bins_s.size, spatial_bins_um.size)) + for t in range(temporal_bins_s.shape[0]): f = scipy.interpolate.interp1d( unit_locations[:, direction_dim], unit_displacements[t, :], fill_value="extrapolate" ) - gt_motion[t, :] = f(spatial_bins) - + gt_displacement[t, :] = f(spatial_bins_um) + + gt_motion = Motion( + gt_displacement, + temporal_bins_s, + spatial_bins_um, + direction="xyz"[direction_dim], + interpolation_method="linear" + ) + return gt_motion @@ -92,7 +102,7 @@ def run(self, **job_kwargs): t2 = time.perf_counter() peak_locations = localize_peaks(self.recording, selected_peaks, **p["localize_kwargs"], **job_kwargs) t3 = time.perf_counter() - motion, temporal_bins, spatial_bins = estimate_motion( + motion = estimate_motion( self.recording, selected_peaks, peak_locations, **p["estimate_motion_kwargs"] ) t4 = time.perf_counter() @@ -106,43 +116,37 @@ def run(self, **job_kwargs): self.result["step_run_times"] = step_run_times self.result["raw_motion"] = motion - self.result["temporal_bins"] = temporal_bins - self.result["spatial_bins"] = spatial_bins def compute_result(self, **result_params): raw_motion = self.result["raw_motion"] - temporal_bins = self.result["temporal_bins"] - spatial_bins = self.result["spatial_bins"] gt_motion = get_gt_motion_from_unit_displacement( self.unit_displacements, self.displacement_sampling_frequency, self.unit_locations, - temporal_bins, - spatial_bins, + raw_motion.temporal_bins_s[0], + raw_motion.spatial_bins_um, direction_dim=self.direction_dim, ) # align globally gt_motion and motion to avoid offsets motion = raw_motion.copy() - motion += np.median(gt_motion - motion) + motion.displacement += np.median(gt_motion.displacement - motion.displacement) self.result["gt_motion"] = gt_motion self.result["motion"] = motion _run_key_saved = [ - ("raw_motion", "npy"), - ("temporal_bins", "npy"), - ("spatial_bins", "npy"), + ("raw_motion", "Motion"), ("step_run_times", "pickle"), ] _result_key_saved = [ ( "gt_motion", - "npy", + "Motion", ), ( "motion", - "npy", + "Motion", ), ] @@ -189,20 +193,20 @@ def plot_drift(self, case_keys=None, gt_drift=True, tested_drift=True, scaling_p # dirft ax = ax1 = fig.add_subplot(gs[2:7]) ax1.sharey(ax0) - temporal_bins = bench.result["temporal_bins"] - spatial_bins = bench.result["spatial_bins"] + # temporal_bins_s = bench.result["temporal_bins_s"] + # spatial_bins_um = bench.result["spatial_bins_um"] gt_motion = bench.result["gt_motion"] motion = bench.result["motion"] # for i in range(self.gt_unit_positions.shape[1]): - # ax.plot(temporal_bins, self.gt_unit_positions[:, i], alpha=0.5, ls="--", c="0.5") + # ax.plot(temporal_bins_s, self.gt_unit_positions[:, i], alpha=0.5, ls="--", c="0.5") for i in range(gt_motion.shape[1]): - depth = spatial_bins[i] + depth = motion.spatial_bins_um[i] if gt_drift: - ax.plot(temporal_bins, gt_motion[:, i] + depth, color="green", lw=4) + ax.plot(motion.temporal_bins_s[0], gt_motion.displacement[0][:, i] + depth, color="green", lw=4) if tested_drift: - ax.plot(temporal_bins, motion[:, i] + depth, color="cyan", lw=2) + ax.plot(motion.temporal_bins_s[0], motion.displacement[0][:, i] + depth, color="cyan", lw=2) ax.set_xlabel("time (s)") _simpleaxis(ax) @@ -241,14 +245,14 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): gt_motion = bench.result["gt_motion"] motion = bench.result["motion"] - temporal_bins = bench.result["temporal_bins"] - spatial_bins = bench.result["spatial_bins"] + # temporal_bins_s = bench.result["temporal_bins_s"] + # spatial_bins_um = bench.result["spatial_bins_um"] fig = plt.figure(figsize=figsize) gs = fig.add_gridspec(2, 2) - errors = gt_motion - motion + errors = gt_motion.displacement[0] - motion.displacement[0] channel_positions = bench.recording.get_channel_locations() probe_y_min, probe_y_max = channel_positions[:, 1].min(), channel_positions[:, 1].max() @@ -259,7 +263,7 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): aspect="auto", interpolation="nearest", origin="lower", - extent=(temporal_bins[0], temporal_bins[-1], spatial_bins[0], spatial_bins[-1]), + extent=(motion.temporal_bins_s[0], motion.temporal_bins_s[-1], motion.spatial_bins_um[0], motion.spatial_bins_um[-1]), ) plt.colorbar(im, ax=ax, label="error") ax.set_ylabel("depth (um)") @@ -270,7 +274,7 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): ax = fig.add_subplot(gs[1, 0]) mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) - ax.plot(temporal_bins, mean_error) + ax.plot(motion.temporal_bins_s, mean_error) ax.set_xlabel("time (s)") ax.set_ylabel("error") _simpleaxis(ax) @@ -279,7 +283,7 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): ax = fig.add_subplot(gs[1, 1]) depth_error = np.sqrt(np.mean((errors) ** 2, axis=0)) - ax.plot(spatial_bins, depth_error) + ax.plot(motion.spatial_bins_um, depth_error) ax.axvline(probe_y_min, color="k", ls="--", alpha=0.5) ax.axvline(probe_y_max, color="k", ls="--", alpha=0.5) ax.set_xlabel("depth (um)") @@ -305,17 +309,17 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) gt_motion = bench.result["gt_motion"] motion = bench.result["motion"] - temporal_bins = bench.result["temporal_bins"] - spatial_bins = bench.result["spatial_bins"] + # temporal_bins_s = bench.result["temporal_bins_s"] + # spatial_bins_um = bench.result["spatial_bins_um"] # c = colors[count] if colors is not None else None c = colors[key] - errors = gt_motion - motion + errors = gt_motion.displacement[0] - motion.displacement[0] mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) depth_error = np.sqrt(np.mean((errors) ** 2, axis=0)) - axes[0].plot(temporal_bins, mean_error, lw=1, label=label, color=c) + axes[0].plot(motion.temporal_bins_s, mean_error, lw=1, label=label, color=c) parts = axes[1].violinplot(mean_error, [count], showmeans=True) if c is not None: for pc in parts["bodies"]: @@ -325,7 +329,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) if k != "bodies": # for line in parts[k]: parts[k].set_color(c) - axes[2].plot(spatial_bins, depth_error, label=label, color=c) + axes[2].plot(motion.spatial_bins_um, depth_error, label=label, color=c) ax0 = ax = axes[0] ax.set_xlabel("Time [s]") @@ -362,8 +366,8 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # "peaks", # "selected_peaks", # "motion", -# "temporal_bins", -# "spatial_bins", +# "temporal_bins_s", +# "spatial_bins_um", # "peak_locations", # "gt_motion", # ) @@ -439,7 +443,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # self.recording, self.selected_peaks, **self.localize_kwargs, **self.job_kwargs # ) # t3 = time.perf_counter() -# self.motion, self.temporal_bins, self.spatial_bins = estimate_motion( +# self.motion, self.temporal_bins_s, self.spatial_bins_um = estimate_motion( # self.recording, self.selected_peaks, self.peak_locations, **self.estimate_motion_kwargs # ) @@ -464,7 +468,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # def run_estimate_motion(self): # # usefull to re run only the motion estimate with peak localization # t3 = time.perf_counter() -# self.motion, self.temporal_bins, self.spatial_bins = estimate_motion( +# self.motion, self.temporal_bins_s, self.spatial_bins_um = estimate_motion( # self.recording, self.selected_peaks, self.peak_locations, **self.estimate_motion_kwargs # ) # t4 = time.perf_counter() @@ -480,7 +484,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # self.save_to_folder() # def compute_gt_motion(self): -# self.gt_unit_positions, _ = mr.extract_units_drift_vector(self.mearec_filename, time_vector=self.temporal_bins) +# self.gt_unit_positions, _ = mr.extract_units_drift_vector(self.mearec_filename, time_vector=self.temporal_bins_s) # template_locations = np.array(mr.load_recordings(self.mearec_filename).template_locations) # assert len(template_locations.shape) == 3 @@ -490,18 +494,18 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # unit_motions = self.gt_unit_positions - unit_mid_positions # # unit_positions = np.mean(self.gt_unit_positions, axis=0) -# if self.spatial_bins is None: +# if self.spatial_bins_um is None: # self.gt_motion = np.mean(unit_motions, axis=1)[:, None] # channel_positions = self.recording.get_channel_locations() # probe_y_min, probe_y_max = channel_positions[:, 1].min(), channel_positions[:, 1].max() # center = (probe_y_min + probe_y_max) // 2 -# self.spatial_bins = np.array([center]) +# self.spatial_bins_um = np.array([center]) # else: # # time, units # self.gt_motion = np.zeros_like(self.motion) # for t in range(self.gt_unit_positions.shape[0]): # f = scipy.interpolate.interp1d(unit_mid_positions, unit_motions[t, :], fill_value="extrapolate") -# self.gt_motion[t, :] = f(self.spatial_bins) +# self.gt_motion[t, :] = f(self.spatial_bins_um) # def plot_true_drift(self, scaling_probe=1.5, figsize=(15, 10), axes=None): # if axes is None: @@ -535,11 +539,11 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # ax = axes[1] # for i in range(self.gt_unit_positions.shape[1]): -# ax.plot(self.temporal_bins, self.gt_unit_positions[:, i], alpha=0.5, ls="--", c="0.5") +# ax.plot(self.temporal_bins_s, self.gt_unit_positions[:, i], alpha=0.5, ls="--", c="0.5") # for i in range(self.gt_motion.shape[1]): -# depth = self.spatial_bins[i] -# ax.plot(self.temporal_bins, self.gt_motion[:, i] + depth, color="green", lw=4) +# depth = self.spatial_bins_um[i] +# ax.plot(self.temporal_bins_s, self.gt_motion[:, i] + depth, color="green", lw=4) # # ax.set_ylim(ymin, ymax) # ax.set_xlabel("time (s)") @@ -618,15 +622,15 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # ax.axhline(probe_y_max, color="k", ls="--", alpha=0.5) # if show_drift: -# if self.spatial_bins is None: +# if self.spatial_bins_um is None: # center = (probe_y_min + probe_y_max) // 2 -# ax.plot(self.temporal_bins, self.gt_motion[:, 0] + center, color="green", lw=1.5) -# ax.plot(self.temporal_bins, self.motion[:, 0] + center, color="orange", lw=1.5) +# ax.plot(self.temporal_bins_s, self.gt_motion[:, 0] + center, color="green", lw=1.5) +# ax.plot(self.temporal_bins_s, self.motion[:, 0] + center, color="orange", lw=1.5) # else: # for i in range(self.gt_motion.shape[1]): -# depth = self.spatial_bins[i] -# ax.plot(self.temporal_bins, self.gt_motion[:, i] + depth, color="green", lw=1.5) -# ax.plot(self.temporal_bins, self.motion[:, i] + depth, color="orange", lw=1.5) +# depth = self.spatial_bins_um[i] +# ax.plot(self.temporal_bins_s, self.gt_motion[:, i] + depth, color="green", lw=1.5) +# ax.plot(self.temporal_bins_s, self.motion[:, i] + depth, color="orange", lw=1.5) # if show_histogram: # ax2 = fig.add_subplot(gs[3]) @@ -672,8 +676,8 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # self.peak_locations, # self.recording, # self.motion, -# self.temporal_bins, -# self.spatial_bins, +# self.temporal_bins_s, +# self.spatial_bins_um, # direction="y", # ) # if axes is None: @@ -735,18 +739,18 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # colors = plt.colormaps["jet"].resampled(n) # for i in range(0, n, step): # ax = axs[0] -# ax.plot(self.temporal_bins, self.gt_motion[:, i], lw=1.5, ls="--", color=colors(i)) +# ax.plot(self.temporal_bins_s, self.gt_motion[:, i], lw=1.5, ls="--", color=colors(i)) # ax.plot( -# self.temporal_bins, +# self.temporal_bins_s, # self.motion[:, i], # lw=1.5, # ls="-", # color=colors(i), -# label=f"{self.spatial_bins[i]:0.1f}", +# label=f"{self.spatial_bins_um[i]:0.1f}", # ) # ax = axs[1] -# ax.plot(self.temporal_bins, self.motion[:, i] - self.gt_motion[:, i], lw=1.5, ls="-", color=colors(i)) +# ax.plot(self.temporal_bins_s, self.motion[:, i] - self.gt_motion[:, i], lw=1.5, ls="-", color=colors(i)) # ax = axs[0] # ax.set_title(self.title) @@ -775,7 +779,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # aspect="auto", # interpolation="nearest", # origin="lower", -# extent=(self.temporal_bins[0], self.temporal_bins[-1], self.spatial_bins[0], self.spatial_bins[-1]), +# extent=(self.temporal_bins_s[0], self.temporal_bins_s[-1], self.spatial_bins_um[0], self.spatial_bins_um[-1]), # ) # plt.colorbar(im, ax=ax, label="error") # ax.set_ylabel("depth (um)") @@ -786,7 +790,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # ax = fig.add_subplot(gs[1, 0]) # mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) -# ax.plot(self.temporal_bins, mean_error) +# ax.plot(self.temporal_bins_s, mean_error) # ax.set_xlabel("time (s)") # ax.set_ylabel("error") # _simpleaxis(ax) @@ -795,7 +799,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # ax = fig.add_subplot(gs[1, 1]) # depth_error = np.sqrt(np.mean((errors) ** 2, axis=0)) -# ax.plot(self.spatial_bins, depth_error) +# ax.plot(self.spatial_bins_um, depth_error) # ax.axvline(probe_y_min, color="k", ls="--", alpha=0.5) # ax.axvline(probe_y_max, color="k", ls="--", alpha=0.5) # ax.set_xlabel("depth (um)") @@ -817,7 +821,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) # depth_error = np.sqrt(np.mean((errors) ** 2, axis=0)) -# axes[0].plot(benchmark.temporal_bins, mean_error, lw=1, label=benchmark.title, color=c) +# axes[0].plot(benchmark.temporal_bins_s, mean_error, lw=1, label=benchmark.title, color=c) # parts = axes[1].violinplot(mean_error, [count], showmeans=True) # if c is not None: # for pc in parts["bodies"]: @@ -827,7 +831,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # if k != "bodies": # # for line in parts[k]: # parts[k].set_color(c) -# axes[2].plot(benchmark.spatial_bins, depth_error, label=benchmark.title, color=c) +# axes[2].plot(benchmark.spatial_bins_um, depth_error, label=benchmark.title, color=c) # ax0 = ax = axes[0] # ax.set_xlabel("Time [s]") @@ -876,10 +880,10 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # interpolation="nearest", # origin="lower", # extent=( -# benchmark.temporal_bins[0], -# benchmark.temporal_bins[-1], -# benchmark.spatial_bins[0], -# benchmark.spatial_bins[-1], +# benchmark.temporal_bins_s[0], +# benchmark.temporal_bins_s[-1], +# benchmark.spatial_bins_um[0], +# benchmark.spatial_bins_um[-1], # ), # ) # fig.colorbar(im, ax=ax, label="error") @@ -897,11 +901,11 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # def plot_motions_several_benchmarks(benchmarks): # fig, ax = plt.subplots(figsize=(15, 5)) -# ax.plot(list(benchmarks)[0].temporal_bins, list(benchmarks)[0].gt_motion[:, 0], lw=2, c="k", label="real motion") +# ax.plot(list(benchmarks)[0].temporal_bins_s, list(benchmarks)[0].gt_motion[:, 0], lw=2, c="k", label="real motion") # for count, benchmark in enumerate(benchmarks): -# ax.plot(benchmark.temporal_bins, benchmark.motion.mean(1), lw=1, c=f"C{count}", label=benchmark.title) +# ax.plot(benchmark.temporal_bins_s, benchmark.motion.mean(1), lw=1, c=f"C{count}", label=benchmark.title) # ax.fill_between( -# benchmark.temporal_bins, +# benchmark.temporal_bins_s, # benchmark.motion.mean(1) - benchmark.motion.std(1), # benchmark.motion.mean(1) + benchmark.motion.std(1), # color=f"C{count}", diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py index b2cf56eb9c..e9f128993d 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py @@ -406,6 +406,8 @@ def _save_keys(self, saved_keys, folder): pickle.dump(self.result[k], f) elif format == "sorting": self.result[k].save(folder=folder / k, format="numpy_folder", overwrite=True) + elif format == "Motion": + self.result[k].save(folder=folder / k) elif format == "zarr_templates": self.result[k].to_zarr(folder / k) elif format == "sorting_analyzer": @@ -440,6 +442,10 @@ def load_folder(cls, folder): from spikeinterface.core import load_extractor result[k] = load_extractor(folder / k) + elif format == "Motion": + from spikeinterface.sortingcomponents.motion_utils import Motion + + result[k] = Motion.load(folder / k) elif format == "zarr_templates": from spikeinterface.core.template import Templates diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 1edf484aa4..93c4a0741f 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -236,3 +236,11 @@ def __eq__(self, other): return False return True + + def copy(self): + return Motion( + self.displacement.copy(), + self.temporal_bins_s.copy(), + self.spatial_bins_um.copy(), + interpolation_method=self.interpolation_method + ) From ad8a062c6769b060fa46d68e892bc728b8290189 Mon Sep 17 00:00:00 2001 From: Julien Verplanken Date: Fri, 7 Jun 2024 10:41:13 +0200 Subject: [PATCH 097/248] add whiteningRange as kilosort2_5 parameter --- src/spikeinterface/sorters/external/kilosort2_5.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/sorters/external/kilosort2_5.py b/src/spikeinterface/sorters/external/kilosort2_5.py index abde2ab324..beccba3481 100644 --- a/src/spikeinterface/sorters/external/kilosort2_5.py +++ b/src/spikeinterface/sorters/external/kilosort2_5.py @@ -53,6 +53,7 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "nPCs": 3, "ntbuff": 64, "nfilt_factor": 4, + "whiteningRange": 32.0, "NT": None, "AUCsplit": 0.9, "do_correction": True, @@ -82,6 +83,7 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "ntbuff": "Samples of symmetrical buffer for whitening and spike detection", "nfilt_factor": "Max number of clusters per good channel (even temporary ones) 4", "do_correction": "If True drift registration is applied", + "whiteningRange": "Number of channels to use for whitening each channel", "NT": "Batch size (if None it is automatically computed)", "AUCsplit": "Threshold on the area under the curve (AUC) criterion for performing a split in the final step", "keep_good_only": "If True only 'good' units are returned", @@ -220,7 +222,7 @@ def _get_specific_options(cls, ops, params): ops["NT"] = params[ "NT" ] # must be multiple of 32 + ntbuff. This is the batch size (try decreasing if out of memory). - ops["whiteningRange"] = 32.0 # number of channels to use for whitening each channel + ops["whiteningRange"] = params["whiteningRange"] # number of channels to use for whitening each channel ops["nSkipCov"] = 25.0 # compute whitening matrix from every N-th batch ops["nPCs"] = params["nPCs"] # how many PCs to project the spikes into ops["useRAM"] = 0.0 # not yet available From a54612bfc0d4f8e704800854a3a119a05129be1c Mon Sep 17 00:00:00 2001 From: Julien Verplanken Date: Fri, 7 Jun 2024 10:54:09 +0200 Subject: [PATCH 098/248] reorder to match positions in param dictionaries --- src/spikeinterface/sorters/external/kilosort2_5.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sorters/external/kilosort2_5.py b/src/spikeinterface/sorters/external/kilosort2_5.py index beccba3481..b3d1718d59 100644 --- a/src/spikeinterface/sorters/external/kilosort2_5.py +++ b/src/spikeinterface/sorters/external/kilosort2_5.py @@ -41,6 +41,7 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "detect_threshold": 6, "projection_threshold": [10, 4], "preclust_threshold": 8, + "whiteningRange": 32.0, "momentum": [20.0, 400.0], "car": True, "minFR": 0.1, @@ -53,7 +54,6 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "nPCs": 3, "ntbuff": 64, "nfilt_factor": 4, - "whiteningRange": 32.0, "NT": None, "AUCsplit": 0.9, "do_correction": True, @@ -70,6 +70,7 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "detect_threshold": "Threshold for spike detection", "projection_threshold": "Threshold on projections", "preclust_threshold": "Threshold crossings for pre-clustering (in PCA projection space)", + "whiteningRange": "Number of channels to use for whitening each channel", "momentum": "Number of samples to average over (annealed from first to second value)", "car": "Enable or disable common reference", "minFR": "Minimum spike rate (Hz), if a cluster falls below this for too long it gets removed", @@ -83,7 +84,6 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter): "ntbuff": "Samples of symmetrical buffer for whitening and spike detection", "nfilt_factor": "Max number of clusters per good channel (even temporary ones) 4", "do_correction": "If True drift registration is applied", - "whiteningRange": "Number of channels to use for whitening each channel", "NT": "Batch size (if None it is automatically computed)", "AUCsplit": "Threshold on the area under the curve (AUC) criterion for performing a split in the final step", "keep_good_only": "If True only 'good' units are returned", From b2b9001b343a285c07640693ea41fc6facdebbfd Mon Sep 17 00:00:00 2001 From: Julien Verplanken Date: Fri, 7 Jun 2024 15:35:37 +0200 Subject: [PATCH 099/248] added whiteningRange parameter to KS2 and KS3 --- src/spikeinterface/sorters/external/kilosort2.py | 4 +++- src/spikeinterface/sorters/external/kilosort3.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sorters/external/kilosort2.py b/src/spikeinterface/sorters/external/kilosort2.py index bdc0372789..0425ad5e53 100644 --- a/src/spikeinterface/sorters/external/kilosort2.py +++ b/src/spikeinterface/sorters/external/kilosort2.py @@ -37,6 +37,7 @@ class Kilosort2Sorter(KilosortBase, BaseSorter): "detect_threshold": 6, "projection_threshold": [10, 4], "preclust_threshold": 8, + "whiteningRange": 32, # samples of the template to use for whitening "spatial" dimension "momentum": [20.0, 400.0], "car": True, "minFR": 0.1, @@ -62,6 +63,7 @@ class Kilosort2Sorter(KilosortBase, BaseSorter): "detect_threshold": "Threshold for spike detection", "projection_threshold": "Threshold on projections", "preclust_threshold": "Threshold crossings for pre-clustering (in PCA projection space)", + "whiteningRange": "Number of channels to use for whitening each channel", "momentum": "Number of samples to average over (annealed from first to second value)", "car": "Enable or disable common reference", "minFR": "Minimum spike rate (Hz), if a cluster falls below this for too long it gets removed", @@ -199,7 +201,7 @@ def _get_specific_options(cls, ops, params): ops["NT"] = params[ "NT" ] # must be multiple of 32 + ntbuff. This is the batch size (try decreasing if out of memory). - ops["whiteningRange"] = 32.0 # number of channels to use for whitening each channel + ops["whiteningRange"] = params["whiteningRange"] # number of channels to use for whitening each channel ops["nSkipCov"] = 25.0 # compute whitening matrix from every N-th batch ops["nPCs"] = params["nPCs"] # how many PCs to project the spikes into ops["useRAM"] = 0.0 # not yet available diff --git a/src/spikeinterface/sorters/external/kilosort3.py b/src/spikeinterface/sorters/external/kilosort3.py index 3d2103ea66..f560fd7e1e 100644 --- a/src/spikeinterface/sorters/external/kilosort3.py +++ b/src/spikeinterface/sorters/external/kilosort3.py @@ -38,6 +38,7 @@ class Kilosort3Sorter(KilosortBase, BaseSorter): "detect_threshold": 6, "projection_threshold": [9, 9], "preclust_threshold": 8, + "whiteningRange": 32, "car": True, "minFR": 0.2, "minfr_goodchannels": 0.2, @@ -65,6 +66,7 @@ class Kilosort3Sorter(KilosortBase, BaseSorter): "detect_threshold": "Threshold for spike detection", "projection_threshold": "Threshold on projections", "preclust_threshold": "Threshold crossings for pre-clustering (in PCA projection space)", + "whiteningRange": "number of channels to use for whitening each channel", "car": "Enable or disable common reference", "minFR": "Minimum spike rate (Hz), if a cluster falls below this for too long it gets removed", "minfr_goodchannels": "Minimum firing rate on a 'good' channel", @@ -212,7 +214,7 @@ def _get_specific_options(cls, ops, params): ops["NT"] = params[ "NT" ] # must be multiple of 32 + ntbuff. This is the batch size (try decreasing if out of memory). - ops["whiteningRange"] = 32.0 # number of channels to use for whitening each channel + ops["whiteningRange"] = params["whiteningRange"] # number of channels to use for whitening each channel ops["nSkipCov"] = 25.0 # compute whitening matrix from every N-th batch ops["scaleproc"] = 200.0 # int16 scaling of whitened data ops["nPCs"] = params["nPCs"] # how many PCs to project the spikes into From 6f0eadcc2a68f8338498493dbcd905ba87e9d335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20WYNGAARD?= Date: Mon, 10 Jun 2024 17:32:00 +0200 Subject: [PATCH 100/248] Add `peak_to_peak` mode to SNR --- src/spikeinterface/qualitymetrics/misc_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/misc_metrics.py b/src/spikeinterface/qualitymetrics/misc_metrics.py index f1082386cc..1e7d4d0444 100644 --- a/src/spikeinterface/qualitymetrics/misc_metrics.py +++ b/src/spikeinterface/qualitymetrics/misc_metrics.py @@ -194,7 +194,7 @@ def compute_snrs( A SortingAnalyzer object. peak_sign : "neg" | "pos" | "both", default: "neg" The sign of the template to compute best channels. - peak_mode : "extremum" | "at_index", default: "extremum" + peak_mode : "extremum" | "at_index", "peak_to_peak", default: "extremum" How to compute the amplitude. Extremum takes the maxima/minima At_index takes the value at t=sorting_analyzer.nbefore. @@ -210,7 +210,7 @@ def compute_snrs( noise_levels = sorting_analyzer.get_extension("noise_levels").get_data() assert peak_sign in ("neg", "pos", "both") - assert peak_mode in ("extremum", "at_index") + assert peak_mode in ("extremum", "at_index", "peak_to_peak") if unit_ids is None: unit_ids = sorting_analyzer.unit_ids From c1c0cb6b3023f9e274ffb9012966ce7db841d649 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Tue, 11 Jun 2024 15:56:01 +0100 Subject: [PATCH 101/248] remove extremum from spikelocations init --- src/spikeinterface/postprocessing/spike_locations.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/postprocessing/spike_locations.py b/src/spikeinterface/postprocessing/spike_locations.py index d468bd90ab..96e01a68c4 100644 --- a/src/spikeinterface/postprocessing/spike_locations.py +++ b/src/spikeinterface/postprocessing/spike_locations.py @@ -59,9 +59,6 @@ class ComputeSpikeLocations(AnalyzerExtension): def __init__(self, sorting_analyzer): AnalyzerExtension.__init__(self, sorting_analyzer) - extremum_channel_inds = get_template_extremum_channel(self.sorting_analyzer, outputs="index") - self.spikes = self.sorting_analyzer.sorting.to_spike_vector(extremum_channel_inds=extremum_channel_inds) - def _set_params( self, ms_before=0.5, @@ -89,8 +86,9 @@ def _set_params( def _select_extension_data(self, unit_ids): old_unit_ids = self.sorting_analyzer.unit_ids unit_inds = np.flatnonzero(np.isin(old_unit_ids, unit_ids)) + spikes = self.sorting_analyzer.sorting.to_spike_vector() - spike_mask = np.isin(self.spikes["unit_index"], unit_inds) + spike_mask = np.isin(spikes["unit_index"], unit_inds) new_spike_locations = self.data["spike_locations"][spike_mask] return dict(spike_locations=new_spike_locations) From 140b248110fb06ca7beaa2e357b032e465e2bcf3 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:11:36 +0100 Subject: [PATCH 102/248] add AstypeRecording round default value --- src/spikeinterface/preprocessing/astype.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/preprocessing/astype.py b/src/spikeinterface/preprocessing/astype.py index 4b0d5f9e55..ce8dbc3ca7 100644 --- a/src/spikeinterface/preprocessing/astype.py +++ b/src/spikeinterface/preprocessing/astype.py @@ -20,7 +20,7 @@ class AstypeRecording(BasePreprocessor): dtype of the output recording. recording : Recording The recording extractor to be converted. - round : Bool + round : Bool | None, default: None If True, will round the values to the nearest integer. If None, will round in the case of float to integer conversion. From f89ea90cf40d2b0485ed138b66b9efe653eac399 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:19:44 +0100 Subject: [PATCH 103/248] Update to fix PR02 rule --- src/spikeinterface/preprocessing/filter.py | 2 -- src/spikeinterface/preprocessing/resample.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index 84ac542acc..ffad9a2029 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -222,7 +222,6 @@ class HighpassFilterRecording(FilterRecording): **filter_kwargs : dict Keyword arguments for `spikeinterface.preprocessing.FilterRecording` class. - {} Returns ------- filter_recording : HighpassFilterRecording @@ -255,7 +254,6 @@ class NotchFilterRecording(BasePreprocessor): margin_ms : float, default: 5.0 Margin in ms on border to avoid border effect - {} Returns ------- filter_recording : NotchFilterRecording diff --git a/src/spikeinterface/preprocessing/resample.py b/src/spikeinterface/preprocessing/resample.py index 54a602b7c0..ed77ec504d 100644 --- a/src/spikeinterface/preprocessing/resample.py +++ b/src/spikeinterface/preprocessing/resample.py @@ -28,7 +28,7 @@ class ResampleRecording(BasePreprocessor): The recording extractor to be re-referenced resample_rate : int The resampling frequency - margin : float, default: 100.0 + margin_ms : float, default: 100.0 Margin in ms for computations, will be used to decrease edge effects. dtype : dtype or None, default: None The dtype of the returned traces. If None, the dtype of the parent recording is used. From fcd6f8e274eab4648f91772d36000025d6b1f22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20WYNGAARD?= Date: Wed, 12 Jun 2024 14:08:50 +0200 Subject: [PATCH 104/248] Update src/spikeinterface/qualitymetrics/misc_metrics.py Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- src/spikeinterface/qualitymetrics/misc_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/misc_metrics.py b/src/spikeinterface/qualitymetrics/misc_metrics.py index 1e7d4d0444..cbb55aeb8b 100644 --- a/src/spikeinterface/qualitymetrics/misc_metrics.py +++ b/src/spikeinterface/qualitymetrics/misc_metrics.py @@ -194,7 +194,7 @@ def compute_snrs( A SortingAnalyzer object. peak_sign : "neg" | "pos" | "both", default: "neg" The sign of the template to compute best channels. - peak_mode : "extremum" | "at_index", "peak_to_peak", default: "extremum" + peak_mode : "extremum" | "at_index" | "peak_to_peak", default: "extremum" How to compute the amplitude. Extremum takes the maxima/minima At_index takes the value at t=sorting_analyzer.nbefore. From 311a4175b01c9a0eb2ab21804a0261607af49790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20WYNGAARD?= Date: Wed, 12 Jun 2024 14:21:29 +0200 Subject: [PATCH 105/248] Remove un-used argument --- src/spikeinterface/curation/auto_merge.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/curation/auto_merge.py b/src/spikeinterface/curation/auto_merge.py index 818b6a72b0..3ce12809dd 100644 --- a/src/spikeinterface/curation/auto_merge.py +++ b/src/spikeinterface/curation/auto_merge.py @@ -251,7 +251,7 @@ def get_potential_auto_merge( def compute_correlogram_diff( - sorting, correlograms_smoothed, bins, win_sizes, adaptative_window_threshold=0.5, pair_mask=None + sorting, correlograms_smoothed, bins, win_sizes, pair_mask=None ): """ Original author: Aurelien Wyngaard (lussac) @@ -267,8 +267,6 @@ def compute_correlogram_diff( Bins of the correlograms win_sized: TODO - adaptative_window_threshold : float - TODO pair_mask : None or boolean array A bool matrix of size (num_units, num_units) to select which pair to compute. From 80c8847e17418bbfbe80920be90fde02a9c4a51c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:22:39 +0000 Subject: [PATCH 106/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/curation/auto_merge.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/curation/auto_merge.py b/src/spikeinterface/curation/auto_merge.py index 3ce12809dd..6629e61bfc 100644 --- a/src/spikeinterface/curation/auto_merge.py +++ b/src/spikeinterface/curation/auto_merge.py @@ -250,9 +250,7 @@ def get_potential_auto_merge( return potential_merges -def compute_correlogram_diff( - sorting, correlograms_smoothed, bins, win_sizes, pair_mask=None -): +def compute_correlogram_diff(sorting, correlograms_smoothed, bins, win_sizes, pair_mask=None): """ Original author: Aurelien Wyngaard (lussac) From d5e3c8c1be5a3e7f20d1996e9deba3cc842f3a2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20WYNGAARD?= Date: Wed, 12 Jun 2024 14:46:21 +0200 Subject: [PATCH 107/248] Oops --- src/spikeinterface/curation/auto_merge.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spikeinterface/curation/auto_merge.py b/src/spikeinterface/curation/auto_merge.py index 6629e61bfc..c652089a39 100644 --- a/src/spikeinterface/curation/auto_merge.py +++ b/src/spikeinterface/curation/auto_merge.py @@ -193,7 +193,6 @@ def get_potential_auto_merge( correlograms_smoothed, bins, win_sizes, - adaptative_window_threshold=adaptative_window_threshold, pair_mask=pair_mask, ) # print(correlogram_diff) From fa363303145d136fac464918d0279404cabc9f82 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 12 Jun 2024 15:08:38 +0200 Subject: [PATCH 108/248] various fixes --- .../benchmark/benchmark_motion_estimation.py | 13 +++++++------ .../benchmark/benchmark_motion_interpolation.py | 2 +- .../tests/test_benchmark_motion_estimation.py | 15 ++++++++------- .../tests/test_benchmark_motion_interpolation.py | 6 +++++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 96b277de6e..2278bfbd3e 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -39,7 +39,7 @@ def get_gt_motion_from_unit_displacement( unit_displacements = unit_displacements[:, :, direction_dim] times = np.arange(unit_displacements.shape[0]) / displacement_sampling_frequency f = scipy.interpolate.interp1d(times, unit_displacements, axis=0) - unit_displacements = f(temporal_bins_s) + unit_displacements = f(temporal_bins_s.clip(times[0], times[-1])) # spatial interpolataion of units discplacement if spatial_bins_um.shape[0] == 1: @@ -131,7 +131,7 @@ def compute_result(self, **result_params): # align globally gt_motion and motion to avoid offsets motion = raw_motion.copy() - motion.displacement += np.median(gt_motion.displacement - motion.displacement) + motion.displacement[0] += np.median(gt_motion.displacement[0] - motion.displacement[0]) self.result["gt_motion"] = gt_motion self.result["motion"] = motion @@ -201,7 +201,7 @@ def plot_drift(self, case_keys=None, gt_drift=True, tested_drift=True, scaling_p # for i in range(self.gt_unit_positions.shape[1]): # ax.plot(temporal_bins_s, self.gt_unit_positions[:, i], alpha=0.5, ls="--", c="0.5") - for i in range(gt_motion.shape[1]): + for i in range(gt_motion.displacement[0].shape[1]): depth = motion.spatial_bins_um[i] if gt_drift: ax.plot(motion.temporal_bins_s[0], gt_motion.displacement[0][:, i] + depth, color="green", lw=4) @@ -263,7 +263,8 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): aspect="auto", interpolation="nearest", origin="lower", - extent=(motion.temporal_bins_s[0], motion.temporal_bins_s[-1], motion.spatial_bins_um[0], motion.spatial_bins_um[-1]), + extent=(motion.temporal_bins_s[0][0], motion.temporal_bins_s[0][-1], + motion.spatial_bins_um[0], motion.spatial_bins_um[-1]), ) plt.colorbar(im, ax=ax, label="error") ax.set_ylabel("depth (um)") @@ -274,7 +275,7 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): ax = fig.add_subplot(gs[1, 0]) mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) - ax.plot(motion.temporal_bins_s, mean_error) + ax.plot(motion.temporal_bins_s[0], mean_error) ax.set_xlabel("time (s)") ax.set_ylabel("error") _simpleaxis(ax) @@ -319,7 +320,7 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) mean_error = np.sqrt(np.mean((errors) ** 2, axis=1)) depth_error = np.sqrt(np.mean((errors) ** 2, axis=0)) - axes[0].plot(motion.temporal_bins_s, mean_error, lw=1, label=label, color=c) + axes[0].plot(motion.temporal_bins_s[0], mean_error, lw=1, label=label, color=c) parts = axes[1].violinplot(mean_error, [count], showmeans=True) if c is not None: for pc in parts["bodies"]: diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py index a515424648..5688d2eaf3 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py @@ -45,7 +45,7 @@ def run(self, **job_kwargs): elif self.params["recording_source"] == "corrected": correct_motion_kwargs = self.params["correct_motion_kwargs"] recording = InterpolateMotionRecording( - self.drifting_recording, self.motion, self.temporal_bins, self.spatial_bins, **correct_motion_kwargs + self.drifting_recording, self.motion, **correct_motion_kwargs ) else: raise ValueError("recording_source") diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py index dec0e612f8..f1aeeb54fb 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py @@ -52,13 +52,13 @@ def test_benchmark_motion_estimaton(): ) study_folder = cache_folder / "study_motion_estimation" - if study_folder.exists(): - shutil.rmtree(study_folder) - study = MotionEstimationStudy.create(study_folder, datasets, cases) + # if study_folder.exists(): + # shutil.rmtree(study_folder) + # study = MotionEstimationStudy.create(study_folder, datasets, cases) - # run and result - study.run(**job_kwargs) - study.compute_results() + # # run and result + # study.run(**job_kwargs) + # study.compute_results() # load study to check persistency study = MotionEstimationStudy(study_folder) @@ -66,10 +66,11 @@ def test_benchmark_motion_estimaton(): # plots study.plot_true_drift() + study.plot_drift() study.plot_errors() study.plot_summary_errors() - import matplotlib.pyplot as plt + import matplotlib.pyplot as plt plt.show() diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py index bf4522df94..f1b05dbb6d 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py @@ -49,8 +49,11 @@ def test_benchmark_motion_interpolation(): spatial_bins, direction_dim=1, ) + # print(gt_motion) + + # import matplotlib.pyplot as plt # fig, ax = plt.subplots() - # ax.imshow(gt_motion.T) + # ax.imshow(gt_motion.displacement[0].T) # plt.show() cases = {} @@ -131,6 +134,7 @@ def test_benchmark_motion_interpolation(): study.plot_sorting_accuracy(mode="depth", mode_best_merge=False) study.plot_sorting_accuracy(mode="depth", mode_best_merge=True) + import matplotlib.pyplot as plt plt.show() From 919e16ad826a5df7e74d4efd3aaff1ac89dfa9ba Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 12 Jun 2024 18:40:05 +0200 Subject: [PATCH 109/248] wip Motion propagation in widgets --- .../sorters/internal/spyking_circus2.py | 4 +- .../tests/test_benchmark_motion_estimation.py | 12 +- .../tests/test_motion_utils.py | 25 +++ src/spikeinterface/widgets/motion.py | 159 +++++++++++++++--- .../widgets/tests/test_widgets.py | 79 +++++++-- src/spikeinterface/widgets/widget_list.py | 4 +- 6 files changed, 233 insertions(+), 50 deletions(-) diff --git a/src/spikeinterface/sorters/internal/spyking_circus2.py b/src/spikeinterface/sorters/internal/spyking_circus2.py index 05853b4c39..5d04495c7e 100644 --- a/src/spikeinterface/sorters/internal/spyking_circus2.py +++ b/src/spikeinterface/sorters/internal/spyking_circus2.py @@ -317,7 +317,9 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): from spikeinterface.preprocessing.motion import load_motion_info motion_info = load_motion_info(motion_folder) - merging_params["maximum_distance_um"] = max(50, 2 * np.abs(motion_info["motion"]).max()) + motion = motion_info["motion"] + max_motion = max(np.max(np.abs(motion.displacement[seg_index])) for seg_index in range(len(motion.displacement))) + merging_params["maximum_distance_um"] = max(50, 2 * max_motion) # peak_sign = params['detection'].get('peak_sign', 'neg') # best_amplitudes = get_template_extremum_amplitude(templates, peak_sign=peak_sign) diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py index f1aeeb54fb..e0f151eafe 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py @@ -52,13 +52,13 @@ def test_benchmark_motion_estimaton(): ) study_folder = cache_folder / "study_motion_estimation" - # if study_folder.exists(): - # shutil.rmtree(study_folder) - # study = MotionEstimationStudy.create(study_folder, datasets, cases) + if study_folder.exists(): + shutil.rmtree(study_folder) + study = MotionEstimationStudy.create(study_folder, datasets, cases) - # # run and result - # study.run(**job_kwargs) - # study.compute_results() + # run and result + study.run(**job_kwargs) + study.compute_results() # load study to check persistency study = MotionEstimationStudy(study_folder) diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 84dda89d0d..1542c8531a 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -5,6 +5,7 @@ import numpy as np import pytest from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.generation import make_one_displacement_vector if hasattr(pytest, "global_test_folder"): cache_folder = pytest.global_test_folder / "sortingcomponents" @@ -12,6 +13,30 @@ cache_folder = Path("cache_folder") / "sortingcomponents" +def make_fake_motion(): + displacement_sampling_frequency = 5. + spatial_bins_um = np.array([100.0, 200.0, 300., 400.]) + + displacement_vector = make_one_displacement_vector( + drift_mode="zigzag", + duration=50.0, + amplitude_factor=1.0, + displacement_sampling_frequency=displacement_sampling_frequency, + period_s=25., + ) + temporal_bins_s = np.arange(displacement_vector.size) / displacement_sampling_frequency + displacement = np.zeros((temporal_bins_s.size, spatial_bins_um.size)) + + n = spatial_bins_um.size + for i in range(n): + displacement[:, i] = displacement_vector * ((i +1 ) / n) + + motion = Motion(displacement, temporal_bins_s, spatial_bins_um, direction="y") + + return motion + + + def test_Motion(): temporal_bins_s = np.arange(0.0, 10.0, 1.0) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 9d64c89e46..d83be77eb3 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -4,15 +4,110 @@ from .base import BaseWidget, to_attr - class MotionWidget(BaseWidget): """ - Plot unit depths + Plot the Motion object + + Parameters + ---------- + motion: Motion + The motion object + segment_index: None | int + If Motion is multi segment, the must be not None + mode: "auto" | "line" | "map" + How to plot map or lines. + "auto" make it automatic if the number of depth is too high. + """ + def __init__( + self, + motion, + segment_index=None, + mode="line", + motion_lim=None, + backend=None, + **backend_kwargs, + + ): + if isinstance(motion, dict): + raise ValueError("The API has changed, plot_motion() used Motion object now, maybe you want plot_motion_info(motion_info)") + + if segment_index is None: + if len(motion.displacement) == 1: + segment_index = 0 + else: + raise ValueError("plot motion : teh Motion object is multi segment you must provide segmentindex=XX") + + plot_data = dict( + motion=motion, + segment_index=segment_index, + mode=mode, + ) + + BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) + + def plot_matplotlib(self, data_plot, **backend_kwargs): + import matplotlib.pyplot as plt + from .utils_matplotlib import make_mpl_figure + from matplotlib.colors import Normalize + + dp = to_attr(data_plot) + + motion = data_plot["motion"] + segment_index = data_plot["segment_index"] + + assert backend_kwargs["axes"] is None + + self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) + + + displacement = motion.displacement[dp.segment_index] + temporal_bins_s = motion.temporal_bins_s[dp.segment_index] + depth = motion.spatial_bins_um + + if dp.motion_lim is None: + motion_lim = np.max(np.abs(displacement)) * 1.05 + else: + motion_lim = dp.motion_lim + + + ax = self.ax + fig = self.figure + if dp.mode == "line": + ax.plot(temporal_bins_s, displacement, alpha=0.2, color="black") + ax.plot(temporal_bins_s, np.mean(displacement, axis=1), color="C0") + ax.set_xlabel("Times [s]") + ax.set_ylabel("motion [um]") + elif dp.mode == "map": + im = ax.imshow( + displacement.T, + interpolation="nearest", + aspect="auto", + origin="lower", + extent=(temporal_bins_s[0], temporal_bins_s[-1], depth[0], depth[-1]), + cmap="PiYG" + ) + im.set_clim(-motion_lim, motion_lim) + + cbar = fig.colorbar(im) + cbar.ax.set_ylabel("motion [um]") + ax.set_xlabel("Times [s]") + ax.set_ylabel("Depth [um]") + + +class MotionInfoWidget(BaseWidget): + """ + Plot motion information from the motion_info dict returned by correct_motion(). + This plot: + * the motion iself + * the peak depth vs time before correction + * the peak depth vs time after correction Parameters ---------- motion_info: dict The motion info return by correct_motion() or load back with load_motion_info() + segment_index: + recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) sampling_frequency : float, default: None @@ -36,6 +131,7 @@ class MotionWidget(BaseWidget): def __init__( self, motion_info, + segment_index=None, recording=None, depth_lim=None, motion_lim=None, @@ -47,6 +143,14 @@ def __init__( backend=None, **backend_kwargs, ): + + motion = motion_info["motion"] + if segment_index is None: + if len(motion.displacement) == 1: + segment_index = 0 + else: + raise ValueError("plot motion : teh Motion object is multi segment you must provide segmentindex=XX") + times = recording.get_times() if recording is not None else None plot_data = dict( @@ -59,6 +163,8 @@ def __init__( amplitude_cmap=amplitude_cmap, amplitude_clim=amplitude_clim, amplitude_alpha=amplitude_alpha, + segment_index=segment_index, + recording=recording, **motion_info, ) @@ -80,7 +186,20 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): fig = self.figure fig.clear() - is_rigid = dp.motion.shape[1] == 1 + is_rigid = dp.motion.spatial_bins_um.shape[0] == 1 + + motion = dp.motion + + + displacement = motion.displacement[dp.segment_index] + temporal_bins_s = motion.temporal_bins_s[dp.segment_index] + spatial_bins_um = motion.spatial_bins_um + + if dp.motion_lim is None: + motion_lim = np.max(np.abs(displacement)) * 1.05 + else: + motion_lim = dp.motion_lim + gs = fig.add_gridspec(2, 2, wspace=0.3, hspace=0.3) ax0 = fig.add_subplot(gs[0, 0]) @@ -91,31 +210,23 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax1.sharex(ax0) ax1.sharey(ax0) - if dp.motion_lim is None: - motion_lim = np.max(np.abs(dp.motion)) * 1.05 - else: - motion_lim = dp.motion_lim - if dp.times is None: - temporal_bins_plot = dp.temporal_bins + # temporal_bins_plot = dp.temporal_bins x = dp.peaks["sample_index"] / dp.sampling_frequency else: # use real times and adjust temporal bins with t_start - temporal_bins_plot = dp.temporal_bins + dp.times[0] + # temporal_bins_plot = dp.temporal_bins + dp.times[0] x = dp.times[dp.peaks["sample_index"]] corrected_location = correct_motion_on_peaks( dp.peaks, dp.peak_locations, - dp.sampling_frequency, - dp.motion, - dp.temporal_bins, - dp.spatial_bins, - direction="y", + dp.recording, + dp.motion ) - y = dp.peak_locations["y"] - y2 = corrected_location["y"] + y = dp.peak_locations[motion.direction] + y2 = corrected_location[motion.direction] if dp.scatter_decimate is not None: x = x[:: dp.scatter_decimate] y = y[:: dp.scatter_decimate] @@ -156,8 +267,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax1.set_ylabel("Depth [um]") ax1.set_title("Corrected peak depth") - ax2.plot(temporal_bins_plot, dp.motion, alpha=0.2, color="black") - ax2.plot(temporal_bins_plot, np.mean(dp.motion, axis=1), color="C0") + ax2.plot(temporal_bins_s, displacement, alpha=0.2, color="black") + ax2.plot(temporal_bins_s, np.mean(displacement, axis=1), color="C0") ax2.set_ylim(-motion_lim, motion_lim) ax2.set_ylabel("Motion [um]") ax2.set_title("Motion vectors") @@ -165,14 +276,14 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): if not is_rigid: im = ax3.imshow( - dp.motion.T, + displacement.T, aspect="auto", origin="lower", extent=( - temporal_bins_plot[0], - temporal_bins_plot[-1], - dp.spatial_bins[0], - dp.spatial_bins[-1], + temporal_bins_s[0], + temporal_bins_s[-1], + spatial_bins_um[0], + spatial_bins_um[-1], ), ) im.set_clim(-motion_lim, motion_lim) diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index 156d1d92e2..8360842572 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -3,6 +3,8 @@ import os from pathlib import Path +import numpy as np + if __name__ != "__main__": try: import matplotlib @@ -76,25 +78,25 @@ def setUpClass(cls): ) job_kwargs = dict(n_jobs=-1) - # create dense - cls.sorting_analyzer_dense = create_sorting_analyzer(cls.sorting, cls.recording, format="memory", sparse=False) - cls.sorting_analyzer_dense.compute("random_spikes") - cls.sorting_analyzer_dense.compute(extensions_to_compute, **job_kwargs) + # # create dense + # cls.sorting_analyzer_dense = create_sorting_analyzer(cls.sorting, cls.recording, format="memory", sparse=False) + # cls.sorting_analyzer_dense.compute("random_spikes") + # cls.sorting_analyzer_dense.compute(extensions_to_compute, **job_kwargs) - sw.set_default_plotter_backend("matplotlib") + # sw.set_default_plotter_backend("matplotlib") - # make sparse waveforms - cls.sparsity_radius = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=50) - cls.sparsity_strict = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=20) - cls.sparsity_large = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=80) - cls.sparsity_best = compute_sparsity(cls.sorting_analyzer_dense, method="best_channels", num_channels=5) + # # make sparse waveforms + # cls.sparsity_radius = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=50) + # cls.sparsity_strict = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=20) + # cls.sparsity_large = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=80) + # cls.sparsity_best = compute_sparsity(cls.sorting_analyzer_dense, method="best_channels", num_channels=5) - # create sparse - cls.sorting_analyzer_sparse = create_sorting_analyzer( - cls.sorting, cls.recording, format="memory", sparsity=cls.sparsity_radius - ) - cls.sorting_analyzer_sparse.compute("random_spikes") - cls.sorting_analyzer_sparse.compute(extensions_to_compute, **job_kwargs) + # # create sparse + # cls.sorting_analyzer_sparse = create_sorting_analyzer( + # cls.sorting, cls.recording, format="memory", sparsity=cls.sparsity_radius + # ) + # cls.sorting_analyzer_sparse.compute("random_spikes") + # cls.sorting_analyzer_sparse.compute(extensions_to_compute, **job_kwargs) cls.skip_backends = ["ipywidgets", "ephyviewer", "spikeinterface_gui"] # cls.skip_backends = ["ipywidgets", "ephyviewer", "sortingview"] @@ -111,7 +113,7 @@ def setUpClass(cls): "spikeinterface_gui": {}, } - cls.gt_comp = sc.compare_sorter_to_ground_truth(cls.sorting, cls.sorting) + # cls.gt_comp = sc.compare_sorter_to_ground_truth(cls.sorting, cls.sorting) from spikeinterface.sortingcomponents.peak_detection import detect_peaks @@ -583,6 +585,45 @@ def test_plot_multicomparison(self): _, axes = plt.subplots(len(mcmp.object_list), 1) sw.plot_multicomparison_agreement_by_sorter(mcmp, axes=axes) + + def test_plot_motion(self): + from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion + motion = make_fake_motion() + + possible_backends = list(sw.MotionWidget.get_possible_backends()) + for backend in possible_backends: + if backend not in self.skip_backends: + sw.plot_motion(motion, backend=backend, mode='line') + sw.plot_motion(motion, backend=backend, mode='map') + + def test_plot_motion_info(self): + from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion + + + motion = make_fake_motion() + rng = np.random.default_rng(seed=2205) + peak_locations = np.zeros(self.peaks.size, dtype=[("x", "float64"), ("y", "float64")]) + peak_locations['y'] = rng.uniform(motion.spatial_bins_um[0], + motion.spatial_bins_um[-1], + size=self.peaks.size) + + motion_info = dict( + motion=motion, + parameters=dict(sampling_frequency=30000.), + run_times=dict(), + peaks=self.peaks, + peak_locations=peak_locations, + ) + + + possible_backends = list(sw.MotionWidget.get_possible_backends()) + for backend in possible_backends: + if backend not in self.skip_backends: + sw.plot_motion_info(motion_info, recording=self.recording, backend=backend) + + + + if __name__ == "__main__": @@ -598,7 +639,7 @@ def test_plot_multicomparison(self): # mytest.test_plot_traces() # mytest.test_plot_spikes_on_traces() # mytest.test_plot_unit_waveforms() - mytest.test_plot_spikes_on_traces() + # mytest.test_plot_spikes_on_traces() # mytest.test_plot_unit_depths() # mytest.test_plot_autocorrelograms() # mytest.test_plot_crosscorrelograms() @@ -618,6 +659,8 @@ def test_plot_multicomparison(self): # mytest.test_plot_peak_activity() # mytest.test_plot_multicomparison() # mytest.test_plot_sorting_summary() + # mytest.test_plot_motion() + mytest.test_plot_motion_info() plt.show() # TestWidgets.tearDownClass() diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index b3c1820276..19ce40ca2b 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -10,7 +10,7 @@ from .autocorrelograms import AutoCorrelogramsWidget from .crosscorrelograms import CrossCorrelogramsWidget from .isi_distribution import ISIDistributionWidget -from .motion import MotionWidget +from .motion import MotionWidget, MotionInfoWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget from .peak_activity import PeakActivityMapWidget from .probe_map import ProbeMapWidget @@ -44,6 +44,7 @@ CrossCorrelogramsWidget, ISIDistributionWidget, MotionWidget, + MotionInfoWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget, MultiCompGraphWidget, @@ -115,6 +116,7 @@ plot_crosscorrelograms = CrossCorrelogramsWidget plot_isi_distribution = ISIDistributionWidget plot_motion = MotionWidget +plot_motion_info = MotionInfoWidget plot_multicomparison_agreement = MultiCompGlobalAgreementWidget plot_multicomparison_agreement_by_sorter = MultiCompAgreementBySorterWidget plot_multicomparison_graph = MultiCompGraphWidget From a891045b24b0d55df2f70a8598554533669bcaeb Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 12 Jun 2024 11:20:22 -0600 Subject: [PATCH 110/248] remove upper bound in scipy --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dadb677056..ea798c31ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ extractors = [ "pyedflib>=0.1.30", "sonpy;python_version<'3.10'", "lxml", # lxml for neuroscope - "scipy<1.13", + "scipy", "ONE-api>=2.7.0", # alf sorter and streaming IBL "ibllib>=2.32.5", # streaming IBL "pymatreader>=0.0.32", # For cell explorer matlab files @@ -75,8 +75,6 @@ extractors = [ streaming_extractors = [ "ONE-api>=2.7.0", # alf sorter and streaming IBL "ibllib>=2.32.5", # streaming IBL - "scipy<1.13", # ibl has a dependency on scipy but it does not have an upper bound - # Remove this once https://github.com/int-brain-lab/ibllib/issues/753 # Following dependencies are for streaming with nwb files "pynwb>=2.6.0", "fsspec", From 58cfcc481141bede3c97676f916d2e3dd4b06389 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 12 Jun 2024 11:23:06 -0600 Subject: [PATCH 111/248] update ibllib --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ea798c31ad..ef7f4bebf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ extractors = [ streaming_extractors = [ "ONE-api>=2.7.0", # alf sorter and streaming IBL - "ibllib>=2.32.5", # streaming IBL + "ibllib>=2.36.0", # streaming IBL # Following dependencies are for streaming with nwb files "pynwb>=2.6.0", "fsspec", From cf5041062a73b1c61d8f15200ade73ea1f1d8bae Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 19:14:51 +0100 Subject: [PATCH 112/248] Run checks for singularity, docker and related python module installations. --- src/spikeinterface/sorters/runsorter.py | 18 ++++++++++++++ src/spikeinterface/sorters/utils/misc.py | 31 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index baec6aaac3..44a08a34a7 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -169,6 +169,15 @@ def run_sorter( container_image = None else: container_image = docker_image + + if not has_docker(): + raise RuntimeError("Docker is not installed. Install docker " + "on this machine to run sorting with docker.") + + if not has_docker_python(): + raise RuntimeError("The python `docker` package must be installed." + "Install with `pip install docker`") + else: mode = "singularity" assert not docker_image @@ -176,6 +185,15 @@ def run_sorter( container_image = None else: container_image = singularity_image + + if not has_singularity(): + raise RuntimeError("Singularity is not installed. Install singularity " + "on this machine to run sorting with singularity.") + + if not has_spython(): + raise RuntimeError("The python singularity package must be installed." + "Install with `pip install spython`") + return run_sorter_container( container_image=container_image, mode=mode, diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index 0a6b4a986c..a1cf34f059 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import subprocess # TODO: decide best format for this from subprocess import check_output, CalledProcessError from typing import List, Union @@ -80,3 +81,33 @@ def has_nvidia(): return device_count > 0 except RuntimeError: # Failed to dlopen libcuda.so return False + +def _run_subprocess_silently(command): + output = subprocess.run( + command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return output + + +def has_docker(): + return self._run_subprocess_silently("docker --version").returncode == 0 + + +def has_singularity(): + return self._run_subprocess_silently("singularity --version").returncode == 0 + + +def has_docker_python(): + try: + import docker + return True + except ImportError: + return False + + +def has_spython(): + try: + import spython + return True + except ImportError: + return False From e49521939f2023c50943afad21a663c3d7822011 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 20:09:03 +0100 Subject: [PATCH 113/248] Add nvidia dependency checks, tidy up. --- src/spikeinterface/sorters/runsorter.py | 17 +++++++++++---- src/spikeinterface/sorters/utils/__init__.py | 2 +- src/spikeinterface/sorters/utils/misc.py | 22 +++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index 44a08a34a7..884cba590f 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -19,7 +19,7 @@ from ..core import BaseRecording, NumpySorting, load_extractor from ..core.core_tools import check_json, is_editable_mode from .sorterlist import sorter_dict -from .utils import SpikeSortingError, has_nvidia +from .utils import SpikeSortingError, has_nvidia, has_docker, has_docker_python, has_singularity, has_spython, has_docker_nvidia_installed, get_nvidia_docker_dependecies from .container_tools import ( find_recording_folders, path_to_unix, @@ -175,7 +175,7 @@ def run_sorter( "on this machine to run sorting with docker.") if not has_docker_python(): - raise RuntimeError("The python `docker` package must be installed." + raise RuntimeError("The python `docker` package must be installed. " "Install with `pip install docker`") else: @@ -191,8 +191,8 @@ def run_sorter( "on this machine to run sorting with singularity.") if not has_spython(): - raise RuntimeError("The python singularity package must be installed." - "Install with `pip install spython`") + raise RuntimeError("The python `spython` package must be installed to " + "run singularity. Install with `pip install spython`") return run_sorter_container( container_image=container_image, @@ -480,6 +480,15 @@ def run_sorter_container( if gpu_capability == "nvidia-required": assert has_nvidia(), "The container requires a NVIDIA GPU capability, but it is not available" extra_kwargs["container_requires_gpu"] = True + + if platform.system() == "Linux" and has_docker_nvidia_installed(): + warn( + f"nvidia-required but none of \n{get_nvidia_docker_dependecies()}\n were found. " + f"This may result in an error being raised during sorting. Try " + "installing `nvidia-container-toolkit`, including setting the " + "configuration steps, if running into errors." + ) + elif gpu_capability == "nvidia-optional": if has_nvidia(): extra_kwargs["container_requires_gpu"] = True diff --git a/src/spikeinterface/sorters/utils/__init__.py b/src/spikeinterface/sorters/utils/__init__.py index 6cad10b211..7f6f3089d4 100644 --- a/src/spikeinterface/sorters/utils/__init__.py +++ b/src/spikeinterface/sorters/utils/__init__.py @@ -1,2 +1,2 @@ from .shellscript import ShellScript -from .misc import SpikeSortingError, get_git_commit, has_nvidia, get_matlab_shell_name, get_bash_path +from .misc import SpikeSortingError, get_git_commit, has_nvidia, get_matlab_shell_name, get_bash_path, has_docker, has_docker_python, has_singularity, has_spython, has_docker_nvidia_installed, get_nvidia_docker_dependecies diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index a1cf34f059..4a900f4485 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -82,6 +82,7 @@ def has_nvidia(): except RuntimeError: # Failed to dlopen libcuda.so return False + def _run_subprocess_silently(command): output = subprocess.run( command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL @@ -90,12 +91,27 @@ def _run_subprocess_silently(command): def has_docker(): - return self._run_subprocess_silently("docker --version").returncode == 0 + return _run_subprocess_silently("docker --version").returncode == 0 def has_singularity(): - return self._run_subprocess_silently("singularity --version").returncode == 0 - + return _run_subprocess_silently("singularity --version").returncode == 0 + +def get_nvidia_docker_dependecies(): + return [ + "nvidia-docker", + "nvidia-docker2", + "nvidia-container-toolkit", + ] + +def has_docker_nvidia_installed(): + all_dependencies = get_nvidia_docker_dependecies() + has_dep = [] + for dep in all_dependencies: + has_dep.append( + _run_subprocess_silently(f"{dep} --version").returncode == 0 + ) + return not any(has_dep) def has_docker_python(): try: From e0656bb86901127c8b1c0f708e4970584e79a40d Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 20:15:48 +0100 Subject: [PATCH 114/248] Add docstrings. --- src/spikeinterface/sorters/utils/misc.py | 44 ++++++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index 4a900f4485..66744fbab1 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -84,9 +84,10 @@ def has_nvidia(): def _run_subprocess_silently(command): - output = subprocess.run( - command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) + """ + Run a subprocess command without outputting to stderr or stdout. + """ + output = subprocess.run(command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return output @@ -97,25 +98,45 @@ def has_docker(): def has_singularity(): return _run_subprocess_silently("singularity --version").returncode == 0 + +def has_docker_nvidia_installed(): + """ + On Linux, nvidia has a set of container dependencies + that are required for running GPU in docker. This is a little + complex and is described in more detail in the links below. + To summarise breifly, at least one of the `get_nvidia_docker_dependecies()` + is almost certainly required to run docker with GPU. + + https://github.com/NVIDIA/nvidia-docker/issues/1268 + https://www.howtogeek.com/devops/how-to-use-an-nvidia-gpu-with-docker-containers/ + + Returns + ------- + Whether at least one of the dependencies listed in + `get_nvidia_docker_dependecies()` is installed. + """ + all_dependencies = get_nvidia_docker_dependecies() + has_dep = [] + for dep in all_dependencies: + has_dep.append(_run_subprocess_silently(f"{dep} --version").returncode == 0) + return not any(has_dep) + + def get_nvidia_docker_dependecies(): + """ + See `has_docker_nvidia_installed()` + """ return [ "nvidia-docker", "nvidia-docker2", "nvidia-container-toolkit", ] -def has_docker_nvidia_installed(): - all_dependencies = get_nvidia_docker_dependecies() - has_dep = [] - for dep in all_dependencies: - has_dep.append( - _run_subprocess_silently(f"{dep} --version").returncode == 0 - ) - return not any(has_dep) def has_docker_python(): try: import docker + return True except ImportError: return False @@ -124,6 +145,7 @@ def has_docker_python(): def has_spython(): try: import spython + return True except ImportError: return False From b145b04ac31a8de3d9c9fbfc56b4a9974ce0eb3a Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 21:21:51 +0100 Subject: [PATCH 115/248] Add tests for runsorter dependencies. --- src/spikeinterface/sorters/runsorter.py | 35 +++-- .../tests/test_runsorter_dependency_checks.py | 144 ++++++++++++++++++ 2 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index 884cba590f..5b2e80b83d 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -19,7 +19,18 @@ from ..core import BaseRecording, NumpySorting, load_extractor from ..core.core_tools import check_json, is_editable_mode from .sorterlist import sorter_dict -from .utils import SpikeSortingError, has_nvidia, has_docker, has_docker_python, has_singularity, has_spython, has_docker_nvidia_installed, get_nvidia_docker_dependecies + +# full import required for monkeypatch testing. +from spikeinterface.sorters.utils import ( + SpikeSortingError, + has_nvidia, + has_docker, + has_docker_python, + has_singularity, + has_spython, + has_docker_nvidia_installed, + get_nvidia_docker_dependecies, +) from .container_tools import ( find_recording_folders, path_to_unix, @@ -171,12 +182,14 @@ def run_sorter( container_image = docker_image if not has_docker(): - raise RuntimeError("Docker is not installed. Install docker " - "on this machine to run sorting with docker.") + raise RuntimeError( + "Docker is not installed. Install docker " "on this machine to run sorting with docker." + ) if not has_docker_python(): - raise RuntimeError("The python `docker` package must be installed. " - "Install with `pip install docker`") + raise RuntimeError( + "The python `docker` package must be installed. " "Install with `pip install docker`" + ) else: mode = "singularity" @@ -187,12 +200,16 @@ def run_sorter( container_image = singularity_image if not has_singularity(): - raise RuntimeError("Singularity is not installed. Install singularity " - "on this machine to run sorting with singularity.") + raise RuntimeError( + "Singularity is not installed. Install singularity " + "on this machine to run sorting with singularity." + ) if not has_spython(): - raise RuntimeError("The python `spython` package must be installed to " - "run singularity. Install with `pip install spython`") + raise RuntimeError( + "The python `spython` package must be installed to " + "run singularity. Install with `pip install spython`" + ) return run_sorter_container( container_image=container_image, diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py new file mode 100644 index 0000000000..8dbb1b20f6 --- /dev/null +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -0,0 +1,144 @@ +import os +import pytest +from pathlib import Path +import shutil +import platform +from spikeinterface import generate_ground_truth_recording +from spikeinterface.sorters.utils import has_spython, has_docker_python +from spikeinterface.sorters import run_sorter +import subprocess +import sys +import copy + + +def _monkeypatch_return_false(): + return False + + +class TestRunersorterDependencyChecks: + """ + This class performs tests to check whether expected + dependency checks prior to sorting are run. The + run_sorter function should raise an error if: + - singularity is not installed + - spython is not installed (python package) + - docker is not installed + - docker is not installed (python package) + when running singularity / docker respectively. + + Two separate checks should be run. First, that the + relevant `has_` function (indicating if the dependency + is installed) is working. Unfortunately it is not possible to + easily test this core singularity and docker installs, so this is not done. + `uninstall_python_dependency()` allows a test to check if the + `has_spython()` and `has_docker_dependency()` return `False` as expected + when these python modules are not installed. + + Second, the `run_sorters()` function should return the appropriate error + when these functions return that the dependency is not available. This is + easier to test as these `has_` reporting functions can be + monkeypatched to return False at runtime. This is done for these 4 + dependency checks, and tests check the expected error is raised. + + Notes + ---- + `has_nvidia()` and `has_docker_nvidia_installed()` are not tested + as these are complex GPU-related dependencies which are difficult to mock. + """ + + @pytest.fixture(scope="function") + def uninstall_python_dependency(self, request): + """ + This python fixture mocks python modules not been importable + by setting the relevant `sys.modules` dict entry to `None`. + It uses `yeild` so that the function can tear-down the test + (even if it failed) and replace the patched `sys.module` entry. + + This function uses an `indirect` parameterisation, meaning the + `request.param` is passed to the fixture at the start of the + test function. This is used to reuse code for nearly identical + `spython` and `docker` python dependency tests. + """ + dep_name = request.param + assert dep_name in ["spython", "docker"] + + try: + if dep_name == "spython": + import spython + else: + import docker + dependency_installed = True + except: + dependency_installed = False + + if dependency_installed: + copy_import = sys.modules[dep_name] + sys.modules[dep_name] = None + yield + if dependency_installed: + sys.modules[dep_name] = copy_import + + @pytest.fixture(scope="session") + def recording(self): + """ + Make a small recording to have something to pass to the sorter. + """ + recording, _ = generate_ground_truth_recording(durations=[10]) + return recording + + @pytest.mark.skipif(platform.system() != "Linux", reason="spython install only for Linux.") + @pytest.mark.parametrize("uninstall_python_dependency", ["spython"], indirect=True) + def test_has_spython(self, recording, uninstall_python_dependency): + """ + Test the `has_spython()` function, see class docstring and + `uninstall_python_dependency()` for details. + """ + assert has_spython() is False + + @pytest.mark.parametrize("uninstall_python_dependency", ["docker"], indirect=True) + def test_has_docker_python(self, recording, uninstall_python_dependency): + """ + Test the `has_docker_python()` function, see class docstring and + `uninstall_python_dependency()` for details. + """ + assert has_docker_python() is False + + @pytest.mark.parametrize("dependency", ["singularity", "spython"]) + def test_has_singularity_and_spython(self, recording, monkeypatch, dependency): + """ + When running a sorting, if singularity dependencies (singularity + itself or the `spython` package`) are not installed, an error is raised. + Beacause it is hard to actually uninstall these dependencies, the + `has_` functions that let `run_sorter` know if the dependency + are installed are monkeypatched. This is done so at runtime these always + return False. Then, test the expected error is raised when the dependency + is not found. + """ + test_func = f"has_{dependency}" + + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.{test_func}", _monkeypatch_return_false) + with pytest.raises(RuntimeError) as e: + run_sorter("kilosort2_5", recording, singularity_image=True) + + if dependency == "spython": + assert "The python `spython` package must be installed" in str(e) + else: + assert "Singularity is not installed." in str(e) + + @pytest.mark.parametrize("dependency", ["docker", "docker_python"]) + def test_has_docker_and_docker_python(self, recording, monkeypatch, dependency): + """ + See `test_has_singularity_and_spython()` for details. This test + is almost identical, but with some key changes for Docker. + """ + test_func = f"has_{dependency}" + + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.{test_func}", _monkeypatch_return_false) + + with pytest.raises(RuntimeError) as e: + run_sorter("kilosort2_5", recording, docker_image=True) + + if dependency == "docker_python": + assert "The python `docker` package must be installed" in str(e) + else: + assert "Docker is not installed." in str(e) From 78ccc2719676b238dbd92d2ad5384786ca0724e0 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 21:24:29 +0100 Subject: [PATCH 116/248] Remove unnecessary non-relative import. --- src/spikeinterface/sorters/runsorter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index 5b2e80b83d..c16435cdb5 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -19,9 +19,7 @@ from ..core import BaseRecording, NumpySorting, load_extractor from ..core.core_tools import check_json, is_editable_mode from .sorterlist import sorter_dict - -# full import required for monkeypatch testing. -from spikeinterface.sorters.utils import ( +from .utils import ( SpikeSortingError, has_nvidia, has_docker, From f1438c4ce20bbd7ae3c910b793f92ebb4d723253 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 21:27:03 +0100 Subject: [PATCH 117/248] Fix some string formatting, add docstring to monkeypatch function. --- src/spikeinterface/sorters/runsorter.py | 6 ++---- .../sorters/tests/test_runsorter_dependency_checks.py | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index c16435cdb5..f9994dd38d 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -181,13 +181,11 @@ def run_sorter( if not has_docker(): raise RuntimeError( - "Docker is not installed. Install docker " "on this machine to run sorting with docker." + "Docker is not installed. Install docker on this machine to run sorting with docker." ) if not has_docker_python(): - raise RuntimeError( - "The python `docker` package must be installed. " "Install with `pip install docker`" - ) + raise RuntimeError("The python `docker` package must be installed. Install with `pip install docker`") else: mode = "singularity" diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py index 8dbb1b20f6..c81593b7db 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -12,6 +12,10 @@ def _monkeypatch_return_false(): + """ + A function to monkeypatch the `has_` functions, + ensuring the always return `False` at runtime. + """ return False From fd4406e0826f80329614e3b59388e9640c00fe3e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:27:36 +0000 Subject: [PATCH 118/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/sorters/utils/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/sorters/utils/__init__.py b/src/spikeinterface/sorters/utils/__init__.py index 7f6f3089d4..62317be6f2 100644 --- a/src/spikeinterface/sorters/utils/__init__.py +++ b/src/spikeinterface/sorters/utils/__init__.py @@ -1,2 +1,14 @@ from .shellscript import ShellScript -from .misc import SpikeSortingError, get_git_commit, has_nvidia, get_matlab_shell_name, get_bash_path, has_docker, has_docker_python, has_singularity, has_spython, has_docker_nvidia_installed, get_nvidia_docker_dependecies +from .misc import ( + SpikeSortingError, + get_git_commit, + has_nvidia, + get_matlab_shell_name, + get_bash_path, + has_docker, + has_docker_python, + has_singularity, + has_spython, + has_docker_nvidia_installed, + get_nvidia_docker_dependecies, +) From 7af611ba289e220c4bf36f4b62ae26efe94f93b1 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 21:42:26 +0100 Subject: [PATCH 119/248] Mock all has functions to ensure tests do not depend on actual dependencies. --- .../tests/test_runsorter_dependency_checks.py | 58 +++++++++++++------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py index c81593b7db..a248033089 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -4,7 +4,7 @@ import shutil import platform from spikeinterface import generate_ground_truth_recording -from spikeinterface.sorters.utils import has_spython, has_docker_python +from spikeinterface.sorters.utils import has_spython, has_docker_python, has_docker, has_singularity from spikeinterface.sorters import run_sorter import subprocess import sys @@ -19,6 +19,10 @@ def _monkeypatch_return_false(): return False +def _monkeypatch_return_true(): + return True + + class TestRunersorterDependencyChecks: """ This class performs tests to check whether expected @@ -91,6 +95,7 @@ def recording(self): return recording @pytest.mark.skipif(platform.system() != "Linux", reason="spython install only for Linux.") + @pytest.mark.skipif(not has_singularity(), reason="singularity required for this test.") @pytest.mark.parametrize("uninstall_python_dependency", ["spython"], indirect=True) def test_has_spython(self, recording, uninstall_python_dependency): """ @@ -100,6 +105,7 @@ def test_has_spython(self, recording, uninstall_python_dependency): assert has_spython() is False @pytest.mark.parametrize("uninstall_python_dependency", ["docker"], indirect=True) + @pytest.mark.skipif(not has_docker(), reason="docker required for this test.") def test_has_docker_python(self, recording, uninstall_python_dependency): """ Test the `has_docker_python()` function, see class docstring and @@ -107,8 +113,7 @@ def test_has_docker_python(self, recording, uninstall_python_dependency): """ assert has_docker_python() is False - @pytest.mark.parametrize("dependency", ["singularity", "spython"]) - def test_has_singularity_and_spython(self, recording, monkeypatch, dependency): + def test_no_singularity_error_raised(self, recording, monkeypatch): """ When running a sorting, if singularity dependencies (singularity itself or the `spython` package`) are not installed, an error is raised. @@ -118,31 +123,46 @@ def test_has_singularity_and_spython(self, recording, monkeypatch, dependency): return False. Then, test the expected error is raised when the dependency is not found. """ - test_func = f"has_{dependency}" + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_singularity", _monkeypatch_return_false) - monkeypatch.setattr(f"spikeinterface.sorters.runsorter.{test_func}", _monkeypatch_return_false) with pytest.raises(RuntimeError) as e: run_sorter("kilosort2_5", recording, singularity_image=True) - if dependency == "spython": - assert "The python `spython` package must be installed" in str(e) - else: - assert "Singularity is not installed." in str(e) + assert "Singularity is not installed." in str(e) - @pytest.mark.parametrize("dependency", ["docker", "docker_python"]) - def test_has_docker_and_docker_python(self, recording, monkeypatch, dependency): + def test_no_spython_error_raised(self, recording, monkeypatch): """ - See `test_has_singularity_and_spython()` for details. This test - is almost identical, but with some key changes for Docker. + See `test_no_singularity_error_raised()`. """ - test_func = f"has_{dependency}" + # make sure singularity test returns true as that comes first + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_singularity", _monkeypatch_return_true) + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_spython", _monkeypatch_return_false) + + with pytest.raises(RuntimeError) as e: + run_sorter("kilosort2_5", recording, singularity_image=True) + + assert "The python `spython` package must be installed" in str(e) - monkeypatch.setattr(f"spikeinterface.sorters.runsorter.{test_func}", _monkeypatch_return_false) + def test_no_docker_error_raised(self, recording, monkeypatch): + """ + See `test_no_singularity_error_raised()`. + """ + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_docker", _monkeypatch_return_false) + + with pytest.raises(RuntimeError) as e: + run_sorter("kilosort2_5", recording, docker_image=True) + + assert "Docker is not installed." in str(e) + + def test_as_no_docker_python_error_raised(self, recording, monkeypatch): + """ + See `test_no_singularity_error_raised()`. + """ + # make sure docker test returns true as that comes first + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_docker", _monkeypatch_return_true) + monkeypatch.setattr(f"spikeinterface.sorters.runsorter.has_docker_python", _monkeypatch_return_false) with pytest.raises(RuntimeError) as e: run_sorter("kilosort2_5", recording, docker_image=True) - if dependency == "docker_python": - assert "The python `docker` package must be installed" in str(e) - else: - assert "Docker is not installed." in str(e) + assert "The python `docker` package must be installed" in str(e) From 0c0b1f908d8e356b9a58cacd4524ace871ff93b3 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 21:43:10 +0100 Subject: [PATCH 120/248] Remove unecessary skips. --- .../sorters/tests/test_runsorter_dependency_checks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py index a248033089..741fe4ae0e 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -95,7 +95,6 @@ def recording(self): return recording @pytest.mark.skipif(platform.system() != "Linux", reason="spython install only for Linux.") - @pytest.mark.skipif(not has_singularity(), reason="singularity required for this test.") @pytest.mark.parametrize("uninstall_python_dependency", ["spython"], indirect=True) def test_has_spython(self, recording, uninstall_python_dependency): """ @@ -105,7 +104,6 @@ def test_has_spython(self, recording, uninstall_python_dependency): assert has_spython() is False @pytest.mark.parametrize("uninstall_python_dependency", ["docker"], indirect=True) - @pytest.mark.skipif(not has_docker(), reason="docker required for this test.") def test_has_docker_python(self, recording, uninstall_python_dependency): """ Test the `has_docker_python()` function, see class docstring and From 1be1dbd39a339ff56c0803ff7a59e5650d95b781 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 13 Jun 2024 09:04:04 +0100 Subject: [PATCH 121/248] Update docstrings. --- .../sorters/tests/test_runsorter_dependency_checks.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py index 741fe4ae0e..c4beaba072 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -20,14 +20,18 @@ def _monkeypatch_return_false(): def _monkeypatch_return_true(): + """ + Monkeypatch for some `has_` functions to + return `True` so functions that are later in the + `runsorter` code can be checked. + """ return True class TestRunersorterDependencyChecks: """ - This class performs tests to check whether expected - dependency checks prior to sorting are run. The - run_sorter function should raise an error if: + This class tests whether expected dependency checks prior to sorting are run. + The run_sorter function should raise an error if: - singularity is not installed - spython is not installed (python package) - docker is not installed From 00663080b03f7933d37ba4ff2ee32e3402aa200e Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 13 Jun 2024 09:10:30 +0100 Subject: [PATCH 122/248] Swap return bool for to match function name. --- src/spikeinterface/sorters/runsorter.py | 2 +- src/spikeinterface/sorters/utils/misc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sorters/runsorter.py b/src/spikeinterface/sorters/runsorter.py index f9994dd38d..80608f8973 100644 --- a/src/spikeinterface/sorters/runsorter.py +++ b/src/spikeinterface/sorters/runsorter.py @@ -494,7 +494,7 @@ def run_sorter_container( assert has_nvidia(), "The container requires a NVIDIA GPU capability, but it is not available" extra_kwargs["container_requires_gpu"] = True - if platform.system() == "Linux" and has_docker_nvidia_installed(): + if platform.system() == "Linux" and not has_docker_nvidia_installed(): warn( f"nvidia-required but none of \n{get_nvidia_docker_dependecies()}\n were found. " f"This may result in an error being raised during sorting. Try " diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index 66744fbab1..1e01b9c052 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -119,7 +119,7 @@ def has_docker_nvidia_installed(): has_dep = [] for dep in all_dependencies: has_dep.append(_run_subprocess_silently(f"{dep} --version").returncode == 0) - return not any(has_dep) + return any(has_dep) def get_nvidia_docker_dependecies(): From 8111789f0848f4b79ab52a8b36d06231b2f62286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Proville?= Date: Thu, 13 Jun 2024 16:15:33 +0200 Subject: [PATCH 123/248] Apply suggestions from code review: typography et al Co-authored-by: Alessio Buccino --- src/spikeinterface/curation/curation_format.py | 6 ------ src/spikeinterface/curation/tests/test_curation_format.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/curation/curation_format.py b/src/spikeinterface/curation/curation_format.py index 82921a56b5..d6eded4345 100644 --- a/src/spikeinterface/curation/curation_format.py +++ b/src/spikeinterface/curation/curation_format.py @@ -14,12 +14,6 @@ def validate_curation_dict(curation_dict): ---------- curation_dict : dict - - Returns - ------- - Nothing. - - """ # format diff --git a/src/spikeinterface/curation/tests/test_curation_format.py b/src/spikeinterface/curation/tests/test_curation_format.py index 1bee5b524c..6d132fbe97 100644 --- a/src/spikeinterface/curation/tests/test_curation_format.py +++ b/src/spikeinterface/curation/tests/test_curation_format.py @@ -106,13 +106,13 @@ def test_curation_format_validation(): # Raised because duplicated merged units validate_curation_dict(duplicate_merge) with pytest.raises(ValueError): - # Raised because Some units belong to multiple merge groups" + # Raised because some units belong to merged and removed unit groups validate_curation_dict(merged_and_removed) with pytest.raises(ValueError): # Some merged units are not in the unit list validate_curation_dict(unknown_merged_unit) with pytest.raises(ValueError): - # Raise beecause Some removed units are not in the unit list + # Raise because some removed units are not in the unit list validate_curation_dict(unknown_removed_unit) From 609cd34732362f4de92672c0574385d6626bdd31 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 13 Jun 2024 09:46:14 -0600 Subject: [PATCH 124/248] update the other ibl --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ef7f4bebf0..51528fcc8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ extractors = [ "lxml", # lxml for neuroscope "scipy", "ONE-api>=2.7.0", # alf sorter and streaming IBL - "ibllib>=2.32.5", # streaming IBL + "ibllib>=2.36.0", # streaming IBL "pymatreader>=0.0.32", # For cell explorer matlab files "zugbruecke>=0.2; sys_platform!='win32'", # For plexon2 ] From d8a3826fe3c5f74f7db335be01966ed0f55cb2a7 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:24:45 +0100 Subject: [PATCH 125/248] Add Zach improvements --- src/spikeinterface/preprocessing/astype.py | 6 +++--- .../preprocessing/detect_bad_channels.py | 14 +++++++------- src/spikeinterface/preprocessing/resample.py | 3 --- .../preprocessing/silence_periods.py | 3 ++- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/spikeinterface/preprocessing/astype.py b/src/spikeinterface/preprocessing/astype.py index ce8dbc3ca7..a05610ea2e 100644 --- a/src/spikeinterface/preprocessing/astype.py +++ b/src/spikeinterface/preprocessing/astype.py @@ -17,12 +17,12 @@ class AstypeRecording(BasePreprocessor): Parameters ---------- dtype : None | str | dtype, default: None - dtype of the output recording. + dtype of the output recording. If None, takes dtype from input `recording`. recording : Recording The recording extractor to be converted. round : Bool | None, default: None - If True, will round the values to the nearest integer. - If None, will round in the case of float to integer conversion. + If True, will round the values to the nearest integer using `numpy.round`. + If None and dtype is an integer, will round floats to nearest integer. Returns ------- diff --git a/src/spikeinterface/preprocessing/detect_bad_channels.py b/src/spikeinterface/preprocessing/detect_bad_channels.py index 218c9cb822..5d8f7107c7 100644 --- a/src/spikeinterface/preprocessing/detect_bad_channels.py +++ b/src/spikeinterface/preprocessing/detect_bad_channels.py @@ -58,28 +58,28 @@ def detect_bad_channels( std_mad_threshold : float, default: 5 The standard deviation/mad multiplier threshold psd_hf_threshold : float, default: 0.02 - Coeherence+psd. An absolute threshold (uV^2/Hz) used as a cutoff for noise channels. + For coherence+psd - an absolute threshold (uV^2/Hz) used as a cutoff for noise channels. Channels with average power at >80% Nyquist larger than this threshold will be labeled as noise dead_channel_threshold : float, default: -0.5 - Coeherence+psd. Threshold for channel coherence below which channels are labeled as dead + For coherence+psd - threshold for channel coherence below which channels are labeled as dead noisy_channel_threshold : float, default: 1 Threshold for channel coherence above which channels are labeled as noisy (together with psd condition) outside_channel_threshold : float, default: -0.75 - Coeherence+psd. Threshold for channel coherence above which channels at the edge of the recording are marked as outside + For coherence+psd - threshold for channel coherence above which channels at the edge of the recording are marked as outside of the brain outside_channels_location : "top" | "bottom" | "both", default: "top" - Coeherence+psd. Location of the outside channels. If "top", only the channels at the top of the probe can be + For coherence+psd - location of the outside channels. If "top", only the channels at the top of the probe can be marked as outside channels. If "bottom", only the channels at the bottom of the probe can be marked as outside channels. If "both", both the channels at the top and bottom of the probe can be marked as outside channels n_neighbors : int, default: 11 - Coeherence+psd. Number of channel neighbors to compute median filter (needs to be odd) + For coeherence+psd - number of channel neighbors to compute median filter (needs to be odd) nyquist_threshold : float, default: 0.8 - Coeherence+psd. Frequency with respect to Nyquist (Fn=1) above which the mean of the PSD is calculated and compared + For coherence+psd - frequency with respect to Nyquist (Fn=1) above which the mean of the PSD is calculated and compared with psd_hf_threshold direction : "x" | "y" | "z", default: "y" - Coeherence+psd. The depth dimension + For coherence+psd - the depth dimension highpass_filter_cutoff : float, default: 300 If the recording is not filtered, the cutoff frequency of the highpass filter chunk_duration_s : float, default: 0.5 diff --git a/src/spikeinterface/preprocessing/resample.py b/src/spikeinterface/preprocessing/resample.py index ed77ec504d..4843df5444 100644 --- a/src/spikeinterface/preprocessing/resample.py +++ b/src/spikeinterface/preprocessing/resample.py @@ -34,9 +34,6 @@ class ResampleRecording(BasePreprocessor): The dtype of the returned traces. If None, the dtype of the parent recording is used. skip_checks : bool, default: False If True, checks on sampling frequencies and cutoff filter frequencies are skipped - margin_ms : float, default: 100.0 - Margin in ms on border to avoid border effect - Returns ------- diff --git a/src/spikeinterface/preprocessing/silence_periods.py b/src/spikeinterface/preprocessing/silence_periods.py index 88c7e2109c..74d370b3a9 100644 --- a/src/spikeinterface/preprocessing/silence_periods.py +++ b/src/spikeinterface/preprocessing/silence_periods.py @@ -26,7 +26,8 @@ class SilencedPeriodsRecording(BasePreprocessor): noise_levels : array Noise levels if already computed seed : int | None, default: None - Random seed for `get_noise_levels` + Random seed for `get_noise_levels` and `NoiseGeneratorRecording`. + If none, `get_noise_levels` uses `seed=0` and `NoiseGeneratorRecording` generates a random seed using `numpy.random.default_rng`. mode : "zeros" | "noise, default: "zeros" Determines what periods are replaced by. Can be one of the following: From bcaafeaefcab0d0bf0c70273e69b8d6f79f33fc4 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 12:31:30 -0600 Subject: [PATCH 126/248] fix most egregorious deprecated behavior and cap version --- pyproject.toml | 2 +- src/spikeinterface/core/core_tools.py | 2 +- .../core/tests/test_jsonification.py | 1 - .../tests/test_template_database.py | 2 +- .../tests/test_highpass_spatial_filter.py | 19 ++++++++------- .../tests/test_interpolate_bad_channels.py | 24 +++++++++++-------- .../sortingcomponents/peak_localization.py | 4 ++-- .../test_waveform_thresholder.py | 2 +- .../waveforms/waveform_thresholder.py | 2 +- 9 files changed, 31 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dadb677056..9dbf0c0229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ - "numpy", + "numpy>=1.20, <2.0", # Minimal needed for np.ptp "threadpoolctl>=3.0.0", "tqdm", "zarr>=2.16,<2.18", diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 5232539422..681392d3f4 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -83,7 +83,7 @@ def default(self, obj): if isinstance(obj, np.generic): return obj.item() - if np.issctype(obj): # Cast numpy datatypes to their names + if isinstance(obj, np.dtype): return np.dtype(obj).name if isinstance(obj, np.ndarray): diff --git a/src/spikeinterface/core/tests/test_jsonification.py b/src/spikeinterface/core/tests/test_jsonification.py index f63cfb16d8..4417ea342f 100644 --- a/src/spikeinterface/core/tests/test_jsonification.py +++ b/src/spikeinterface/core/tests/test_jsonification.py @@ -122,7 +122,6 @@ def test_numpy_dtype_alises_encoding(): # People tend to use this a dtype instead of the proper classes json.dumps(np.int32, cls=SIJsonEncoder) json.dumps(np.float32, cls=SIJsonEncoder) - json.dumps(np.bool_, cls=SIJsonEncoder) # Note that np.bool was deperecated in numpy 1.20.0 def test_recording_encoding(numpy_generated_recording): diff --git a/src/spikeinterface/generation/tests/test_template_database.py b/src/spikeinterface/generation/tests/test_template_database.py index a71faf0683..9e2a013ad0 100644 --- a/src/spikeinterface/generation/tests/test_template_database.py +++ b/src/spikeinterface/generation/tests/test_template_database.py @@ -36,7 +36,7 @@ def test_fetch_templates_database_info(): def test_query_templates_from_database(): templates_info = fetch_templates_database_info() - templates_info = templates_info.iloc[::15] + templates_info = templates_info.iloc[[1, 3, 5]] num_selected = len(templates_info) templates = query_templates_from_database(templates_info) diff --git a/src/spikeinterface/preprocessing/tests/test_highpass_spatial_filter.py b/src/spikeinterface/preprocessing/tests/test_highpass_spatial_filter.py index 5c843e7c0b..0dd75fd476 100644 --- a/src/spikeinterface/preprocessing/tests/test_highpass_spatial_filter.py +++ b/src/spikeinterface/preprocessing/tests/test_highpass_spatial_filter.py @@ -8,14 +8,7 @@ import spikeinterface.extractors as se from spikeinterface.core import generate_recording import spikeinterface.widgets as sw - -try: - import spikeglx - import neurodsp.voltage as voltage - - HAVE_IBL_NPIX = True -except ImportError: - HAVE_IBL_NPIX = False +import importlib.util ON_GITHUB = bool(os.getenv("GITHUB_ACTIONS")) @@ -31,7 +24,10 @@ # ---------------------------------------------------------------------------------------------------------------------- -@pytest.mark.skipif(not HAVE_IBL_NPIX or ON_GITHUB, reason="Only local. Requires ibl-neuropixel install") +@pytest.mark.skipif( + importlib.util.find_spec("neurodsp") is not None or importlib.util.find_spec("spikeglx") or ON_GITHUB, + reason="Only local. Requires ibl-neuropixel install", +) @pytest.mark.parametrize("lagc", [False, 1, 300]) def test_highpass_spatial_filter_real_data(lagc): """ @@ -56,6 +52,9 @@ def test_highpass_spatial_filter_real_data(lagc): use DEBUG = true to visualise. """ + import spikeglx + import neurodsp.voltage as voltage + options = dict(lagc=lagc, ntr_pad=25, ntr_tap=50, butter_kwargs=None) print(options) @@ -146,6 +145,8 @@ def get_ibl_si_data(): """ Set fixture to session to ensure origional data is not changed. """ + import spikeglx + local_path = si.download_dataset(remote_path="spikeglx/Noise4Sam_g0") ibl_recording = spikeglx.Reader( local_path / "Noise4Sam_g0_imec0" / "Noise4Sam_g0_t0.imec0.ap.bin", ignore_warnings=True diff --git a/src/spikeinterface/preprocessing/tests/test_interpolate_bad_channels.py b/src/spikeinterface/preprocessing/tests/test_interpolate_bad_channels.py index ad073e40aa..1189f04f7d 100644 --- a/src/spikeinterface/preprocessing/tests/test_interpolate_bad_channels.py +++ b/src/spikeinterface/preprocessing/tests/test_interpolate_bad_channels.py @@ -6,17 +6,10 @@ import spikeinterface.preprocessing as spre import spikeinterface.extractors as se from spikeinterface.core.generate import generate_recording +import importlib.util -try: - import spikeglx - import neurodsp.voltage as voltage - - HAVE_IBL_NPIX = True -except ImportError: - HAVE_IBL_NPIX = False ON_GITHUB = bool(os.getenv("GITHUB_ACTIONS")) - DEBUG = False if DEBUG: import matplotlib.pyplot as plt @@ -30,7 +23,10 @@ # ------------------------------------------------------------------------------- -@pytest.mark.skipif(not HAVE_IBL_NPIX or ON_GITHUB, reason="Only local. Requires ibl-neuropixel install") +@pytest.mark.skipif( + importlib.util.find_spec("neurodsp") is not None or importlib.util.find_spec("spikeglx") or ON_GITHUB, + reason="Only local. Requires ibl-neuropixel install", +) def test_compare_real_data_with_ibl(): """ Test SI implementation of bad channel interpolation against native IBL. @@ -43,6 +39,9 @@ def test_compare_real_data_with_ibl(): si_scaled_recordin.get_traces(0) is also close to 1e-2. """ # Download and load data + import spikeglx + import neurodsp.voltage as voltage + local_path = si.download_dataset(remote_path="spikeglx/Noise4Sam_g0") si_recording = se.read_spikeglx(local_path, stream_id="imec0.ap") ibl_recording = spikeglx.Reader( @@ -80,7 +79,10 @@ def test_compare_real_data_with_ibl(): assert np.mean(is_close) > 0.999 -@pytest.mark.skipif(not HAVE_IBL_NPIX, reason="Requires ibl-neuropixel install") +@pytest.mark.skipif( + importlib.util.find_spec("neurodsp") is not None or importlib.util.find_spec("spikeglx") is not None, + reason="Requires ibl-neuropixel install", +) @pytest.mark.parametrize("num_channels", [32, 64]) @pytest.mark.parametrize("sigma_um", [1.25, 40]) @pytest.mark.parametrize("p", [0, -0.5, 1, 5]) @@ -90,6 +92,8 @@ def test_compare_input_argument_ranges_against_ibl(shanks, p, sigma_um, num_chan Perform an extended test across a range of function inputs to check IBL and SI interpolation results match. """ + import neurodsp.voltage as voltage + recording = generate_recording(num_channels=num_channels, durations=[1]) # distribute default probe locations across 4 shanks if set diff --git a/src/spikeinterface/sortingcomponents/peak_localization.py b/src/spikeinterface/sortingcomponents/peak_localization.py index b06f6fac3e..716eecf123 100644 --- a/src/spikeinterface/sortingcomponents/peak_localization.py +++ b/src/spikeinterface/sortingcomponents/peak_localization.py @@ -204,7 +204,7 @@ def compute(self, traces, peaks, waveforms): wf = waveforms[idx][:, :, chan_inds] if self.feature == "ptp": - wf_data = wf.ptp(axis=1) + wf_data = np.ptp(wf, axis=1) elif self.feature == "mean": wf_data = wf.mean(axis=1) elif self.feature == "energy": @@ -293,7 +293,7 @@ def compute(self, traces, peaks, waveforms): wf = waveforms[i, :][:, chan_inds] if self.feature == "ptp": - wf_data = wf.ptp(axis=0) + wf_data = np.ptp(wf, axis=0) elif self.feature == "energy": wf_data = np.linalg.norm(wf, axis=0) elif self.feature == "peak_voltage": diff --git a/src/spikeinterface/sortingcomponents/tests/test_waveforms/test_waveform_thresholder.py b/src/spikeinterface/sortingcomponents/tests/test_waveforms/test_waveform_thresholder.py index 4f55030283..79a9603b8d 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_waveforms/test_waveform_thresholder.py +++ b/src/spikeinterface/sortingcomponents/tests/test_waveforms/test_waveform_thresholder.py @@ -37,7 +37,7 @@ def test_waveform_thresholder_ptp( recording, peaks, nodes=pipeline_nodes, job_kwargs=chunk_executor_kwargs ) - data = tresholded_waveforms.ptp(axis=1) / noise_levels + data = np.ptp(tresholded_waveforms, axis=1) / noise_levels assert np.all(data[data != 0] > 3) diff --git a/src/spikeinterface/sortingcomponents/waveforms/waveform_thresholder.py b/src/spikeinterface/sortingcomponents/waveforms/waveform_thresholder.py index 76d72f3b08..b4c54be6ad 100644 --- a/src/spikeinterface/sortingcomponents/waveforms/waveform_thresholder.py +++ b/src/spikeinterface/sortingcomponents/waveforms/waveform_thresholder.py @@ -78,7 +78,7 @@ def __init__( def compute(self, traces, peaks, waveforms): if self.feature == "ptp": - wf_data = waveforms.ptp(axis=1) / self.noise_levels + wf_data = np.ptp(waveforms, axis=1) / self.noise_levels elif self.feature == "mean": wf_data = waveforms.mean(axis=1) / self.noise_levels elif self.feature == "energy": From 61a7c2cae0743d4030ed8c0e4d6089cfd1768e81 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 12:55:15 -0600 Subject: [PATCH 127/248] fix for python api class --- src/spikeinterface/core/core_tools.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 681392d3f4..a1a23aaade 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -83,9 +83,15 @@ def default(self, obj): if isinstance(obj, np.generic): return obj.item() + # # Standard numpy dtypes like np.dtype('int32") are transformed this way if isinstance(obj, np.dtype): return np.dtype(obj).name + # This will transform to a sring canonical representation of the dtype (e.g. np.int32 -> 'int32') + + if isinstance(obj, type) and issubclass(obj, np.generic): + return np.dtype(obj).name + if isinstance(obj, np.ndarray): return obj.tolist() From c82d085d5d116c61ef67ff6148588d8ab7e6eb04 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 14:53:47 -0600 Subject: [PATCH 128/248] bump pickle --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9dbf0c0229..d6a627cb97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ - "numpy>=1.20, <2.0", # Minimal needed for np.ptp + "numpy>=1.26, <2.0", # 1.20 np.ptp, 1.26 for avoiding pikcling errors when numpy >2.0 "threadpoolctl>=3.0.0", "tqdm", "zarr>=2.16,<2.18", From a014e5e714f33f10216206943de32830d3c20c52 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 16:32:34 -0600 Subject: [PATCH 129/248] Apply suggestions from code review Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d6a627cb97..734fe04962 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ - "numpy>=1.26, <2.0", # 1.20 np.ptp, 1.26 for avoiding pikcling errors when numpy >2.0 + "numpy>=1.26, <2.0", # 1.20 np.ptp, 1.26 for avoiding pickling errors when numpy >2.0 "threadpoolctl>=3.0.0", "tqdm", "zarr>=2.16,<2.18", From da99a89c873d216cfb15c760d07ca0b19d4f79dc Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 16:33:05 -0600 Subject: [PATCH 130/248] Update src/spikeinterface/core/core_tools.py Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- src/spikeinterface/core/core_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index a1a23aaade..0c6a6cbcd5 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -87,7 +87,7 @@ def default(self, obj): if isinstance(obj, np.dtype): return np.dtype(obj).name - # This will transform to a sring canonical representation of the dtype (e.g. np.int32 -> 'int32') + # This will transform to a string canonical representation of the dtype (e.g. np.int32 -> 'int32') if isinstance(obj, type) and issubclass(obj, np.generic): return np.dtype(obj).name From fb179016d87e4f29529b93f2e45eb080681bb9bb Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 17 Jun 2024 17:27:09 -0600 Subject: [PATCH 131/248] add time slice --- src/spikeinterface/core/baserecording.py | 24 ++++++++++++++++++ .../core/tests/test_baserecording.py | 25 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 68a7dd744b..184959512b 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -679,6 +679,30 @@ def frame_slice(self, start_frame: int, end_frame: int) -> BaseRecording: sub_recording = FrameSliceRecording(self, start_frame=start_frame, end_frame=end_frame) return sub_recording + def time_slice(self, start_time: float, end_time: float) -> BaseRecording: + """ + Returns a new recording with sliced time. Note that this operation is not in place. + + Parameters + ---------- + start_time : float + The start time in seconds. + end_time : float + The end time in seconds. + + Returns + ------- + BaseRecording + The object with sliced time. + """ + + assert self.get_num_segments() == 1, "Time slicing is only supported for single segment recordings." + + start_frame = self.time_to_sample_index(start_time) + end_frame = self.time_to_sample_index(end_time) + + return self.frame_slice(start_frame=start_frame, end_frame=end_frame) + def _select_segments(self, segment_indices): from .segmentutils import SelectSegmentRecording diff --git a/src/spikeinterface/core/tests/test_baserecording.py b/src/spikeinterface/core/tests/test_baserecording.py index eb6cf7ac12..682881af8a 100644 --- a/src/spikeinterface/core/tests/test_baserecording.py +++ b/src/spikeinterface/core/tests/test_baserecording.py @@ -361,5 +361,30 @@ def test_select_channels(): assert np.array_equal(selected_channel_ids, ["a", "c"]) +def test_time_slice(): + # Case with sampling frequency + sampling_frequency = 10_000.0 + recording = generate_recording(durations=[1.0], num_channels=3, sampling_frequency=sampling_frequency) + + sliced_recording_times = recording.time_slice(start_time=0.1, end_time=0.8) + sliced_recording_frames = recording.frame_slice(start_frame=1000, end_frame=8000) + + assert np.allclose(sliced_recording_times.get_traces(), sliced_recording_frames.get_traces()) + + +def test_time_slice_with_time_vector(): + + # Case with time vector + sampling_frequency = 10_000.0 + recording = generate_recording(durations=[1.0], num_channels=3, sampling_frequency=sampling_frequency) + times = 1 + np.arange(0, 10_000) / sampling_frequency + recording.set_times(times=times, segment_index=0, with_warning=False) + + sliced_recording_times = recording.time_slice(start_time=1.1, end_time=1.8) + sliced_recording_frames = recording.frame_slice(start_frame=1000, end_frame=8000) + + assert np.allclose(sliced_recording_times.get_traces(), sliced_recording_frames.get_traces()) + + if __name__ == "__main__": test_BaseRecording() From da83ec9e3b545e5f4d5db26b3eab2a7c3037426d Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 11:30:37 +0200 Subject: [PATCH 132/248] Set DEV=True --- src/spikeinterface/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/__init__.py b/src/spikeinterface/__init__.py index 97fb95b623..306c12d516 100644 --- a/src/spikeinterface/__init__.py +++ b/src/spikeinterface/__init__.py @@ -30,5 +30,5 @@ # This flag must be set to False for release # This avoids using versioning that contains ".dev0" (and this is a better choice) # This is mainly useful when using run_sorter in a container and spikeinterface install -# DEV_MODE = True -DEV_MODE = False +DEV_MODE = True +# DEV_MODE = False From 14970e1b33a46e9308509eab4b3d6b21b18d957e Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 11:37:29 +0200 Subject: [PATCH 133/248] Comment linting --- src/spikeinterface/core/core_tools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 0c6a6cbcd5..664eac169f 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -83,12 +83,11 @@ def default(self, obj): if isinstance(obj, np.generic): return obj.item() - # # Standard numpy dtypes like np.dtype('int32") are transformed this way + # Standard numpy dtypes like np.dtype('int32") are transformed this way if isinstance(obj, np.dtype): return np.dtype(obj).name # This will transform to a string canonical representation of the dtype (e.g. np.int32 -> 'int32') - if isinstance(obj, type) and issubclass(obj, np.generic): return np.dtype(obj).name From 2b35a0880326551ce1d4e179b0af4c8953d873ed Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 13:15:14 +0200 Subject: [PATCH 134/248] Update plot_motion and correct_motion_on_peaks --- .../benchmark/benchmark_motion_estimation.py | 5 +- .../sortingcomponents/motion_interpolation.py | 26 ++++---- .../sortingcomponents/motion_utils.py | 26 ++------ .../tests/test_motion_interpolation.py | 2 +- src/spikeinterface/widgets/motion.py | 59 +++++++++++-------- 5 files changed, 55 insertions(+), 63 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 7428629c4a..b353b75817 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -670,11 +670,8 @@ def plot_summary_errors(self, case_keys=None, show_legend=True, figsize=(15, 5)) # peak_locations_corrected = correct_motion_on_peaks( # self.selected_peaks, # self.peak_locations, -# self.recording, # self.motion, -# self.temporal_bins, -# self.spatial_bins, -# direction="y", +# self.recording, # ) # if axes is None: # if show_probe: diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 32c3e706cf..4b3c081f3f 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -7,25 +7,23 @@ from spikeinterface.preprocessing.filter import fix_dtype -def correct_motion_on_peaks( - peaks, - peak_locations, - rec, - motion, -): +def correct_motion_on_peaks(peaks, peak_locations, motion, recording=None, sampling_frequency=None): """ Given the output of estimate_motion(), apply inverse motion on peak locations. Parameters ---------- - peaks: np.array + peaks : np.array peaks vector - peak_locations: np.array + peak_locations : np.array peaks location vector - sampling_frequency: np.array - sampling_frequency of the recording - motion: Motion + motion : Motion The motion object. + recording : Recording | None, default: None + The recording object. If given, this is used to convert sample indices to times. + sampling_frequency : float | None + Sampling_frequency of the recording, required if recording is None. + Returns ------- @@ -33,7 +31,11 @@ def correct_motion_on_peaks( Motion-corrected peak locations """ corrected_peak_locations = peak_locations.copy() - times_s = rec.sample_index_to_time(peaks["sample_index"]) + assert recording is not None or sampling_frequency is not None, "recording or sampling_frequency must be provided" + if recording is not None: + times_s = recording.sample_index_to_time(peaks["sample_index"]) + else: + times_s = peaks["sample_index"] / sampling_frequency for segment_index in range(motion.num_segments): i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 1edf484aa4..9bccfae1e2 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -5,24 +5,6 @@ import spikeinterface from spikeinterface.core.core_tools import check_json -# @charlie @sam -# here TODO list for motion object -# * simple test for Motion: DONE -# * save/load Motion DONE -# * make simple test for Motion object with save/load DONE -# * propagate to estimate_motion : DONE -# * handle multi segment in estimate_motion(): maybe in another PR -# * propagate to motion_interpolation.py: DONE -# * propagate to preprocessing/correct_motion(): DONE -# * generate drifting signals for test estimate_motion and interpolate_motion: SIMPLE ONE DONE? -# * uncomment assert in test_estimate_motion (aka debug torch vs numpy diff): DONE -# * delegate times to recording object in -# * estimate motion: DONE -# * correct_motion_on_peaks(): DONE -# * interpolate_motion_on_traces(): DONE -# propagate to benchmark estimate motion -# update plot_motion() dans widget - class Motion: """ @@ -30,19 +12,21 @@ class Motion: Parameters ---------- - displacement: numpy array 2d or list of + displacement : numpy array 2d or list of Motion estimate in um. List is the number of segment. For each semgent : * shape (temporal bins, spatial bins) * motion.shape[0] = temporal_bins.shape[0] * motion.shape[1] = 1 (rigid) or spatial_bins.shape[1] (non rigid) - temporal_bins_s: numpy.array 1d or list of + temporal_bins_s : numpy.array 1d or list of temporal bins (bin center) - spatial_bins_um: numpy.array 1d + spatial_bins_um : numpy.array 1d Windows center. spatial_bins_um.shape[0] == displacement.shape[1] If rigid then spatial_bins_um.shape[0] == 1 + direction : str, default: 'y' + Direction of the motion. interpolation_method : str How to determine the displacement between bin centers? See the docs for scipy.interpolate.RegularGridInterpolator for options. diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index c2be600586..3628d534a4 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -46,8 +46,8 @@ def test_correct_motion_on_peaks(): corrected_peak_locations = correct_motion_on_peaks( peaks, peak_locations, - rec, motion, + recording=rec, ) # print(corrected_peak_locations) assert np.any(corrected_peak_locations["y"] != 0) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 9d64c89e46..f4ed7fecf5 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -11,10 +11,12 @@ class MotionWidget(BaseWidget): Parameters ---------- - motion_info: dict + motion: dict The motion info return by correct_motion() or load back with load_motion_info() recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) + segment_index : int, default: 0 + The segment index to display. sampling_frequency : float, default: None The sampling frequency (needed if recording is None) depth_lim : tuple or None, default: None @@ -36,6 +38,7 @@ class MotionWidget(BaseWidget): def __init__( self, motion_info, + segment_index=0, recording=None, depth_lim=None, motion_lim=None, @@ -47,11 +50,12 @@ def __init__( backend=None, **backend_kwargs, ): - times = recording.get_times() if recording is not None else None + times = recording.get_times(segment_index=segment_index) if recording is not None else None plot_data = dict( sampling_frequency=motion_info["parameters"]["sampling_frequency"], times=times, + segment_index=segment_index, depth_lim=depth_lim, motion_lim=motion_lim, color_amplitude=color_amplitude, @@ -59,6 +63,7 @@ def __init__( amplitude_cmap=amplitude_cmap, amplitude_clim=amplitude_clim, amplitude_alpha=amplitude_alpha, + recording=recording, **motion_info, ) @@ -73,16 +78,20 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): dp = to_attr(data_plot) - assert backend_kwargs["axes"] is None - assert backend_kwargs["ax"] is None + assert backend_kwargs["axes"] is None, "axes argument is not allowed in MotionWidget" + assert backend_kwargs["ax"] is None, "ax argument is not allowed in MotionWidget" self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) fig = self.figure fig.clear() - is_rigid = dp.motion.shape[1] == 1 + motion_array = dp.motion.displacement[dp.segment_index] + temporal_bins_s = dp.motion.temporal_bins_s[dp.segment_index] + spatial_bins_um = dp.motion.spatial_bins_um - gs = fig.add_gridspec(2, 2, wspace=0.3, hspace=0.3) + is_rigid = motion_array.shape[1] == 1 + + gs = fig.add_gridspec(2, 2, wspace=0.3, hspace=0.5) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[0, 1]) ax2 = fig.add_subplot(gs[1, 0]) @@ -92,30 +101,29 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax1.sharey(ax0) if dp.motion_lim is None: - motion_lim = np.max(np.abs(dp.motion)) * 1.05 + motion_lim = np.max(np.abs(motion_array)) * 1.05 else: motion_lim = dp.motion_lim if dp.times is None: - temporal_bins_plot = dp.temporal_bins + temporal_bins_plot = temporal_bins_s x = dp.peaks["sample_index"] / dp.sampling_frequency else: # use real times and adjust temporal bins with t_start - temporal_bins_plot = dp.temporal_bins + dp.times[0] + temporal_bins_plot = temporal_bins_s + dp.times[0] x = dp.times[dp.peaks["sample_index"]] corrected_location = correct_motion_on_peaks( dp.peaks, dp.peak_locations, - dp.sampling_frequency, dp.motion, - dp.temporal_bins, - dp.spatial_bins, - direction="y", + dp.recording, + dp.sampling_frequency, ) + dim = ["x", "y", "z"][dp.motion.dim] - y = dp.peak_locations["y"] - y2 = corrected_location["y"] + y = dp.peak_locations[dim] + y2 = corrected_location[dim] if dp.scatter_decimate is not None: x = x[:: dp.scatter_decimate] y = y[:: dp.scatter_decimate] @@ -149,37 +157,38 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax0.set_ylim(*dp.depth_lim) ax0.set_title("Peak depth") ax0.set_xlabel("Times [s]") - ax0.set_ylabel("Depth [um]") + ax0.set_ylabel("Depth [$\\mu$m]") ax1.scatter(x, y2, s=1, **color_kwargs) ax1.set_xlabel("Times [s]") - ax1.set_ylabel("Depth [um]") + ax1.set_ylabel("Depth [$\\mu$m]") ax1.set_title("Corrected peak depth") - ax2.plot(temporal_bins_plot, dp.motion, alpha=0.2, color="black") - ax2.plot(temporal_bins_plot, np.mean(dp.motion, axis=1), color="C0") + ax2.plot(temporal_bins_plot, motion_array, alpha=0.2, color="black") + ax2.plot(temporal_bins_plot, np.mean(motion_array, axis=1), color="C0") ax2.set_ylim(-motion_lim, motion_lim) - ax2.set_ylabel("Motion [um]") + ax2.set_ylabel("Motion [$\\mu$m]") + ax2.set_xlabel("Times [s]") ax2.set_title("Motion vectors") axes = [ax0, ax1, ax2] if not is_rigid: im = ax3.imshow( - dp.motion.T, + motion_array.T, aspect="auto", origin="lower", extent=( temporal_bins_plot[0], temporal_bins_plot[-1], - dp.spatial_bins[0], - dp.spatial_bins[-1], + spatial_bins_um[0], + spatial_bins_um[-1], ), ) im.set_clim(-motion_lim, motion_lim) cbar = fig.colorbar(im) - cbar.ax.set_xlabel("motion [um]") + cbar.ax.set_ylabel("Motion [$\\mu$m]") ax3.set_xlabel("Times [s]") - ax3.set_ylabel("Depth [um]") + ax3.set_ylabel("Depth [$\\mu$m]") ax3.set_title("Motion vectors") axes.append(ax3) self.axes = np.array(axes) From ecbe9a399d2b6432bef4d265620cc34861d88a2c Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Tue, 18 Jun 2024 08:43:57 -0400 Subject: [PATCH 135/248] fix is filtered check --- src/spikeinterface/sorters/basesorter.py | 2 +- src/spikeinterface/sorters/external/mountainsort5.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/spikeinterface/sorters/basesorter.py b/src/spikeinterface/sorters/basesorter.py index 2f87065d9f..8c52626703 100644 --- a/src/spikeinterface/sorters/basesorter.py +++ b/src/spikeinterface/sorters/basesorter.py @@ -183,7 +183,7 @@ def set_params_to_folder(cls, recording, output_folder, new_params, verbose): # custom check params params = cls._check_params(recording, output_folder, params) # common check : filter warning - if recording.is_filtered and cls._check_apply_filter_in_params(params) and verbose: + if recording.is_filtered() and cls._check_apply_filter_in_params(params) and verbose: print(f"Warning! The recording is already filtered, but {cls.sorter_name} filter is enabled") # dump parameters inside the folder with json diff --git a/src/spikeinterface/sorters/external/mountainsort5.py b/src/spikeinterface/sorters/external/mountainsort5.py index 6fa68de190..cf6933c9e6 100644 --- a/src/spikeinterface/sorters/external/mountainsort5.py +++ b/src/spikeinterface/sorters/external/mountainsort5.py @@ -120,7 +120,6 @@ def _setup_recording(cls, recording, sorter_output_folder, params, verbose): @classmethod def _run_from_folder(cls, sorter_output_folder, params, verbose): import mountainsort5 as ms5 - from mountainsort5.util import create_cached_recording recording = cls.load_recording_from_folder(sorter_output_folder.parent, with_warnings=False) if recording is None: From eb31e08f3c03e082960f3d95d1bb3fcdc68c96bd Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 15:38:04 +0200 Subject: [PATCH 136/248] Add self._tmp_recording to store temp recording in analyzer --- src/spikeinterface/core/recording_tools.py | 8 +++--- src/spikeinterface/core/sortinganalyzer.py | 19 ++++++++----- .../core/tests/test_sortinganalyzer.py | 28 +++++++++++-------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 81fb0b3eb8..2b193f4164 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -919,7 +919,7 @@ def get_rec_attributes(recording): return rec_attributes -def check_recording_attributes_match(recording1, recording2_attributes, skip_properties=True) -> bool: +def check_recording_attributes_match(recording1, recording2_attributes) -> bool: """ Check if two recordings have the same attributes @@ -937,9 +937,9 @@ def check_recording_attributes_match(recording1, recording2_attributes, skip_pro """ recording1_attributes = get_rec_attributes(recording1) recording2_attributes = deepcopy(recording2_attributes) - if skip_properties: - recording1_attributes.pop("properties") - recording2_attributes.pop("properties") + recording1_attributes.pop("properties") + recording2_attributes.pop("properties") + return ( np.array_equal(recording1_attributes["channel_ids"], recording2_attributes["channel_ids"]) and recording1_attributes["sampling_frequency"] == recording2_attributes["sampling_frequency"] diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 5862c247a1..706c1d5a73 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -203,6 +203,8 @@ def __init__( self.format = format self.sparsity = sparsity self.return_scaled = return_scaled + # this is used to store temporary recording + self._tmp_recording = None # extensions are not loaded at init self.extensions = dict() @@ -619,15 +621,13 @@ def set_temporary_recording(self, recording: BaseRecording): The recording object to set as temporary recording. """ # check that recording is compatible - assert check_recording_attributes_match( - recording, self.rec_attributes, skip_properties=True - ), "Recording attributes do not match." + assert check_recording_attributes_match(recording, self.rec_attributes), "Recording attributes do not match." assert np.array_equal( recording.get_channel_locations(), self.get_channel_locations() ), "Recording channel locations do not match." if self._recording is not None: - warnings.warn("SortingAnalyzer recording is already set. " "The current recording is temporarily replaced.") - self._recording = recording + warnings.warn("SortingAnalyzer recording is already set. The current recording is temporarily replaced.") + self._tmp_recording = recording def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingAnalyzer": """ @@ -635,7 +635,9 @@ def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> """ if self.has_recording(): - recording = self.recording + recording = self._recording + elif self.has_temporary_recording(): + recording = self._tmp_recording else: recording = None @@ -754,7 +756,7 @@ def is_read_only(self) -> bool: def recording(self) -> BaseRecording: if not self.has_recording(): raise ValueError("SortingAnalyzer could not load the recording") - return self._recording + return self._tmp_recording or self._recording @property def channel_ids(self) -> np.ndarray: @@ -771,6 +773,9 @@ def unit_ids(self) -> np.ndarray: def has_recording(self) -> bool: return self._recording is not None + def has_temporary_recording(self) -> bool: + return self._tmp_recording is not None + def is_sparse(self) -> bool: return self.sparsity is not None diff --git a/src/spikeinterface/core/tests/test_sortinganalyzer.py b/src/spikeinterface/core/tests/test_sortinganalyzer.py index e3003e693f..d780932146 100644 --- a/src/spikeinterface/core/tests/test_sortinganalyzer.py +++ b/src/spikeinterface/core/tests/test_sortinganalyzer.py @@ -19,7 +19,7 @@ import numpy as np -def _get_dataset(): +def get_dataset(): recording, sorting = generate_ground_truth_recording( durations=[30.0], sampling_frequency=16000.0, @@ -33,12 +33,12 @@ def _get_dataset(): @pytest.fixture(scope="module") -def get_dataset(): - return _get_dataset() +def dataset(): + return get_dataset() -def test_SortingAnalyzer_memory(tmp_path, get_dataset): - recording, sorting = get_dataset +def test_SortingAnalyzer_memory(tmp_path, dataset): + recording, sorting = dataset sorting_analyzer = create_sorting_analyzer(sorting, recording, format="memory", sparse=False, sparsity=None) _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) @@ -57,8 +57,8 @@ def test_SortingAnalyzer_memory(tmp_path, get_dataset): assert not sorting_analyzer.return_scaled -def test_SortingAnalyzer_binary_folder(tmp_path, get_dataset): - recording, sorting = get_dataset +def test_SortingAnalyzer_binary_folder(tmp_path, dataset): + recording, sorting = dataset folder = tmp_path / "test_SortingAnalyzer_binary_folder" if folder.exists(): @@ -87,8 +87,8 @@ def test_SortingAnalyzer_binary_folder(tmp_path, get_dataset): _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) -def test_SortingAnalyzer_zarr(tmp_path, get_dataset): - recording, sorting = get_dataset +def test_SortingAnalyzer_zarr(tmp_path, dataset): + recording, sorting = dataset folder = tmp_path / "test_SortingAnalyzer_zarr.zarr" if folder.exists(): @@ -108,12 +108,18 @@ def test_SortingAnalyzer_zarr(tmp_path, get_dataset): ) -def test_SortingAnalyzer_tmp_recording(get_dataset): - recording, sorting = get_dataset +def test_SortingAnalyzer_tmp_recording(dataset): + recording, sorting = dataset recording_cached = recording.save(mode="memory") sorting_analyzer = create_sorting_analyzer(sorting, recording, format="memory", sparse=False, sparsity=None) sorting_analyzer.set_temporary_recording(recording_cached) + assert sorting_analyzer.has_temporary_recording() + # check that saving as uses the original recording + sorting_analyzer_saved = sorting_analyzer.save_as(format="memory") + assert sorting_analyzer_saved.has_recording() + assert not sorting_analyzer_saved.has_temporary_recording() + assert isinstance(sorting_analyzer_saved.recording, type(recording)) recording_sliced = recording.channel_slice(recording.channel_ids[:-1]) From 68e0a339c074769a5d36946b098b70b93d0cbbd8 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 15:48:15 +0200 Subject: [PATCH 137/248] _tmp_recording -> temporary_recording --- src/spikeinterface/core/sortinganalyzer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 706c1d5a73..c1080b3d4b 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -204,7 +204,7 @@ def __init__( self.sparsity = sparsity self.return_scaled = return_scaled # this is used to store temporary recording - self._tmp_recording = None + self._temporary_recording = None # extensions are not loaded at init self.extensions = dict() @@ -627,7 +627,7 @@ def set_temporary_recording(self, recording: BaseRecording): ), "Recording channel locations do not match." if self._recording is not None: warnings.warn("SortingAnalyzer recording is already set. The current recording is temporarily replaced.") - self._tmp_recording = recording + self._temporary_recording = recording def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingAnalyzer": """ @@ -637,7 +637,7 @@ def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> if self.has_recording(): recording = self._recording elif self.has_temporary_recording(): - recording = self._tmp_recording + recording = self._temporary_recording else: recording = None @@ -756,7 +756,7 @@ def is_read_only(self) -> bool: def recording(self) -> BaseRecording: if not self.has_recording(): raise ValueError("SortingAnalyzer could not load the recording") - return self._tmp_recording or self._recording + return self._temporary_recording or self._recording @property def channel_ids(self) -> np.ndarray: @@ -774,7 +774,7 @@ def has_recording(self) -> bool: return self._recording is not None def has_temporary_recording(self) -> bool: - return self._tmp_recording is not None + return self._temporary_recording is not None def is_sparse(self) -> bool: return self.sparsity is not None From 025b86f47d8ba2e277eb86b7421b3c16b1de1e3c Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 15:51:24 +0200 Subject: [PATCH 138/248] check_recording_attributes_match -> do_recording_attributes_match --- src/spikeinterface/core/recording_tools.py | 2 +- src/spikeinterface/core/sortinganalyzer.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 2b193f4164..024382dea2 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -919,7 +919,7 @@ def get_rec_attributes(recording): return rec_attributes -def check_recording_attributes_match(recording1, recording2_attributes) -> bool: +def do_recording_attributes_match(recording1, recording2_attributes) -> bool: """ Check if two recordings have the same attributes diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index c1080b3d4b..bdb1a6c248 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -22,7 +22,7 @@ from .basesorting import BaseSorting from .base import load_extractor -from .recording_tools import check_probe_do_not_overlap, get_rec_attributes, check_recording_attributes_match +from .recording_tools import check_probe_do_not_overlap, get_rec_attributes, do_recording_attributes_match from .core_tools import check_json, retrieve_importing_provenance from .job_tools import split_job_kwargs from .numpyextractors import NumpySorting @@ -621,7 +621,7 @@ def set_temporary_recording(self, recording: BaseRecording): The recording object to set as temporary recording. """ # check that recording is compatible - assert check_recording_attributes_match(recording, self.rec_attributes), "Recording attributes do not match." + assert do_recording_attributes_match(recording, self.rec_attributes), "Recording attributes do not match." assert np.array_equal( recording.get_channel_locations(), self.get_channel_locations() ), "Recording channel locations do not match." From 88ec0717e3694314e4e50d895da8257a5db26a2e Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 18 Jun 2024 16:25:22 +0200 Subject: [PATCH 139/248] Improve check in recording property --- src/spikeinterface/core/sortinganalyzer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index bdb1a6c248..46d02099d5 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -754,7 +754,7 @@ def is_read_only(self) -> bool: @property def recording(self) -> BaseRecording: - if not self.has_recording(): + if not self.has_recording() and not self.has_temporary_recording(): raise ValueError("SortingAnalyzer could not load the recording") return self._temporary_recording or self._recording From 6ed34caac56ad20b9a311b26a4b5f2537a269caa Mon Sep 17 00:00:00 2001 From: Zach McKenzie <92116279+zm711@users.noreply.github.com> Date: Tue, 18 Jun 2024 12:27:22 -0400 Subject: [PATCH 140/248] add miniconda latest. --- .github/workflows/installation-tips-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/installation-tips-test.yml b/.github/workflows/installation-tips-test.yml index cbe313b12e..e83399cf7c 100644 --- a/.github/workflows/installation-tips-test.yml +++ b/.github/workflows/installation-tips-test.yml @@ -28,8 +28,9 @@ jobs: with: python-version: '3.10' - name: Test Conda Environment Creation - uses: conda-incubator/setup-miniconda@v2.2.0 + uses: conda-incubator/setup-miniconda@v3 with: + miniconda-version: "latest" environment-file: ./installation_tips/full_spikeinterface_environment_${{ matrix.label }}.yml activate-environment: si_env - name: Check Installation Tips From 0b3065b58653392b33853f044a23c7936a66d3c5 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Tue, 18 Jun 2024 18:51:35 +0100 Subject: [PATCH 141/248] Add _common_filter_docs --- src/spikeinterface/preprocessing/filter.py | 37 ++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index ffad9a2029..8236acf848 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -8,14 +8,16 @@ from ..core import get_chunk_with_margin -_common_filter_docs = """**filter_kwargs : keyword arguments for parallel processing: - - * filter_order : order - The order of the filter - * filter_mode : "sos or "ba" - "sos" is bi quadratic and more stable than ab so thery are prefered. - * ftype : str - Filter type for iirdesign ("butter" / "cheby1" / ... all possible of scipy.signal.iirdesign) +_common_filter_docs = """**filter_kwargs : dict + Certain keyword arguments for `scipy.signal` filters: + filter_order : order + The order of the filter + filter_mode : "sos" | "ba", default: "sos" + Filter form of the filter coefficients: + - second-order sections ("sos") + - numerator/denominator : ("ba") + ftype : str, default: "butter" + Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1". """ @@ -39,20 +41,13 @@ class FilterRecording(BasePreprocessor): Type of the filter margin_ms : float, default: 5.0 Margin in ms on border to avoid border effect - filter_mode : "sos" | "ba", default: "sos" - Filter form of the filter coefficients: - - second-order sections ("sos") - - numerator/denominator : ("ba") coeff : array | None, default: None Filter coefficients in the filter_mode form. dtype : dtype or None, default: None The dtype of the returned traces. If None, the dtype of the parent recording is used add_reflect_padding : Bool, default False If True, uses a left and right margin during calculation. - ftype : str | None, default: "butter" - The type of IIR filter to design, used in `scipy.signal.iirfilter`. - filter_order : int, default: 5 - The order of the filter, used in `scipy.signal.iirfilter`. + {} Returns ------- @@ -183,8 +178,7 @@ class BandpassFilterRecording(FilterRecording): Margin in ms on border to avoid border effect dtype : dtype or None The dtype of the returned traces. If None, the dtype of the parent recording is used - **filter_kwargs : dict - Keyword arguments for `spikeinterface.preprocessing.FilterRecording` class. + {} Returns ------- @@ -219,8 +213,7 @@ class HighpassFilterRecording(FilterRecording): Margin in ms on border to avoid border effect dtype : dtype or None The dtype of the returned traces. If None, the dtype of the parent recording is used - **filter_kwargs : dict - Keyword arguments for `spikeinterface.preprocessing.FilterRecording` class. + {} Returns ------- @@ -297,6 +290,10 @@ def __init__(self, recording, freq=3000, q=30, margin_ms=5.0, dtype=None): notch_filter = define_function_from_class(source_class=NotchFilterRecording, name="notch_filter") highpass_filter = define_function_from_class(source_class=HighpassFilterRecording, name="highpass_filter") +filter.__doc__ = filter.__doc__.format(_common_filter_docs) +bandpass_filter.__doc__ = bandpass_filter.__doc__.format(_common_filter_docs) +highpass_filter.__doc__ = highpass_filter.__doc__.format(_common_filter_docs) + def fix_dtype(recording, dtype): if dtype is None: From 257950d5859521730ed1da746b6fd32b7b6335bb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 06:36:40 +0000 Subject: [PATCH 142/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sorters/internal/spyking_circus2.py | 4 ++- .../benchmark/benchmark_motion_estimation.py | 20 ++++++------- .../benchmark_motion_interpolation.py | 4 +-- .../tests/test_benchmark_motion_estimation.py | 1 + .../test_benchmark_motion_interpolation.py | 1 + .../sortingcomponents/motion_utils.py | 4 +-- .../tests/test_motion_utils.py | 11 ++++--- src/spikeinterface/widgets/motion.py | 30 ++++++++----------- .../widgets/tests/test_widgets.py | 21 +++++-------- 9 files changed, 41 insertions(+), 55 deletions(-) diff --git a/src/spikeinterface/sorters/internal/spyking_circus2.py b/src/spikeinterface/sorters/internal/spyking_circus2.py index 1a064dcb31..b5df0f1059 100644 --- a/src/spikeinterface/sorters/internal/spyking_circus2.py +++ b/src/spikeinterface/sorters/internal/spyking_circus2.py @@ -314,7 +314,9 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): motion_info = load_motion_info(motion_folder) motion = motion_info["motion"] - max_motion = max(np.max(np.abs(motion.displacement[seg_index])) for seg_index in range(len(motion.displacement))) + max_motion = max( + np.max(np.abs(motion.displacement[seg_index])) for seg_index in range(len(motion.displacement)) + ) merging_params["maximum_distance_um"] = max(50, 2 * max_motion) # peak_sign = params['detection'].get('peak_sign', 'neg') diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 1408a5cb32..55ef21de9d 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -55,13 +55,9 @@ def get_gt_motion_from_unit_displacement( gt_displacement[t, :] = f(spatial_bins_um) gt_motion = Motion( - gt_displacement, - temporal_bins_s, - spatial_bins_um, - direction="xyz"[direction_dim], - interpolation_method="linear" + gt_displacement, temporal_bins_s, spatial_bins_um, direction="xyz"[direction_dim], interpolation_method="linear" ) - + return gt_motion @@ -102,9 +98,7 @@ def run(self, **job_kwargs): t2 = time.perf_counter() peak_locations = localize_peaks(self.recording, selected_peaks, **p["localize_kwargs"], **job_kwargs) t3 = time.perf_counter() - motion = estimate_motion( - self.recording, selected_peaks, peak_locations, **p["estimate_motion_kwargs"] - ) + motion = estimate_motion(self.recording, selected_peaks, peak_locations, **p["estimate_motion_kwargs"]) t4 = time.perf_counter() step_run_times = dict( @@ -263,8 +257,12 @@ def plot_errors(self, case_keys=None, figsize=None, lim=None): aspect="auto", interpolation="nearest", origin="lower", - extent=(motion.temporal_bins_s[0][0], motion.temporal_bins_s[0][-1], - motion.spatial_bins_um[0], motion.spatial_bins_um[-1]), + extent=( + motion.temporal_bins_s[0][0], + motion.temporal_bins_s[0][-1], + motion.spatial_bins_um[0], + motion.spatial_bins_um[-1], + ), ) plt.colorbar(im, ax=ax, label="error") ax.set_ylabel("depth (um)") diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py index 5688d2eaf3..a6ff05fc55 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py @@ -44,9 +44,7 @@ def run(self, **job_kwargs): recording = self.drifting_recording elif self.params["recording_source"] == "corrected": correct_motion_kwargs = self.params["correct_motion_kwargs"] - recording = InterpolateMotionRecording( - self.drifting_recording, self.motion, **correct_motion_kwargs - ) + recording = InterpolateMotionRecording(self.drifting_recording, self.motion, **correct_motion_kwargs) else: raise ValueError("recording_source") diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py index 14a5fe9138..526cc2e92f 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py @@ -70,6 +70,7 @@ def test_benchmark_motion_estimaton(create_cache_folder): study.plot_summary_errors() import matplotlib.pyplot as plt + plt.show() diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py index 07eb35b693..6d80d027f2 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py @@ -134,6 +134,7 @@ def test_benchmark_motion_interpolation(create_cache_folder): study.plot_sorting_accuracy(mode="depth", mode_best_merge=True) import matplotlib.pyplot as plt + plt.show() diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index d4f0bb93b5..26d4b35b1a 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -220,11 +220,11 @@ def __eq__(self, other): return False return True - + def copy(self): return Motion( self.displacement.copy(), self.temporal_bins_s.copy(), self.spatial_bins_um.copy(), - interpolation_method=self.interpolation_method + interpolation_method=self.interpolation_method, ) diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py index 1542c8531a..0b67be39c0 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py @@ -14,29 +14,28 @@ def make_fake_motion(): - displacement_sampling_frequency = 5. - spatial_bins_um = np.array([100.0, 200.0, 300., 400.]) + displacement_sampling_frequency = 5.0 + spatial_bins_um = np.array([100.0, 200.0, 300.0, 400.0]) displacement_vector = make_one_displacement_vector( drift_mode="zigzag", duration=50.0, amplitude_factor=1.0, displacement_sampling_frequency=displacement_sampling_frequency, - period_s=25., + period_s=25.0, ) temporal_bins_s = np.arange(displacement_vector.size) / displacement_sampling_frequency displacement = np.zeros((temporal_bins_s.size, spatial_bins_um.size)) - + n = spatial_bins_um.size for i in range(n): - displacement[:, i] = displacement_vector * ((i +1 ) / n) + displacement[:, i] = displacement_vector * ((i + 1) / n) motion = Motion(displacement, temporal_bins_s, spatial_bins_um, direction="y") return motion - def test_Motion(): temporal_bins_s = np.arange(0.0, 10.0, 1.0) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index b98e619bf7..dcb7b26f7e 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -4,6 +4,7 @@ from .base import BaseWidget, to_attr + class MotionWidget(BaseWidget): """ Plot the Motion object @@ -18,6 +19,7 @@ class MotionWidget(BaseWidget): How to plot map or lines. "auto" make it automatic if the number of depth is too high. """ + def __init__( self, motion, @@ -26,10 +28,11 @@ def __init__( motion_lim=None, backend=None, **backend_kwargs, - - ): + ): if isinstance(motion, dict): - raise ValueError("The API has changed, plot_motion() used Motion object now, maybe you want plot_motion_info(motion_info)") + raise ValueError( + "The API has changed, plot_motion() used Motion object now, maybe you want plot_motion_info(motion_info)" + ) if segment_index is None: if len(motion.displacement) == 1: @@ -43,7 +46,7 @@ def __init__( mode=mode, ) - BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) + BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) def plot_matplotlib(self, data_plot, **backend_kwargs): import matplotlib.pyplot as plt @@ -59,7 +62,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) - displacement = motion.displacement[dp.segment_index] temporal_bins_s = motion.temporal_bins_s[dp.segment_index] depth = motion.spatial_bins_um @@ -69,7 +71,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): else: motion_lim = dp.motion_lim - ax = self.ax fig = self.figure if dp.mode == "line": @@ -84,10 +85,10 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): aspect="auto", origin="lower", extent=(temporal_bins_s[0], temporal_bins_s[-1], depth[0], depth[-1]), - cmap="PiYG" + cmap="PiYG", ) im.set_clim(-motion_lim, motion_lim) - + cbar = fig.colorbar(im) cbar.ax.set_ylabel("motion [um]") ax.set_xlabel("Times [s]") @@ -106,7 +107,7 @@ class MotionInfoWidget(BaseWidget): ---------- motion_info : dict The motion info return by correct_motion() or load back with load_motion_info() - segment_index: + segment_index: recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) @@ -145,7 +146,7 @@ def __init__( backend=None, **backend_kwargs, ): - + motion = motion_info["motion"] if segment_index is None: if len(motion.displacement) == 1: @@ -193,7 +194,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): motion = dp.motion - displacement = motion.displacement[dp.segment_index] temporal_bins_s = motion.temporal_bins_s[dp.segment_index] spatial_bins_um = motion.spatial_bins_um @@ -203,7 +203,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): else: motion_lim = dp.motion_lim - is_rigid = displacement.shape[1] == 1 gs = fig.add_gridspec(2, 2, wspace=0.3, hspace=0.5) @@ -223,12 +222,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): # temporal_bins_plot = dp.temporal_bins + dp.times[0] x = dp.times[dp.peaks["sample_index"]] - corrected_location = correct_motion_on_peaks( - dp.peaks, - dp.peak_locations, - dp.recording, - dp.motion - ) + corrected_location = correct_motion_on_peaks(dp.peaks, dp.peak_locations, dp.recording, dp.motion) dim = ["x", "y", "z"][dp.motion.dim] y = dp.peak_locations[motion.direction] diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index 3e3e432817..0198e24626 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -579,47 +579,40 @@ def test_plot_multicomparison(self): _, axes = plt.subplots(len(mcmp.object_list), 1) sw.plot_multicomparison_agreement_by_sorter(mcmp, axes=axes) - + def test_plot_motion(self): from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion + motion = make_fake_motion() possible_backends = list(sw.MotionWidget.get_possible_backends()) for backend in possible_backends: if backend not in self.skip_backends: - sw.plot_motion(motion, backend=backend, mode='line') - sw.plot_motion(motion, backend=backend, mode='map') + sw.plot_motion(motion, backend=backend, mode="line") + sw.plot_motion(motion, backend=backend, mode="map") def test_plot_motion_info(self): from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion - motion = make_fake_motion() rng = np.random.default_rng(seed=2205) peak_locations = np.zeros(self.peaks.size, dtype=[("x", "float64"), ("y", "float64")]) - peak_locations['y'] = rng.uniform(motion.spatial_bins_um[0], - motion.spatial_bins_um[-1], - size=self.peaks.size) - + peak_locations["y"] = rng.uniform(motion.spatial_bins_um[0], motion.spatial_bins_um[-1], size=self.peaks.size) + motion_info = dict( motion=motion, - parameters=dict(sampling_frequency=30000.), + parameters=dict(sampling_frequency=30000.0), run_times=dict(), peaks=self.peaks, peak_locations=peak_locations, ) - possible_backends = list(sw.MotionWidget.get_possible_backends()) for backend in possible_backends: if backend not in self.skip_backends: sw.plot_motion_info(motion_info, recording=self.recording, backend=backend) - - - - if __name__ == "__main__": # unittest.main() import matplotlib.pyplot as plt From 3eafcb35c876ccafd6cf9de62c87165105471c1c Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 09:29:57 +0200 Subject: [PATCH 143/248] fix --- .../sortingcomponents/motion_interpolation.py | 9 +++------ .../sortingcomponents/tests/test_motion_interpolation.py | 2 +- src/spikeinterface/widgets/motion.py | 7 +++---- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 4b3c081f3f..935b574565 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -7,7 +7,7 @@ from spikeinterface.preprocessing.filter import fix_dtype -def correct_motion_on_peaks(peaks, peak_locations, motion, recording=None, sampling_frequency=None): +def correct_motion_on_peaks(peaks, peak_locations, motion, recording): """ Given the output of estimate_motion(), apply inverse motion on peak locations. @@ -19,11 +19,8 @@ def correct_motion_on_peaks(peaks, peak_locations, motion, recording=None, sampl peaks location vector motion : Motion The motion object. - recording : Recording | None, default: None - The recording object. If given, this is used to convert sample indices to times. - sampling_frequency : float | None - Sampling_frequency of the recording, required if recording is None. - + recording : Recording + The recording object. This is used to convert sample indices to times. Returns ------- diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py index 2c6ff7ecdd..cb26560272 100644 --- a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py @@ -42,7 +42,7 @@ def test_correct_motion_on_peaks(): peaks, peak_locations, motion, - recording=rec, + rec, ) # print(corrected_peak_locations) assert np.any(corrected_peak_locations["y"] != 0) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index b98e619bf7..2f1f9d4adf 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -106,8 +106,8 @@ class MotionInfoWidget(BaseWidget): ---------- motion_info : dict The motion info return by correct_motion() or load back with load_motion_info() - segment_index: - + segment_index: int, default: None + The segment index to display. recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) segment_index : int, default: 0 @@ -226,10 +226,9 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): corrected_location = correct_motion_on_peaks( dp.peaks, dp.peak_locations, + dp.motion, dp.recording, - dp.motion ) - dim = ["x", "y", "z"][dp.motion.dim] y = dp.peak_locations[motion.direction] y2 = corrected_location[motion.direction] From 2d72c96411fd892d0eb3cb030ef0522712a23786 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:20:28 +0100 Subject: [PATCH 144/248] Change filter kwargs --- src/spikeinterface/preprocessing/filter.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index 8236acf848..6a1733c57c 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -47,13 +47,19 @@ class FilterRecording(BasePreprocessor): The dtype of the returned traces. If None, the dtype of the parent recording is used add_reflect_padding : Bool, default False If True, uses a left and right margin during calculation. - {} + filter_order : order + The order of the filter for `scipy.signal.iirfilter` + filter_mode : "sos" | "ba", default: "sos" + Filter form of the filter coefficients for `scipy.signal.iirfilter`: + - second-order sections ("sos") + - numerator/denominator : ("ba") + ftype : str, default: "butter" + Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1". Returns ------- filter_recording : FilterRecording The filtered recording extractor object - """ name = "filter" @@ -290,7 +296,6 @@ def __init__(self, recording, freq=3000, q=30, margin_ms=5.0, dtype=None): notch_filter = define_function_from_class(source_class=NotchFilterRecording, name="notch_filter") highpass_filter = define_function_from_class(source_class=HighpassFilterRecording, name="highpass_filter") -filter.__doc__ = filter.__doc__.format(_common_filter_docs) bandpass_filter.__doc__ = bandpass_filter.__doc__.format(_common_filter_docs) highpass_filter.__doc__ = highpass_filter.__doc__.format(_common_filter_docs) From 225269dc1f36ade930bedb4b331f92db75e48d23 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 10:28:05 +0200 Subject: [PATCH 145/248] more fix --- doc/modules/motion_correction.rst | 22 ++++++++----------- .../sortingcomponents/motion_interpolation.py | 12 +++++----- src/spikeinterface/widgets/motion.py | 11 +++++----- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/doc/modules/motion_correction.rst b/doc/modules/motion_correction.rst index 8be2456caa..af81cb42d1 100644 --- a/doc/modules/motion_correction.rst +++ b/doc/modules/motion_correction.rst @@ -163,21 +163,19 @@ The high-level :py:func:`~spikeinterface.preprocessing.correct_motion()` is inte max_distance_um=150.0, **job_kwargs) # Step 2: motion inference - motion, temporal_bins, spatial_bins = estimate_motion(recording=rec, - peaks=peaks, - peak_locations=peak_locations, - method="decentralized", - direction="y", - bin_duration_s=2.0, - bin_um=5.0, - win_step_um=50.0, - win_sigma_um=150.0) + motion = estimate_motion(recording=rec, + peaks=peaks, + peak_locations=peak_locations, + method="decentralized", + direction="y", + bin_duration_s=2.0, + bin_um=5.0, + win_step_um=50.0, + win_sigma_um=150.0) # Step 3: motion interpolation # this step is lazy rec_corrected = interpolate_motion(recording=rec, motion=motion, - temporal_bins=temporal_bins, - spatial_bins=spatial_bins, border_mode="remove_channels", spatial_interpolation_method="kriging", sigma_um=30.) @@ -220,8 +218,6 @@ different preprocessing chains: one for motion correction and one for spike sort rec_corrected2 = interpolate_motion( recording=rec2, motion=motion_info['motion'], - temporal_bins=motion_info['temporal_bins'], - spatial_bins=motion_info['spatial_bins'], **motion_info['parameters']['interpolate_motion_kwargs']) sorting = run_sorter(sorter_name="montainsort5", recording=rec_corrected2) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 935b574565..203eafbb6e 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -28,13 +28,10 @@ def correct_motion_on_peaks(peaks, peak_locations, motion, recording): Motion-corrected peak locations """ corrected_peak_locations = peak_locations.copy() - assert recording is not None or sampling_frequency is not None, "recording or sampling_frequency must be provided" - if recording is not None: - times_s = recording.sample_index_to_time(peaks["sample_index"]) - else: - times_s = peaks["sample_index"] / sampling_frequency + for segment_index in range(motion.num_segments): + times_s = recording.sample_index_to_time(peaks["sample_index"], segment_index=segment_index) i0, i1 = np.searchsorted(peaks["segment_index"], [segment_index, segment_index + 1]) spike_times = times_s[i0:i1] @@ -368,7 +365,7 @@ def __init__( if interpolation_time_bin_centers_s is None: # in this case, interpolation_time_bin_size_s is set. s_end = parent_segment.get_num_samples() - t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end])) + t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end]), segment_index=segment_index) halfbin = interpolation_time_bin_size_s / 2.0 segment_interpolation_time_bins_s = np.arange(t_start + halfbin, t_end, interpolation_time_bin_size_s) else: @@ -441,11 +438,12 @@ def get_traces(self, start_frame, end_frame, channel_indices): times, self.channel_locations, self.motion, + segment_index=self.segment_index, channel_inds=self.channel_inds, spatial_interpolation_method=self.spatial_interpolation_method, spatial_interpolation_kwargs=self.spatial_interpolation_kwargs, interpolation_time_bin_centers_s=self.interpolation_time_bin_centers_s, - segment_index=self.segment_index, + ) if channel_indices is not None: diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 2f1f9d4adf..7811415614 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -10,11 +10,11 @@ class MotionWidget(BaseWidget): Parameters ---------- - motion: Motion + motion : Motion The motion object - segment_index: None | int + segment_index : None | int If Motion is multi segment, the must be not None - mode: "auto" | "line" | "map" + mode : "auto" | "line" | "map" How to plot map or lines. "auto" make it automatic if the number of depth is too high. """ @@ -35,7 +35,7 @@ def __init__( if len(motion.displacement) == 1: segment_index = 0 else: - raise ValueError("plot motion : teh Motion object is multi segment you must provide segmentindex=XX") + raise ValueError("plot motion : the Motion object is multi segment you must provide segment_index=XX") plot_data = dict( motion=motion, @@ -106,7 +106,7 @@ class MotionInfoWidget(BaseWidget): ---------- motion_info : dict The motion info return by correct_motion() or load back with load_motion_info() - segment_index: int, default: None + segment_index : int, default: None The segment index to display. recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) @@ -166,7 +166,6 @@ def __init__( amplitude_cmap=amplitude_cmap, amplitude_clim=amplitude_clim, amplitude_alpha=amplitude_alpha, - segment_index=segment_index, recording=recording, **motion_info, ) From eb21af56ed496a9d183aaeb4bf6d5e7a4609a282 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 08:30:44 +0000 Subject: [PATCH 146/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../sortingcomponents/motion_interpolation.py | 2 - src/spikeinterface/widgets/motion.py | 74 +++++++++---------- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 203eafbb6e..9006ebdcf0 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -28,7 +28,6 @@ def correct_motion_on_peaks(peaks, peak_locations, motion, recording): Motion-corrected peak locations """ corrected_peak_locations = peak_locations.copy() - for segment_index in range(motion.num_segments): times_s = recording.sample_index_to_time(peaks["sample_index"], segment_index=segment_index) @@ -443,7 +442,6 @@ def get_traces(self, start_frame, end_frame, channel_indices): spatial_interpolation_method=self.spatial_interpolation_method, spatial_interpolation_kwargs=self.spatial_interpolation_kwargs, interpolation_time_bin_centers_s=self.interpolation_time_bin_centers_s, - ) if channel_indices is not None: diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 27831061ef..12b43ce7de 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -97,43 +97,43 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): class MotionInfoWidget(BaseWidget): """ - Plot motion information from the motion_info dict returned by correct_motion(). - This plot: - * the motion iself - * the peak depth vs time before correction - * the peak depth vs time after correction - - Parameters - ---------- - motion_info : dict - The motion info return by correct_motion() or load back with load_motion_info() -<<<<<<< HEAD - segment_index : int, default: None - The segment index to display. -======= - segment_index: - ->>>>>>> 257950d5859521730ed1da746b6fd32b7b6335bb - recording : RecordingExtractor, default: None - The recording extractor object (only used to get "real" times) - segment_index : int, default: 0 - The segment index to display. - sampling_frequency : float, default: None - The sampling frequency (needed if recording is None) - depth_lim : tuple or None, default: None - The min and max depth to display, if None (min and max of the recording) - motion_lim : tuple or None, default: None - The min and max motion to display, if None (min and max of the motion) - color_amplitude : bool, default: False - If True, the color of the scatter points is the amplitude of the peaks - scatter_decimate : int, default: None - If > 1, the scatter points are decimated - amplitude_cmap : str, default: "inferno" - The colormap to use for the amplitude - amplitude_clim : tuple or None, default: None - The min and max amplitude to display, if None (min and max of the amplitudes) - amplitude_alpha : float, default: 1 - The alpha of the scatter points + Plot motion information from the motion_info dict returned by correct_motion(). + This plot: + * the motion iself + * the peak depth vs time before correction + * the peak depth vs time after correction + + Parameters + ---------- + motion_info : dict + The motion info return by correct_motion() or load back with load_motion_info() + <<<<<<< HEAD + segment_index : int, default: None + The segment index to display. + ======= + segment_index: + + >>>>>>> 257950d5859521730ed1da746b6fd32b7b6335bb + recording : RecordingExtractor, default: None + The recording extractor object (only used to get "real" times) + segment_index : int, default: 0 + The segment index to display. + sampling_frequency : float, default: None + The sampling frequency (needed if recording is None) + depth_lim : tuple or None, default: None + The min and max depth to display, if None (min and max of the recording) + motion_lim : tuple or None, default: None + The min and max motion to display, if None (min and max of the motion) + color_amplitude : bool, default: False + If True, the color of the scatter points is the amplitude of the peaks + scatter_decimate : int, default: None + If > 1, the scatter points are decimated + amplitude_cmap : str, default: "inferno" + The colormap to use for the amplitude + amplitude_clim : tuple or None, default: None + The min and max amplitude to display, if None (min and max of the amplitudes) + amplitude_alpha : float, default: 1 + The alpha of the scatter points """ def __init__( From 8bd92e29e641afe5c2f8f6c122d15520c4d79519 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Wed, 19 Jun 2024 10:45:33 +0200 Subject: [PATCH 147/248] Ficx bug in plot_potential_merges --- src/spikeinterface/widgets/potential_merges.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/potential_merges.py b/src/spikeinterface/widgets/potential_merges.py index c1c7d86522..be882209b8 100644 --- a/src/spikeinterface/widgets/potential_merges.py +++ b/src/spikeinterface/widgets/potential_merges.py @@ -237,7 +237,7 @@ def _update_plot(self, change=None): self.w_templates._plot_probe(self.ax_probe, channel_locations, plot_unit_ids) crosscorrelograms_data_plot = self.w_crosscorrelograms.data_plot.copy() crosscorrelograms_data_plot["unit_ids"] = plot_unit_ids - merge_unit_indices = np.flatnonzero(np.isin(self.unique_merge_units, plot_unit_ids)) + merge_unit_indices = np.flatnonzero(np.isin(self.data_plot["unique_merge_units"], plot_unit_ids)) updated_correlograms = crosscorrelograms_data_plot["correlograms"] updated_correlograms = updated_correlograms[merge_unit_indices][:, merge_unit_indices] crosscorrelograms_data_plot["correlograms"] = updated_correlograms From aab214893cecd941b74af1af0df838217c047285 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 10:47:19 +0200 Subject: [PATCH 148/248] oups --- .../widgets/tests/test_widgets.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index 0198e24626..e841a1c93b 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -72,25 +72,25 @@ def setUpClass(cls): ) job_kwargs = dict(n_jobs=-1) - # # create dense - # cls.sorting_analyzer_dense = create_sorting_analyzer(cls.sorting, cls.recording, format="memory", sparse=False) - # cls.sorting_analyzer_dense.compute("random_spikes") - # cls.sorting_analyzer_dense.compute(extensions_to_compute, **job_kwargs) - - # sw.set_default_plotter_backend("matplotlib") - - # # make sparse waveforms - # cls.sparsity_radius = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=50) - # cls.sparsity_strict = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=20) - # cls.sparsity_large = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=80) - # cls.sparsity_best = compute_sparsity(cls.sorting_analyzer_dense, method="best_channels", num_channels=5) - - # # create sparse - # cls.sorting_analyzer_sparse = create_sorting_analyzer( - # cls.sorting, cls.recording, format="memory", sparsity=cls.sparsity_radius - # ) - # cls.sorting_analyzer_sparse.compute("random_spikes") - # cls.sorting_analyzer_sparse.compute(extensions_to_compute, **job_kwargs) + # create dense + cls.sorting_analyzer_dense = create_sorting_analyzer(cls.sorting, cls.recording, format="memory", sparse=False) + cls.sorting_analyzer_dense.compute("random_spikes") + cls.sorting_analyzer_dense.compute(extensions_to_compute, **job_kwargs) + + sw.set_default_plotter_backend("matplotlib") + + # make sparse waveforms + cls.sparsity_radius = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=50) + cls.sparsity_strict = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=20) + cls.sparsity_large = compute_sparsity(cls.sorting_analyzer_dense, method="radius", radius_um=80) + cls.sparsity_best = compute_sparsity(cls.sorting_analyzer_dense, method="best_channels", num_channels=5) + + # create sparse + cls.sorting_analyzer_sparse = create_sorting_analyzer( + cls.sorting, cls.recording, format="memory", sparsity=cls.sparsity_radius + ) + cls.sorting_analyzer_sparse.compute("random_spikes") + cls.sorting_analyzer_sparse.compute(extensions_to_compute, **job_kwargs) cls.skip_backends = ["ipywidgets", "ephyviewer", "spikeinterface_gui"] # cls.skip_backends = ["ipywidgets", "ephyviewer", "sortingview"] @@ -107,7 +107,7 @@ def setUpClass(cls): "spikeinterface_gui": {}, } - # cls.gt_comp = sc.compare_sorter_to_ground_truth(cls.sorting, cls.sorting) + cls.gt_comp = sc.compare_sorter_to_ground_truth(cls.sorting, cls.sorting) from spikeinterface.sortingcomponents.peak_detection import detect_peaks From 82c7ee51115086eff01cc2ed63abefa4243c30c6 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 11:13:43 +0200 Subject: [PATCH 149/248] oups --- src/spikeinterface/widgets/motion.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 27831061ef..5ef3c2d4af 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -44,6 +44,7 @@ def __init__( motion=motion, segment_index=segment_index, mode=mode, + motion_lim=motion_lim, ) BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) @@ -107,13 +108,8 @@ class MotionInfoWidget(BaseWidget): ---------- motion_info : dict The motion info return by correct_motion() or load back with load_motion_info() -<<<<<<< HEAD segment_index : int, default: None The segment index to display. -======= - segment_index: - ->>>>>>> 257950d5859521730ed1da746b6fd32b7b6335bb recording : RecordingExtractor, default: None The recording extractor object (only used to get "real" times) segment_index : int, default: 0 From 9664f69c4bcdd24e20584f601bcbd6a9ae79e174 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Wed, 19 Jun 2024 11:49:32 +0200 Subject: [PATCH 150/248] Apply suggestions from code review Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- .../sorters/tests/test_runsorter_dependency_checks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py index c4beaba072..83d6ec3161 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py +++ b/src/spikeinterface/sorters/tests/test_runsorter_dependency_checks.py @@ -13,7 +13,7 @@ def _monkeypatch_return_false(): """ - A function to monkeypatch the `has_` functions, + A function to monkeypatch the `has_` functions, ensuring the always return `False` at runtime. """ return False @@ -61,12 +61,12 @@ class TestRunersorterDependencyChecks: @pytest.fixture(scope="function") def uninstall_python_dependency(self, request): """ - This python fixture mocks python modules not been importable + This python fixture mocks python modules not being importable by setting the relevant `sys.modules` dict entry to `None`. - It uses `yeild` so that the function can tear-down the test + It uses `yield` so that the function can tear-down the test (even if it failed) and replace the patched `sys.module` entry. - This function uses an `indirect` parameterisation, meaning the + This function uses an `indirect` parameterization, meaning the `request.param` is passed to the fixture at the start of the test function. This is used to reuse code for nearly identical `spython` and `docker` python dependency tests. From 1c6dcf403914b2cfabd3d6174a40b049b7231f97 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 11:52:52 +0200 Subject: [PATCH 151/248] oups --- src/spikeinterface/sortingcomponents/motion_interpolation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion_interpolation.py index 9006ebdcf0..32bb7634e9 100644 --- a/src/spikeinterface/sortingcomponents/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion_interpolation.py @@ -364,7 +364,7 @@ def __init__( if interpolation_time_bin_centers_s is None: # in this case, interpolation_time_bin_size_s is set. s_end = parent_segment.get_num_samples() - t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end]), segment_index=segment_index) + t_start, t_end = parent_segment.sample_index_to_time(np.array([0, s_end])) halfbin = interpolation_time_bin_size_s / 2.0 segment_interpolation_time_bins_s = np.arange(t_start + halfbin, t_end, interpolation_time_bin_size_s) else: From 2be582b155fa820de809590e5343a4bf2de695ef Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Wed, 19 Jun 2024 12:41:14 +0200 Subject: [PATCH 152/248] Clean up docs --- src/spikeinterface/curation/auto_merge.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/curation/auto_merge.py b/src/spikeinterface/curation/auto_merge.py index c652089a39..0797947f87 100644 --- a/src/spikeinterface/curation/auto_merge.py +++ b/src/spikeinterface/curation/auto_merge.py @@ -191,7 +191,6 @@ def get_potential_auto_merge( correlogram_diff = compute_correlogram_diff( sorting, correlograms_smoothed, - bins, win_sizes, pair_mask=pair_mask, ) @@ -249,28 +248,28 @@ def get_potential_auto_merge( return potential_merges -def compute_correlogram_diff(sorting, correlograms_smoothed, bins, win_sizes, pair_mask=None): +def compute_correlogram_diff(sorting, correlograms_smoothed, win_sizes, pair_mask=None): """ Original author: Aurelien Wyngaard (lussac) Parameters ---------- sorting : BaseSorting - The sorting object + The sorting object. correlograms_smoothed : array 3d The 3d array containing all cross and auto correlograms - (smoothed by a convolution with a gaussian curve) - bins : array - Bins of the correlograms - win_sized: - TODO + (smoothed by a convolution with a gaussian curve). + win_sizes : np.array[int] + Window size for each unit correlogram. pair_mask : None or boolean array A bool matrix of size (num_units, num_units) to select which pair to compute. Returns ------- - corr_diff + corr_diff : 2D array + The difference between the cross-correlogram and the auto-correlogram + for each pair of units. """ # bin_ms = bins[1] - bins[0] @@ -367,7 +366,7 @@ def get_unit_adaptive_window(auto_corr: np.ndarray, threshold: float): Returns ------- - unit_window (int): + unit_window : int Index at which the adaptive window has been calculated. """ import scipy.signal From 5ef22d6894552a8008b5cae9547b65904f99ed47 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Wed, 19 Jun 2024 12:41:50 +0200 Subject: [PATCH 153/248] Remove comment --- src/spikeinterface/curation/auto_merge.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spikeinterface/curation/auto_merge.py b/src/spikeinterface/curation/auto_merge.py index 0797947f87..8ab4a07dd6 100644 --- a/src/spikeinterface/curation/auto_merge.py +++ b/src/spikeinterface/curation/auto_merge.py @@ -271,8 +271,6 @@ def compute_correlogram_diff(sorting, correlograms_smoothed, win_sizes, pair_mas The difference between the cross-correlogram and the auto-correlogram for each pair of units. """ - # bin_ms = bins[1] - bins[0] - unit_ids = sorting.unit_ids n = len(unit_ids) From c504fc63f94bf0d31b5aea7329cc067f2d81dee4 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Wed, 19 Jun 2024 12:40:57 -0400 Subject: [PATCH 154/248] fix for load_json --- src/spikeinterface/core/sortinganalyzer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 46d02099d5..0094012013 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -1608,7 +1608,9 @@ def load_data(self): if self.format == "binary_folder": extension_folder = self._get_binary_extension_folder() for ext_data_file in extension_folder.iterdir(): - if ext_data_file.name == "params.json": + # patch for https://github.com/SpikeInterface/spikeinterface/issues/3041 + # maybe add a check for version number from the info.json during loading only + if ext_data_file.name == "params.json" or ext_data_file.name == "info.json": continue ext_data_name = ext_data_file.stem if ext_data_file.suffix == ".json": From 375620fa1589b8fdb46e7e9289909992ac5b0398 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 19:01:08 +0200 Subject: [PATCH 155/248] fix spike_vector_to_indices --- src/spikeinterface/core/sorting_tools.py | 15 ++++++++++++++- .../postprocessing/spike_amplitudes.py | 2 +- .../postprocessing/spike_locations.py | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 2313e7d253..5e3af58198 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -47,7 +47,7 @@ def spike_vector_to_spike_trains(spike_vector: list[np.array], unit_ids: np.arra return spike_trains -def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array): +def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, absolut_index=False): """ Similar to spike_vector_to_spike_trains but instead having the spike_trains (aka spike times) return spike indices by segment and units. @@ -61,6 +61,12 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array): List of spike vectors optained with sorting.to_spike_vector(concatenated=False) unit_ids: np.array Unit ids + absolut_index: bool, default False + Give spike indices absolut usefull when having a unique spike vector + or relative to segment usefull with a list of spike vectors + When a unique spike vectors (or amplitudes) is used then absolut_index should be True. + When a list of spikes (or amplitudes) is used then absolut_index should be False. + Returns ------- spike_indices: dict[dict]: @@ -82,12 +88,19 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array): num_units = unit_ids.size spike_indices = {} + + total_spikes = 0 for segment_index, spikes in enumerate(spike_vector): indices = np.arange(spikes.size, dtype=np.int64) + if absolut_index: + indices += total_spikes + total_spikes += spikes.size unit_indices = np.array(spikes["unit_index"]).astype(np.int64, copy=False) list_of_spike_indices = vector_to_list_of_spiketrain(indices, unit_indices, num_units) + spike_indices[segment_index] = dict(zip(unit_ids, list_of_spike_indices)) + return spike_indices diff --git a/src/spikeinterface/postprocessing/spike_amplitudes.py b/src/spikeinterface/postprocessing/spike_amplitudes.py index 09b46362e5..2a9edf7e73 100644 --- a/src/spikeinterface/postprocessing/spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/spike_amplitudes.py @@ -127,7 +127,7 @@ def _get_data(self, outputs="numpy"): elif outputs == "by_unit": unit_ids = self.sorting_analyzer.unit_ids spike_vector = self.sorting_analyzer.sorting.to_spike_vector(concatenated=False) - spike_indices = spike_vector_to_indices(spike_vector, unit_ids) + spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolut_index=True) amplitudes_by_units = {} for segment_index in range(self.sorting_analyzer.sorting.get_num_segments()): amplitudes_by_units[segment_index] = {} diff --git a/src/spikeinterface/postprocessing/spike_locations.py b/src/spikeinterface/postprocessing/spike_locations.py index d468bd90ab..e7a9d7a992 100644 --- a/src/spikeinterface/postprocessing/spike_locations.py +++ b/src/spikeinterface/postprocessing/spike_locations.py @@ -140,7 +140,7 @@ def _get_data(self, outputs="numpy"): elif outputs == "by_unit": unit_ids = self.sorting_analyzer.unit_ids spike_vector = self.sorting_analyzer.sorting.to_spike_vector(concatenated=False) - spike_indices = spike_vector_to_indices(spike_vector, unit_ids) + spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolut_index=True) spike_locations_by_units = {} for segment_index in range(self.sorting_analyzer.sorting.get_num_segments()): spike_locations_by_units[segment_index] = {} From 543cc8f2a67719e4ae8b5b64a198a6c7256406e4 Mon Sep 17 00:00:00 2001 From: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> Date: Wed, 19 Jun 2024 18:12:31 +0100 Subject: [PATCH 156/248] Add apptainer case to 'has_singularity()' Co-authored-by: Alessio Buccino --- src/spikeinterface/sorters/utils/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index 1e01b9c052..82480ffe0a 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -96,7 +96,7 @@ def has_docker(): def has_singularity(): - return _run_subprocess_silently("singularity --version").returncode == 0 + return _run_subprocess_silently("singularity --version").returncode == 0 or _run_subprocess_silently("apptainer --version").returncode == 0 def has_docker_nvidia_installed(): From dceb08070af9954b25c99c82ed2df314ef924aa7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 17:12:51 +0000 Subject: [PATCH 157/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/sorters/utils/misc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/sorters/utils/misc.py b/src/spikeinterface/sorters/utils/misc.py index 82480ffe0a..9c8c3bba89 100644 --- a/src/spikeinterface/sorters/utils/misc.py +++ b/src/spikeinterface/sorters/utils/misc.py @@ -96,7 +96,10 @@ def has_docker(): def has_singularity(): - return _run_subprocess_silently("singularity --version").returncode == 0 or _run_subprocess_silently("apptainer --version").returncode == 0 + return ( + _run_subprocess_silently("singularity --version").returncode == 0 + or _run_subprocess_silently("apptainer --version").returncode == 0 + ) def has_docker_nvidia_installed(): From 8a7c145a8a1abd4c8d63c55eabb32910205053ab Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 12 Jun 2024 13:30:06 +0100 Subject: [PATCH 158/248] Add peaks_on_probe widget and tests. --- src/spikeinterface/widgets/peaks_on_probe.py | 218 +++++++++++++ .../widgets/tests/test_peaks_on_probe.py | 304 ++++++++++++++++++ src/spikeinterface/widgets/widget_list.py | 3 + 3 files changed, 525 insertions(+) create mode 100644 src/spikeinterface/widgets/peaks_on_probe.py create mode 100644 src/spikeinterface/widgets/tests/test_peaks_on_probe.py diff --git a/src/spikeinterface/widgets/peaks_on_probe.py b/src/spikeinterface/widgets/peaks_on_probe.py new file mode 100644 index 0000000000..0d23b6c67e --- /dev/null +++ b/src/spikeinterface/widgets/peaks_on_probe.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import numpy as np + + +from .base import BaseWidget, to_attr + + +class PeaksOnProbeWidget(BaseWidget): + """ + Generate a plot of spike peaks showing their location on a plot + of the probe. Color scaling represents spike amplitude. + + The generated plot overlays the estimated position of a spike peak + (as a single point for each peak) onto a plot of the probe. The + dimensions of the plot are x axis: probe width, y axis: probe depth. + + Plots of different sets of peaks can be created on subplots, by + passing a list of peaks and corresponding peak locations. + + Parameters + ---------- + recording : Recording + A SpikeInterface recording object. + peaks : np.array | list[np.ndarray] + SpikeInterface 'peaks' array created with `detect_peaks()`, + an array of length num_peaks with entries: + (sample_index, channel_index, amplitude, segment_index) + To plot different sets of peaks in subplots, pass a list of peaks, each + with a corresponding entry in a list passed to `peak_locations`. + peak_locations : np.array | list[np.ndarray] + A SpikeInterface 'peak_locations' array created with `localize_peaks()`. + an array of length num_peaks with entries: (x, y) + To plot multiple peaks in subplots, pass a list of `peak_locations` + here with each entry having a corresponding `peaks`. + segment_index : None | int, default: None + If set, only peaks from this recording segment will be used. + time_range : None | Tuple, default: None + The time period over which to include peaks. If `None`, peaks + across the entire recording will be shown. + ylim : None | Tuple, default: None + The y-axis limits (i.e. the probe depth). If `None`, the entire + probe will be displayed. + decimate : int, default: 5 + For performance reasons, every nth peak is shown on the plot, + where n is set by decimate. To plot all peaks, set `decimate=1`. + """ + + def __init__( + self, + recording, + peaks, + peak_locations, + segment_index=None, + time_range=None, + ylim=None, + decimate=5, + backend=None, + **backend_kwargs, + ): + data_plot = dict( + recording=recording, + peaks=peaks, + peak_locations=peak_locations, + segment_index=segment_index, + time_range=time_range, + ylim=ylim, + decimate=decimate, + ) + + BaseWidget.__init__(self, data_plot, backend=backend, **backend_kwargs) + + def plot_matplotlib(self, data_plot, **backend_kwargs): + import matplotlib.pyplot as plt + from .utils_matplotlib import make_mpl_figure + from spikeinterface.widgets import plot_probe_map + + dp = to_attr(data_plot) + + peaks, peak_locations = self._check_and_format_inputs( + dp.peaks, + dp.peak_locations, + ) + fs = dp.recording.get_sampling_frequency() + num_plots = len(peaks) + + # Set the maximum time to the end time of the longest segment + if dp.time_range is None: + + time_range = self._get_min_and_max_times_in_recording(dp.recording) + else: + time_range = dp.time_range + + ## Create the figure and axes + if backend_kwargs["figsize"] is None: + backend_kwargs.update(dict(figsize=(12, 8))) + + self.figure, self.axes, self.ax = make_mpl_figure(num_axes=num_plots, **backend_kwargs) + self.axes = self.axes[0] + + # Plot each passed peaks / peak_locations over the probe on a separate subplot + for ax_idx, (peaks_to_plot, peak_locs_to_plot) in enumerate(zip(peaks, peak_locations)): + + ax = self.axes[ax_idx] + plot_probe_map(dp.recording, ax=ax) + + time_mask = self._get_peaks_time_mask(dp.recording, time_range, peaks_to_plot) + + if dp.segment_index is not None: + segment_mask = peaks_to_plot["segment_index"] == dp.segment_index + mask = time_mask & segment_mask + else: + mask = time_mask + + if not any(mask): + raise ValueError( + "No peaks within the time and segment mask found. Change `time_range` or `segment_index`" + ) + + # only plot every nth peak + peak_slice = slice(None, None, dp.decimate) + + # Find the amplitudes for the colormap scaling + # (intensity represents amplitude) + amps = np.abs(peaks_to_plot["amplitude"][mask][peak_slice]) + amps /= np.quantile(amps, 0.95) + cmap = plt.get_cmap("inferno")(amps) + color_kwargs = dict(alpha=0.2, s=2, c=cmap) + + # Plot the peaks over the plot, and set the y-axis limits. + ax.scatter( + peak_locs_to_plot["x"][mask][peak_slice], peak_locs_to_plot["y"][mask][peak_slice], **color_kwargs + ) + + if dp.ylim is None: + padding = 25 # arbitary padding just to give some space around highests and lowest peaks on the plot + ylim = (np.min(peak_locs_to_plot["y"]) - padding, np.max(peak_locs_to_plot["y"]) + padding) + else: + ylim = dp.ylim + + ax.set_ylim(ylim[0], ylim[1]) + + self.figure.suptitle(f"Peaks on Probe Plot") + + def _get_peaks_time_mask(self, recording, time_range, peaks_to_plot): + """ + Return a mask of `True` where the peak is within the given time range + and `False` otherwise. + + This is a little complex, as each segment can have different start / + end times. For each segment, find the time bounds relative to that + segment time and fill the `time_mask` one segment at a time. + """ + time_mask = np.zeros(peaks_to_plot.size, dtype=bool) + + for seg_idx in range(recording.get_num_segments()): + + segment = recording.select_segments(seg_idx) + + t_start_sample = segment.time_to_sample_index(time_range[0]) + t_stop_sample = segment.time_to_sample_index(time_range[1]) + + seg_mask = peaks_to_plot["segment_index"] == seg_idx + + time_mask[seg_mask] = (t_start_sample < peaks_to_plot[seg_mask]["sample_index"]) & ( + peaks_to_plot[seg_mask]["sample_index"] < t_stop_sample + ) + + return time_mask + + def _get_min_and_max_times_in_recording(self, recording): + """ + Find the maximum and minimum time across all segments in the recording. + For example if the segment times are (10-100 s, 0 - 50s) the + min and max times are (0, 100) + """ + t_starts = [] + t_stops = [] + for seg_idx in range(recording.get_num_segments()): + + segment = recording.select_segments(seg_idx) + + t_starts.append(segment.sample_index_to_time(0)) + + t_stops.append(segment.sample_index_to_time(segment.get_num_samples() - 1)) + + time_range = (np.min(t_starts), np.max(t_stops)) + + return time_range + + def _check_and_format_inputs(self, peaks, peak_locations): + """ + Check that the inpust are in expected form. Corresponding peaks + and peak_locations of same size and format must be provided. + """ + types_are_list = [isinstance(peaks, list), isinstance(peak_locations, list)] + + if not all(types_are_list): + if any(types_are_list): + raise ValueError("`peaks` and `peak_locations` must either be both lists or both not lists.") + peaks = [peaks] + peak_locations = [peak_locations] + + if len(peaks) != len(peak_locations): + raise ValueError( + "If `peaks` and `peak_locations` are lists, they must contain " + "the same number of (corresponding) peaks and peak locations." + ) + + for idx, (peak, peak_loc) in enumerate(zip(peaks, peak_locations)): + if peak.size != peak_loc.size: + raise ValueError( + f"The number of peaks and peak_locations do not " + f"match for the {idx} input. For each spike peak, there " + f"must be a corresponding peak location" + ) + + return peaks, peak_locations diff --git a/src/spikeinterface/widgets/tests/test_peaks_on_probe.py b/src/spikeinterface/widgets/tests/test_peaks_on_probe.py new file mode 100644 index 0000000000..9820ee5e72 --- /dev/null +++ b/src/spikeinterface/widgets/tests/test_peaks_on_probe.py @@ -0,0 +1,304 @@ +import pytest +from spikeinterface.sortingcomponents.peak_localization import localize_peaks +from spikeinterface.sortingcomponents.peak_detection import detect_peaks +from spikeinterface.widgets import plot_peaks_on_probe +from spikeinterface import generate_ground_truth_recording # TODO: think about imports +import numpy as np + + +class TestPeaksOnProbe: + + @pytest.fixture(scope="session") + def peak_info(self): + """ + Fixture (created only once per test run) of a small + ground truth recording with peaks and peak locations calculated. + """ + recording, _ = generate_ground_truth_recording(num_units=5, num_channels=16, durations=[20, 9], seed=0) + peaks = detect_peaks(recording) + + peak_locations = localize_peaks( + recording, + peaks, + ms_before=0.3, + ms_after=0.6, + method="center_of_mass", + ) + + return (recording, peaks, peak_locations) + + def data_from_widget(self, widget, axes_idx): + """ + Convenience function to get the data of the peaks + that are on the plot (not sure why they are in the + second 'collections'). + """ + return widget.axes[axes_idx].collections[2].get_offsets().data + + def test_peaks_on_probe_main(self, peak_info): + """ + Plot all peaks, and check every peak is plot. + Check the labels are corect. + """ + recording, peaks, peak_locations = peak_info + + widget = plot_peaks_on_probe(recording, peaks, peak_locations, decimate=1) + + ax_y_data = self.data_from_widget(widget, 0)[:, 1] + ax_y_pos = peak_locations["y"] + + assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) + assert widget.axes[0].get_ylabel() == "y ($\\mu m$)" + assert widget.axes[0].get_xlabel() == "x ($\\mu m$)" + + @pytest.mark.parametrize("segment_index", [0, 1]) + def test_segment_selection(self, peak_info, segment_index): + """ + Check that that when specifying only to plot peaks + from a sepecific segment, that only peaks + from that segment are plot. + """ + recording, peaks, peak_locations = peak_info + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=1, + segment_index=segment_index, + ) + + ax_y_data = self.data_from_widget(widget, 0)[:, 1] + ax_y_pos = peak_locations["y"][peaks["segment_index"] == segment_index] + + assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) + + def test_multiple_inputs(self, peak_info): + """ + Check that multiple inputs are correctly plot + on separate axes. Do this my creating a copy + of the peaks / peak locations with less peaks + and different locations, for good measure. + Check that these separate peaks / peak locations + are plot on different axes. + """ + recording, peaks, peak_locations = peak_info + + half_num_peaks = int(peaks.shape[0] / 2) + + peaks_change = peaks.copy()[:half_num_peaks] + locs_change = peak_locations.copy()[:half_num_peaks] + locs_change["y"] += 1 + + widget = plot_peaks_on_probe( + recording, + [peaks, peaks_change], + [peak_locations, locs_change], + decimate=1, + ) + + # Test the first entry, axis 0 + ax_0_y_data = self.data_from_widget(widget, 0)[:, 1] + + assert np.array_equal(np.sort(peak_locations["y"]), np.sort(ax_0_y_data)) + + # Test the second entry, axis 1. + ax_1_y_data = self.data_from_widget(widget, 1)[:, 1] + + assert np.array_equal(np.sort(locs_change["y"]), np.sort(ax_1_y_data)) + + def test_times_all(self, peak_info): + """ + Check that when the times of peaks to plot is restricted, + only peaks within the given time range are plot. Set the + limits just before and after the second peak, and check only + that peak is plot. + """ + recording, peaks, peak_locations = peak_info + + peak_idx = 1 + peak_cutoff_low = peaks["sample_index"][peak_idx] - 1 + peak_cutoff_high = peaks["sample_index"][peak_idx] + 1 + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=1, + time_range=( + peak_cutoff_low / recording.get_sampling_frequency(), + peak_cutoff_high / recording.get_sampling_frequency(), + ), + ) + + ax_y_data = self.data_from_widget(widget, 0)[:, 1] + + assert np.array_equal([peak_locations[peak_idx]["y"]], ax_y_data) + + def test_times_per_segment(self, peak_info): + """ + Test that the time bounds for multi-segment recordings + with different times are handled properly. The time bounds + given must respect the times for each segment. Here, we build + two segments with times 0-100s and 100-200s. We set the + time limits for peaks to plot as 50-150 i.e. all peaks + from the second half of the first segment, and the first half + of the second segment, should be plotted. + + Recompute peaks here for completeness even though this does + duplicate the fixture. + """ + recording, _, _ = peak_info + + first_seg_times = np.linspace(0, 100, recording.get_num_samples(0)) + second_seg_times = np.linspace(100, 200, recording.get_num_samples(1)) + + recording.set_times(first_seg_times, segment_index=0) + recording.set_times(second_seg_times, segment_index=1) + + # After setting the peak times above, re-detect peaks and plot + # with a time range 50-150 s + peaks = detect_peaks(recording) + + peak_locations = localize_peaks( + recording, + peaks, + ms_before=0.3, + ms_after=0.6, + method="center_of_mass", + ) + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=1, + time_range=( + 50, + 150, + ), + ) + + # Find the peaks that are expected to be plot given the time + # restriction (second half of first segment, first half of + # second segment) and check that indeed the expected locations + # are displayed. + seg_one_num_samples = recording.get_num_samples(0) + seg_two_num_samples = recording.get_num_samples(1) + + okay_peaks_one = np.logical_and( + peaks["segment_index"] == 0, peaks["sample_index"] > int(seg_one_num_samples / 2) + ) + okay_peaks_two = np.logical_and( + peaks["segment_index"] == 1, peaks["sample_index"] < int(seg_two_num_samples / 2) + ) + okay_peaks = np.logical_or(okay_peaks_one, okay_peaks_two) + + ax_y_data = self.data_from_widget(widget, 0)[:, 1] + + assert any(okay_peaks), "someting went wrong in test generation, no peaks within the set time bounds detected" + + assert np.array_equal(np.sort(ax_y_data), np.sort(peak_locations[okay_peaks]["y"])) + + def test_get_min_and_max_times_in_recording(self, peak_info): + """ + Check that the function which finds the minimum and maximum times + across all segments in the recording returns correctly. First + set times of the segments such that the earliest time is 50s and + latest 200s. Check the function returns (50, 200). + """ + recording, peaks, peak_locations = peak_info + + first_seg_times = np.linspace(50, 100, recording.get_num_samples(0)) + second_seg_times = np.linspace(100, 200, recording.get_num_samples(1)) + + recording.set_times(first_seg_times, segment_index=0) + recording.set_times(second_seg_times, segment_index=1) + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=1, + ) + + min_max_times = widget._get_min_and_max_times_in_recording(recording) + + assert min_max_times == (50, 200) + + def test_ylim(self, peak_info): + """ + Specify some y-axis limits (which is the probe height + to show) and check that the plot is restricted to + these limits. + """ + recording, peaks, peak_locations = peak_info + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=1, + ylim=(300, 600), + ) + + assert widget.axes[0].get_ylim() == (300, 600) + + def test_decimate(self, peak_info): + """ + By default, only a subset of peaks are shown for + performance reasons. In tests, decimate is set to 1 + to ensure all peaks are plot. This tests now + checks the decimate argument, to ensure peaks that are + plot are correctly decimated. + """ + recording, peaks, peak_locations = peak_info + + decimate = 5 + + widget = plot_peaks_on_probe( + recording, + peaks, + peak_locations, + decimate=decimate, + ) + + ax_y_data = self.data_from_widget(widget, 0)[:, 1] + ax_y_pos = peak_locations["y"][::decimate] + + assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) + + def test_errors(self, peak_info): + """ + Test all validation errors are raised when data in + incorrect form is passed to the plotting function. + """ + recording, peaks, peak_locations = peak_info + + # All lists must be same length + with pytest.raises(ValueError) as e: + plot_peaks_on_probe( + recording, + [peaks, peaks], + [peak_locations], + ) + + # peaks and corresponding peak locations must be same size + with pytest.raises(ValueError) as e: + plot_peaks_on_probe( + recording, + [peaks[:-1]], + [peak_locations], + ) + + # if one is list, both must be lists + with pytest.raises(ValueError) as e: + plot_peaks_on_probe( + recording, + peaks, + [peak_locations], + ) + + # must have some peaks within the given time / segment + with pytest.raises(ValueError) as e: + plot_peaks_on_probe(recording, [peaks[:-1]], [peak_locations], time_range=(0, 0.001)) diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index d6df59b0f3..6367e098ea 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -13,6 +13,7 @@ from .motion import MotionWidget, MotionInfoWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget from .peak_activity import PeakActivityMapWidget +from .peaks_on_probe import PeaksOnProbeWidget from .potential_merges import PotentialMergesWidget from .probe_map import ProbeMapWidget from .quality_metrics import QualityMetricsWidget @@ -50,6 +51,7 @@ MultiCompAgreementBySorterWidget, MultiCompGraphWidget, PeakActivityMapWidget, + PeaksOnProbeWidget, PotentialMergesWidget, ProbeMapWidget, QualityMetricsWidget, @@ -123,6 +125,7 @@ plot_multicomparison_agreement_by_sorter = MultiCompAgreementBySorterWidget plot_multicomparison_graph = MultiCompGraphWidget plot_peak_activity = PeakActivityMapWidget +plot_peaks_on_probe = PeaksOnProbeWidget plot_potential_merges = PotentialMergesWidget plot_probe_map = ProbeMapWidget plot_quality_metrics = QualityMetricsWidget From 7ab068b36bcc55d1efd6966051f54b152c4321e2 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 19 Jun 2024 20:00:17 +0100 Subject: [PATCH 159/248] Remove tests. --- .../widgets/tests/test_peaks_on_probe.py | 304 ------------------ 1 file changed, 304 deletions(-) delete mode 100644 src/spikeinterface/widgets/tests/test_peaks_on_probe.py diff --git a/src/spikeinterface/widgets/tests/test_peaks_on_probe.py b/src/spikeinterface/widgets/tests/test_peaks_on_probe.py deleted file mode 100644 index 9820ee5e72..0000000000 --- a/src/spikeinterface/widgets/tests/test_peaks_on_probe.py +++ /dev/null @@ -1,304 +0,0 @@ -import pytest -from spikeinterface.sortingcomponents.peak_localization import localize_peaks -from spikeinterface.sortingcomponents.peak_detection import detect_peaks -from spikeinterface.widgets import plot_peaks_on_probe -from spikeinterface import generate_ground_truth_recording # TODO: think about imports -import numpy as np - - -class TestPeaksOnProbe: - - @pytest.fixture(scope="session") - def peak_info(self): - """ - Fixture (created only once per test run) of a small - ground truth recording with peaks and peak locations calculated. - """ - recording, _ = generate_ground_truth_recording(num_units=5, num_channels=16, durations=[20, 9], seed=0) - peaks = detect_peaks(recording) - - peak_locations = localize_peaks( - recording, - peaks, - ms_before=0.3, - ms_after=0.6, - method="center_of_mass", - ) - - return (recording, peaks, peak_locations) - - def data_from_widget(self, widget, axes_idx): - """ - Convenience function to get the data of the peaks - that are on the plot (not sure why they are in the - second 'collections'). - """ - return widget.axes[axes_idx].collections[2].get_offsets().data - - def test_peaks_on_probe_main(self, peak_info): - """ - Plot all peaks, and check every peak is plot. - Check the labels are corect. - """ - recording, peaks, peak_locations = peak_info - - widget = plot_peaks_on_probe(recording, peaks, peak_locations, decimate=1) - - ax_y_data = self.data_from_widget(widget, 0)[:, 1] - ax_y_pos = peak_locations["y"] - - assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) - assert widget.axes[0].get_ylabel() == "y ($\\mu m$)" - assert widget.axes[0].get_xlabel() == "x ($\\mu m$)" - - @pytest.mark.parametrize("segment_index", [0, 1]) - def test_segment_selection(self, peak_info, segment_index): - """ - Check that that when specifying only to plot peaks - from a sepecific segment, that only peaks - from that segment are plot. - """ - recording, peaks, peak_locations = peak_info - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=1, - segment_index=segment_index, - ) - - ax_y_data = self.data_from_widget(widget, 0)[:, 1] - ax_y_pos = peak_locations["y"][peaks["segment_index"] == segment_index] - - assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) - - def test_multiple_inputs(self, peak_info): - """ - Check that multiple inputs are correctly plot - on separate axes. Do this my creating a copy - of the peaks / peak locations with less peaks - and different locations, for good measure. - Check that these separate peaks / peak locations - are plot on different axes. - """ - recording, peaks, peak_locations = peak_info - - half_num_peaks = int(peaks.shape[0] / 2) - - peaks_change = peaks.copy()[:half_num_peaks] - locs_change = peak_locations.copy()[:half_num_peaks] - locs_change["y"] += 1 - - widget = plot_peaks_on_probe( - recording, - [peaks, peaks_change], - [peak_locations, locs_change], - decimate=1, - ) - - # Test the first entry, axis 0 - ax_0_y_data = self.data_from_widget(widget, 0)[:, 1] - - assert np.array_equal(np.sort(peak_locations["y"]), np.sort(ax_0_y_data)) - - # Test the second entry, axis 1. - ax_1_y_data = self.data_from_widget(widget, 1)[:, 1] - - assert np.array_equal(np.sort(locs_change["y"]), np.sort(ax_1_y_data)) - - def test_times_all(self, peak_info): - """ - Check that when the times of peaks to plot is restricted, - only peaks within the given time range are plot. Set the - limits just before and after the second peak, and check only - that peak is plot. - """ - recording, peaks, peak_locations = peak_info - - peak_idx = 1 - peak_cutoff_low = peaks["sample_index"][peak_idx] - 1 - peak_cutoff_high = peaks["sample_index"][peak_idx] + 1 - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=1, - time_range=( - peak_cutoff_low / recording.get_sampling_frequency(), - peak_cutoff_high / recording.get_sampling_frequency(), - ), - ) - - ax_y_data = self.data_from_widget(widget, 0)[:, 1] - - assert np.array_equal([peak_locations[peak_idx]["y"]], ax_y_data) - - def test_times_per_segment(self, peak_info): - """ - Test that the time bounds for multi-segment recordings - with different times are handled properly. The time bounds - given must respect the times for each segment. Here, we build - two segments with times 0-100s and 100-200s. We set the - time limits for peaks to plot as 50-150 i.e. all peaks - from the second half of the first segment, and the first half - of the second segment, should be plotted. - - Recompute peaks here for completeness even though this does - duplicate the fixture. - """ - recording, _, _ = peak_info - - first_seg_times = np.linspace(0, 100, recording.get_num_samples(0)) - second_seg_times = np.linspace(100, 200, recording.get_num_samples(1)) - - recording.set_times(first_seg_times, segment_index=0) - recording.set_times(second_seg_times, segment_index=1) - - # After setting the peak times above, re-detect peaks and plot - # with a time range 50-150 s - peaks = detect_peaks(recording) - - peak_locations = localize_peaks( - recording, - peaks, - ms_before=0.3, - ms_after=0.6, - method="center_of_mass", - ) - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=1, - time_range=( - 50, - 150, - ), - ) - - # Find the peaks that are expected to be plot given the time - # restriction (second half of first segment, first half of - # second segment) and check that indeed the expected locations - # are displayed. - seg_one_num_samples = recording.get_num_samples(0) - seg_two_num_samples = recording.get_num_samples(1) - - okay_peaks_one = np.logical_and( - peaks["segment_index"] == 0, peaks["sample_index"] > int(seg_one_num_samples / 2) - ) - okay_peaks_two = np.logical_and( - peaks["segment_index"] == 1, peaks["sample_index"] < int(seg_two_num_samples / 2) - ) - okay_peaks = np.logical_or(okay_peaks_one, okay_peaks_two) - - ax_y_data = self.data_from_widget(widget, 0)[:, 1] - - assert any(okay_peaks), "someting went wrong in test generation, no peaks within the set time bounds detected" - - assert np.array_equal(np.sort(ax_y_data), np.sort(peak_locations[okay_peaks]["y"])) - - def test_get_min_and_max_times_in_recording(self, peak_info): - """ - Check that the function which finds the minimum and maximum times - across all segments in the recording returns correctly. First - set times of the segments such that the earliest time is 50s and - latest 200s. Check the function returns (50, 200). - """ - recording, peaks, peak_locations = peak_info - - first_seg_times = np.linspace(50, 100, recording.get_num_samples(0)) - second_seg_times = np.linspace(100, 200, recording.get_num_samples(1)) - - recording.set_times(first_seg_times, segment_index=0) - recording.set_times(second_seg_times, segment_index=1) - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=1, - ) - - min_max_times = widget._get_min_and_max_times_in_recording(recording) - - assert min_max_times == (50, 200) - - def test_ylim(self, peak_info): - """ - Specify some y-axis limits (which is the probe height - to show) and check that the plot is restricted to - these limits. - """ - recording, peaks, peak_locations = peak_info - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=1, - ylim=(300, 600), - ) - - assert widget.axes[0].get_ylim() == (300, 600) - - def test_decimate(self, peak_info): - """ - By default, only a subset of peaks are shown for - performance reasons. In tests, decimate is set to 1 - to ensure all peaks are plot. This tests now - checks the decimate argument, to ensure peaks that are - plot are correctly decimated. - """ - recording, peaks, peak_locations = peak_info - - decimate = 5 - - widget = plot_peaks_on_probe( - recording, - peaks, - peak_locations, - decimate=decimate, - ) - - ax_y_data = self.data_from_widget(widget, 0)[:, 1] - ax_y_pos = peak_locations["y"][::decimate] - - assert np.array_equal(np.sort(ax_y_data), np.sort(ax_y_pos)) - - def test_errors(self, peak_info): - """ - Test all validation errors are raised when data in - incorrect form is passed to the plotting function. - """ - recording, peaks, peak_locations = peak_info - - # All lists must be same length - with pytest.raises(ValueError) as e: - plot_peaks_on_probe( - recording, - [peaks, peaks], - [peak_locations], - ) - - # peaks and corresponding peak locations must be same size - with pytest.raises(ValueError) as e: - plot_peaks_on_probe( - recording, - [peaks[:-1]], - [peak_locations], - ) - - # if one is list, both must be lists - with pytest.raises(ValueError) as e: - plot_peaks_on_probe( - recording, - peaks, - [peak_locations], - ) - - # must have some peaks within the given time / segment - with pytest.raises(ValueError) as e: - plot_peaks_on_probe(recording, [peaks[:-1]], [peak_locations], time_range=(0, 0.001)) From bd626b0b4fbf4bfa5cfa68e857fb8ea997784c56 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 19 Jun 2024 13:22:10 -0600 Subject: [PATCH 160/248] propagate FrameSlice behavior to frame_slice and time_slice --- src/spikeinterface/core/baserecording.py | 31 +++++++++---------- .../core/frameslicerecording.py | 2 +- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 184959512b..40d014cdb3 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -45,7 +45,6 @@ def __init__(self, sampling_frequency: float, channel_ids: list, dtype): self.annotate(is_filtered=False) def __repr__(self): - extractor_name = self.__class__.__name__ num_segments = self.get_num_segments() @@ -182,7 +181,7 @@ def add_recording_segment(self, recording_segment): self._recording_segments.append(recording_segment) recording_segment.set_parent_extractor(self) - def get_num_samples(self, segment_index=None) -> int: + def get_num_samples(self, segment_index: int | None = None) -> int: """ Returns the number of samples for a segment. @@ -657,21 +656,21 @@ def _remove_channels(self, remove_channel_ids): sub_recording = ChannelSliceRecording(self, new_channel_ids) return sub_recording - def frame_slice(self, start_frame: int, end_frame: int) -> BaseRecording: + def frame_slice(self, start_frame: int | None, end_frame: int | None) -> BaseRecording: """ Returns a new recording with sliced frames. Note that this operation is not in place. Parameters ---------- - start_frame : int - The start frame - end_frame : int - The end frame + start_frame : int, optional + The start frame, if not provided it is set to 0 + end_frame : int, optional + The end frame, it not provided it is set to the total number of samples Returns ------- BaseRecording - The object with sliced frames + A new recording object with only samples between start_frame and end_frame """ from .frameslicerecording import FrameSliceRecording @@ -679,27 +678,27 @@ def frame_slice(self, start_frame: int, end_frame: int) -> BaseRecording: sub_recording = FrameSliceRecording(self, start_frame=start_frame, end_frame=end_frame) return sub_recording - def time_slice(self, start_time: float, end_time: float) -> BaseRecording: + def time_slice(self, start_time: float | None, end_time: float) -> BaseRecording: """ Returns a new recording with sliced time. Note that this operation is not in place. Parameters ---------- - start_time : float - The start time in seconds. - end_time : float - The end time in seconds. + start_time : float, optional + The start time in seconds. If not provided it is set to 0. + end_time : float, optional + The end time in seconds. If not provided it is set to the total duration. Returns ------- BaseRecording - The object with sliced time. + A new recording object with only samples between start_time and end_time """ assert self.get_num_segments() == 1, "Time slicing is only supported for single segment recordings." - start_frame = self.time_to_sample_index(start_time) - end_frame = self.time_to_sample_index(end_time) + start_frame = self.time_to_sample_index(start_time) if start_time else None + end_frame = self.time_to_sample_index(end_time) if end_time else None return self.frame_slice(start_frame=start_frame, end_frame=end_frame) diff --git a/src/spikeinterface/core/frameslicerecording.py b/src/spikeinterface/core/frameslicerecording.py index 533328ad42..133cbf886c 100644 --- a/src/spikeinterface/core/frameslicerecording.py +++ b/src/spikeinterface/core/frameslicerecording.py @@ -30,7 +30,7 @@ def __init__(self, parent_recording, start_frame=None, end_frame=None): assert parent_recording.get_num_segments() == 1, "FrameSliceRecording only works with one segment" - parent_size = parent_recording.get_num_samples(0) + parent_size = parent_recording.get_num_samples(segment_index=0) if start_frame is None: start_frame = 0 else: From e139e75d6d9a5bcbde9ce133db84a83ab7724a9e Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 19 Jun 2024 15:36:09 -0600 Subject: [PATCH 161/248] Cell explorer deprecations (#3046) * cell explorer deprecations --- .../cellexplorersortingextractor.py | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/spikeinterface/extractors/cellexplorersortingextractor.py b/src/spikeinterface/extractors/cellexplorersortingextractor.py index 3436313b4d..9b77965c43 100644 --- a/src/spikeinterface/extractors/cellexplorersortingextractor.py +++ b/src/spikeinterface/extractors/cellexplorersortingextractor.py @@ -2,8 +2,7 @@ import numpy as np from pathlib import Path -import warnings -import datetime + from ..core import BaseSorting, BaseSortingSegment from ..core.core_tools import define_function_from_class @@ -36,36 +35,15 @@ class CellExplorerSortingExtractor(BaseSorting): def __init__( self, - file_path: str | Path | None = None, + file_path: str | Path, sampling_frequency: float | None = None, session_info_file_path: str | Path | None = None, - spikes_matfile_path: str | Path | None = None, ): try: from pymatreader import read_mat except ImportError: raise ImportError(self.installation_mesg) - assert ( - file_path is not None or spikes_matfile_path is not None - ), "Either file_path or spikes_matfile_path must be provided!" - - if spikes_matfile_path is not None: - # Raise an error if the warning period has expired - deprecation_issued = datetime.datetime(2023, 4, 1) - deprecation_deadline = deprecation_issued + datetime.timedelta(days=180) - if datetime.datetime.now() > deprecation_deadline: - raise ValueError("The spikes_matfile_path argument is no longer supported in. Use file_path instead.") - - # Otherwise, issue a DeprecationWarning - else: - warnings.warn( - "The spikes_matfile_path argument is deprecated and will be removed in six months. " - "Use file_path instead.", - DeprecationWarning, - ) - file_path = spikes_matfile_path if file_path is None else file_path - self.spikes_cellinfo_path = Path(file_path) self.session_path = self.spikes_cellinfo_path.parent self.session_id = self.spikes_cellinfo_path.stem.split(".")[0] From 3a6545700b6b55d254c79bac909e83539f471062 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 20 Jun 2024 12:06:03 +0200 Subject: [PATCH 162/248] Support kilosort>=4.0.12 --- src/spikeinterface/core/generate.py | 6 +++++ .../sorters/external/kilosort4.py | 26 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index 05c1ebc7ed..251678e675 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -1145,6 +1145,8 @@ def get_traces( ) -> np.ndarray: start_frame = 0 if start_frame is None else max(start_frame, 0) end_frame = self.num_samples if end_frame is None else min(end_frame, self.num_samples) + start_frame = int(start_frame) + end_frame = int(end_frame) start_frame_within_block = start_frame % self.noise_block_size end_frame_within_block = end_frame % self.noise_block_size @@ -1812,6 +1814,8 @@ def get_traces( ) -> np.ndarray: start_frame = 0 if start_frame is None else start_frame end_frame = self.num_samples if end_frame is None else end_frame + start_frame = int(start_frame) + end_frame = int(end_frame) if channel_indices is None: n_channels = self.templates.shape[2] @@ -1848,6 +1852,8 @@ def get_traces( end_traces = start_traces + template.shape[0] if start_traces >= end_frame - start_frame or end_traces <= 0: continue + start_traces = int(start_traces) + end_traces = int(end_traces) start_template = 0 end_template = template.shape[0] diff --git a/src/spikeinterface/sorters/external/kilosort4.py b/src/spikeinterface/sorters/external/kilosort4.py index 47846f10ce..a7f40a9558 100644 --- a/src/spikeinterface/sorters/external/kilosort4.py +++ b/src/spikeinterface/sorters/external/kilosort4.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import Union +from packaging import version from ..basesorter import BaseSorter from .kilosortbase import KilosortBase @@ -24,11 +25,14 @@ class Kilosort4Sorter(BaseSorter): "do_CAR": True, "invert_sign": False, "nt": 61, + "shift": None, + "scale": None, "artifact_threshold": None, "nskip": 25, "whitening_range": 32, "binning_depth": 5, "sig_interp": 20, + "drift_smoothing": [0.5, 0.5, 0.5], "nt0min": None, "dmin": None, "dminx": 32, @@ -63,11 +67,14 @@ class Kilosort4Sorter(BaseSorter): "do_CAR": "Whether to perform common average reference. Default value: True.", "invert_sign": "Invert the sign of the data. Default value: False.", "nt": "Number of samples per waveform. Also size of symmetric padding for filtering. Default value: 61.", + "shift": "Scalar shift to apply to data before all other operations. Default None.", + "scale": "Scaling factor to apply to data before all other operations. Default None.", "artifact_threshold": "If a batch contains absolute values above this number, it will be zeroed out under the assumption that a recording artifact is present. By default, the threshold is infinite (so that no zeroing occurs). Default value: None.", "nskip": "Batch stride for computing whitening matrix. Default value: 25.", "whitening_range": "Number of nearby channels used to estimate the whitening matrix. Default value: 32.", "binning_depth": "For drift correction, vertical bin size in microns used for 2D histogram. Default value: 5.", "sig_interp": "For drift correction, sigma for interpolation (spatial standard deviation). Approximate smoothness scale in units of microns. Default value: 20.", + "drift_smoothing": "Amount of gaussian smoothing to apply to the spatiotemporal drift estimation, for x,y,time axes in units of registration blocks (for x,y axes) and batch size (for time axis). The x,y smoothing has no effect for `nblocks = 1`.", "nt0min": "Sample index for aligning waveforms, so that their minimum or maximum value happens here. Default of 20. Default value: None.", "dmin": "Vertical spacing of template centers used for spike detection, in microns. Determined automatically by default. Default value: None.", "dminx": "Horizontal spacing of template centers used for spike detection, in microns. Default value: 32.", @@ -153,6 +160,11 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): import torch import numpy as np + if verbose: + import logging + + logging.basicConfig(level=logging.INFO) + sorter_output_folder = sorter_output_folder.absolute() probe_filename = sorter_output_folder / "probe.prb" @@ -194,11 +206,17 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): data_dir = "" results_dir = sorter_output_folder filename, data_dir, results_dir, probe = set_files(settings, filename, probe, probe_name, data_dir, results_dir) - ops = initialize_ops(settings, probe, recording.get_dtype(), do_CAR, invert_sign, device) + if version.parse(cls.get_sorter_version()) >= version.parse("4.0.12"): + ops = initialize_ops(settings, probe, recording.get_dtype(), do_CAR, invert_sign, device, False) + n_chan_bin, fs, NT, nt, twav_min, chan_map, dtype, do_CAR, invert, _, _, tmin, tmax, artifact, _, _ = ( + get_run_parameters(ops) + ) + else: + ops = initialize_ops(settings, probe, recording.get_dtype(), do_CAR, invert_sign, device) + n_chan_bin, fs, NT, nt, twav_min, chan_map, dtype, do_CAR, invert, _, _, tmin, tmax, artifact = ( + get_run_parameters(ops) + ) - n_chan_bin, fs, NT, nt, twav_min, chan_map, dtype, do_CAR, invert, _, _, tmin, tmax, artifact = ( - get_run_parameters(ops) - ) # Set preprocessing and drift correction parameters if not params["skip_kilosort_preprocessing"]: ops = compute_preprocessing(ops, device, tic0=tic0, file_object=file_object) From 0507d59b811682f6e2fb1132099dd6575b43362b Mon Sep 17 00:00:00 2001 From: Garcia Samuel Date: Thu, 20 Jun 2024 12:59:08 +0200 Subject: [PATCH 163/248] Update src/spikeinterface/core/sorting_tools.py Co-authored-by: Alessio Buccino --- src/spikeinterface/core/sorting_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 5e3af58198..13f6b28f3c 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -47,7 +47,7 @@ def spike_vector_to_spike_trains(spike_vector: list[np.array], unit_ids: np.arra return spike_trains -def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, absolut_index=False): +def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, absolute_index : bool = False): """ Similar to spike_vector_to_spike_trains but instead having the spike_trains (aka spike times) return spike indices by segment and units. From 861857f6215b89ecb7220c1f34c8b504943b3b60 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Thu, 20 Jun 2024 08:16:55 -0400 Subject: [PATCH 164/248] add tests for select units for zarr --- .../core/tests/test_sortinganalyzer.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/core/tests/test_sortinganalyzer.py b/src/spikeinterface/core/tests/test_sortinganalyzer.py index d780932146..7456680b2a 100644 --- a/src/spikeinterface/core/tests/test_sortinganalyzer.py +++ b/src/spikeinterface/core/tests/test_sortinganalyzer.py @@ -67,9 +67,16 @@ def test_SortingAnalyzer_binary_folder(tmp_path, dataset): sorting_analyzer = create_sorting_analyzer( sorting, recording, format="binary_folder", folder=folder, sparse=False, sparsity=None ) + + sorting_analyzer.compute(["random_spikes", "templates"]) sorting_analyzer = load_sorting_analyzer(folder, format="auto") _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) + # test select_units see https://github.com/SpikeInterface/spikeinterface/issues/3041 + # this bug requires that we have an info.json file so we calculate templates above + select_units_sorting_analyer = sorting_analyzer.select_units(unit_ids=[1]) + assert len(select_units_sorting_analyer.unit_ids) == 1 + folder = tmp_path / "test_SortingAnalyzer_binary_folder" if folder.exists(): shutil.rmtree(folder) @@ -97,9 +104,15 @@ def test_SortingAnalyzer_zarr(tmp_path, dataset): sorting_analyzer = create_sorting_analyzer( sorting, recording, format="zarr", folder=folder, sparse=False, sparsity=None ) + sorting_analyzer.compute(["random_spikes", "templates"]) sorting_analyzer = load_sorting_analyzer(folder, format="auto") _check_sorting_analyzers(sorting_analyzer, sorting, cache_folder=tmp_path) + # test select_units see https://github.com/SpikeInterface/spikeinterface/issues/3041 + # this bug requires that we have an info.json file so we calculate templates above + select_units_sorting_analyer = sorting_analyzer.select_units(unit_ids=[1]) + assert len(select_units_sorting_analyer.unit_ids) == 1 + folder = tmp_path / "test_SortingAnalyzer_zarr.zarr" if folder.exists(): shutil.rmtree(folder) @@ -312,7 +325,7 @@ def test_extensions_sorting(): if __name__ == "__main__": tmp_path = Path("test_SortingAnalyzer") - dataset = _get_dataset() + dataset = get_dataset() test_SortingAnalyzer_memory(tmp_path, dataset) test_SortingAnalyzer_binary_folder(tmp_path, dataset) test_SortingAnalyzer_zarr(tmp_path, dataset) From 8a2c56fa6ca401fca56aa8f41d432c06eb0c62b2 Mon Sep 17 00:00:00 2001 From: Garcia Samuel Date: Thu, 20 Jun 2024 15:12:14 +0200 Subject: [PATCH 165/248] Merci Zach Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- src/spikeinterface/core/sorting_tools.py | 8 ++++---- src/spikeinterface/postprocessing/spike_amplitudes.py | 2 +- src/spikeinterface/postprocessing/spike_locations.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 13f6b28f3c..5ac3fcc822 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -61,11 +61,11 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab List of spike vectors optained with sorting.to_spike_vector(concatenated=False) unit_ids: np.array Unit ids - absolut_index: bool, default False + absolute_index: bool, default False Give spike indices absolut usefull when having a unique spike vector or relative to segment usefull with a list of spike vectors - When a unique spike vectors (or amplitudes) is used then absolut_index should be True. - When a list of spikes (or amplitudes) is used then absolut_index should be False. + When a unique spike vectors (or amplitudes) is used then absolute_index should be True. + When a list of spikes (or amplitudes) is used then absolute_index should be False. Returns ------- @@ -92,7 +92,7 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab total_spikes = 0 for segment_index, spikes in enumerate(spike_vector): indices = np.arange(spikes.size, dtype=np.int64) - if absolut_index: + if absolute_index: indices += total_spikes total_spikes += spikes.size unit_indices = np.array(spikes["unit_index"]).astype(np.int64, copy=False) diff --git a/src/spikeinterface/postprocessing/spike_amplitudes.py b/src/spikeinterface/postprocessing/spike_amplitudes.py index 2a9edf7e73..aebfd1fd78 100644 --- a/src/spikeinterface/postprocessing/spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/spike_amplitudes.py @@ -127,7 +127,7 @@ def _get_data(self, outputs="numpy"): elif outputs == "by_unit": unit_ids = self.sorting_analyzer.unit_ids spike_vector = self.sorting_analyzer.sorting.to_spike_vector(concatenated=False) - spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolut_index=True) + spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolute_index=True) amplitudes_by_units = {} for segment_index in range(self.sorting_analyzer.sorting.get_num_segments()): amplitudes_by_units[segment_index] = {} diff --git a/src/spikeinterface/postprocessing/spike_locations.py b/src/spikeinterface/postprocessing/spike_locations.py index e7a9d7a992..a2dcd4a68a 100644 --- a/src/spikeinterface/postprocessing/spike_locations.py +++ b/src/spikeinterface/postprocessing/spike_locations.py @@ -140,7 +140,7 @@ def _get_data(self, outputs="numpy"): elif outputs == "by_unit": unit_ids = self.sorting_analyzer.unit_ids spike_vector = self.sorting_analyzer.sorting.to_spike_vector(concatenated=False) - spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolut_index=True) + spike_indices = spike_vector_to_indices(spike_vector, unit_ids, absolute_index=True) spike_locations_by_units = {} for segment_index in range(self.sorting_analyzer.sorting.get_num_segments()): spike_locations_by_units[segment_index] = {} From 16dd4c77b419b37f41ffc0795225afff753345ef Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 20 Jun 2024 08:27:53 -0600 Subject: [PATCH 166/248] pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a2e1b3d3a5..58c0f66e44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ dependencies = [ - "numpy>=1.26, <2.0", # 1.20 np.ptp, 1.26 for avoiding pickling errors when numpy >2.0 + "numpy>=1.20, <2.0", # 1.20 np.ptp, 1.26 might be necessary for avoiding pickling errors when numpy >2.0 "threadpoolctl>=3.0.0", "tqdm", "zarr>=2.16,<2.18", From 328bba007391c47a5f08a1f0897119b5ea09bc05 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 20 Jun 2024 08:56:54 -0600 Subject: [PATCH 167/248] Fix intan kwargs (#3054) * add "ignore_integrity_checks" to intan kwargs --- src/spikeinterface/extractors/neoextractors/intan.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/extractors/neoextractors/intan.py b/src/spikeinterface/extractors/neoextractors/intan.py index 7b3816a04d..c37ff47807 100644 --- a/src/spikeinterface/extractors/neoextractors/intan.py +++ b/src/spikeinterface/extractors/neoextractors/intan.py @@ -53,6 +53,8 @@ def __init__( ) self._kwargs.update(dict(file_path=str(Path(file_path).absolute()))) + if "ignore_integrity_checks" in neo_kwargs: + self._kwargs["ignore_integrity_checks"] = neo_kwargs["ignore_integrity_checks"] @classmethod def map_to_neo_kwargs(cls, file_path, ignore_integrity_checks: bool = False): From 617649569e147f8a530d6cfd0c0637857481e367 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 20 Jun 2024 13:14:37 -0600 Subject: [PATCH 168/248] improve error log to json in run_sorter --- src/spikeinterface/sorters/basesorter.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/sorters/basesorter.py b/src/spikeinterface/sorters/basesorter.py index 8c52626703..799444ddbd 100644 --- a/src/spikeinterface/sorters/basesorter.py +++ b/src/spikeinterface/sorters/basesorter.py @@ -262,7 +262,12 @@ def run_from_folder(cls, output_folder, raise_error, verbose): has_error = True run_time = None log["error"] = True - log["error_trace"] = traceback.format_exc() + error_log_to_display = traceback.format_exc() + trace_lines = error_log_to_display.strip().split("\n") + error_to_json = ["Traceback (most recent call last):"] + [ + f" {line}" if not line.startswith(" ") else line for line in trace_lines[1:] + ] + log["error_trace"] = error_to_json log["error"] = has_error log["run_time"] = run_time @@ -290,7 +295,7 @@ def run_from_folder(cls, output_folder, raise_error, verbose): if has_error and raise_error: raise SpikeSortingError( - f"Spike sorting error trace:\n{log['error_trace']}\n" + f"Spike sorting error trace:\n{error_log_to_display}\n" f"Spike sorting failed. You can inspect the runtime trace in {output_folder}/spikeinterface_log.json." ) From 2016de841178030f49a20df2749556479a4aa4ac Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 20 Jun 2024 21:14:52 +0100 Subject: [PATCH 169/248] Remove duplicate function from common test suite. --- .../postprocessing/tests/common_extension_tests.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index c99b2d4f3b..bb2f5aaafd 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -77,19 +77,6 @@ class instance is used for each. In this case, we have to set ) self.__class__.cache_folder = create_cache_folder - def _prepare_sorting_analyzer(self, format, sparse, extension_class): - """ - Prepare a SortingAnalyzer object with dependencies already computed - according to format (e.g. "memory", "binary_folder", "zarr") - and sparsity (e.g. True, False). - """ - sparsity_ = self.sparsity if sparse else None - - sorting_analyzer = self.get_sorting_analyzer( - self.recording, self.sorting, format=format, sparsity=sparsity_, name=extension_class.extension_name - ) - return sorting_analyzer - def get_sorting_analyzer(self, recording, sorting, format="memory", sparsity=None, name=""): sparse = sparsity is not None From 864d1d3237c206226b850409d4ee0bd12d65a32b Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 20 Jun 2024 21:17:35 +0100 Subject: [PATCH 170/248] try and fix access issue by blinding rerunning tests. From 7bdefe5c993678da6f0d618d2958c5c0e2c5e6d6 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Fri, 21 Jun 2024 09:41:29 +0100 Subject: [PATCH 171/248] Force tests again in the vain hope that doing nothing overnight has fixed the issue. From 6abb74b84bc766c801ac366a8678f6cdafb2a06c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 21 Jun 2024 09:37:35 +0000 Subject: [PATCH 172/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/core/sorting_tools.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 5ac3fcc822..65a65875e1 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -47,7 +47,7 @@ def spike_vector_to_spike_trains(spike_vector: list[np.array], unit_ids: np.arra return spike_trains -def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, absolute_index : bool = False): +def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, absolute_index: bool = False): """ Similar to spike_vector_to_spike_trains but instead having the spike_trains (aka spike times) return spike indices by segment and units. @@ -66,7 +66,7 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab or relative to segment usefull with a list of spike vectors When a unique spike vectors (or amplitudes) is used then absolute_index should be True. When a list of spikes (or amplitudes) is used then absolute_index should be False. - + Returns ------- spike_indices: dict[dict]: @@ -88,7 +88,7 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab num_units = unit_ids.size spike_indices = {} - + total_spikes = 0 for segment_index, spikes in enumerate(spike_vector): indices = np.arange(spikes.size, dtype=np.int64) @@ -100,7 +100,6 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab spike_indices[segment_index] = dict(zip(unit_ids, list_of_spike_indices)) - return spike_indices From 34e0e32dd9210245199b2326126f1ae626b41ee2 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 21 Jun 2024 11:48:50 +0200 Subject: [PATCH 173/248] Check start_frame/end_frame in BaseRecording.get_traces() rather than individual segment.get_traces() --- src/spikeinterface/core/baserecording.py | 2 ++ src/spikeinterface/core/frameslicerecording.py | 4 ---- src/spikeinterface/core/segmentutils.py | 5 ----- src/spikeinterface/extractors/cbin_ibl.py | 4 ---- src/spikeinterface/extractors/iblextractors.py | 4 ---- src/spikeinterface/extractors/nwbextractors.py | 5 ----- .../preprocessing/average_across_direction.py | 5 ----- src/spikeinterface/preprocessing/decimate.py | 7 ------- .../preprocessing/deepinterpolation/deepinterpolation.py | 8 -------- .../preprocessing/directional_derivative.py | 5 ----- src/spikeinterface/preprocessing/phase_shift.py | 4 ---- src/spikeinterface/preprocessing/remove_artifacts.py | 5 ----- src/spikeinterface/preprocessing/resample.py | 5 ----- src/spikeinterface/preprocessing/silence_periods.py | 6 ------ src/spikeinterface/preprocessing/zero_channel_pad.py | 9 --------- 15 files changed, 2 insertions(+), 76 deletions(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 184959512b..f16707f31c 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -331,6 +331,8 @@ def get_traces( segment_index = self._check_segment_index(segment_index) channel_indices = self.ids_to_indices(channel_ids, prefer_slice=True) rs = self._recording_segments[segment_index] + start_frame = int(start_frame) if start_frame is not None else 0 + end_frame = int(min(end_frame, rs.get_num_samples())) if end_frame is not None else rs.get_num_samples() traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) if order is not None: assert order in ["C", "F"] diff --git a/src/spikeinterface/core/frameslicerecording.py b/src/spikeinterface/core/frameslicerecording.py index 533328ad42..7831dd61a1 100644 --- a/src/spikeinterface/core/frameslicerecording.py +++ b/src/spikeinterface/core/frameslicerecording.py @@ -86,10 +86,6 @@ def get_num_samples(self): return self.end_frame - self.start_frame def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() parent_start = self.start_frame + start_frame parent_end = self.start_frame + end_frame traces = self._parent_recording_segment.get_traces( diff --git a/src/spikeinterface/core/segmentutils.py b/src/spikeinterface/core/segmentutils.py index 959b7f8c43..b23b7202c6 100644 --- a/src/spikeinterface/core/segmentutils.py +++ b/src/spikeinterface/core/segmentutils.py @@ -163,11 +163,6 @@ def get_num_samples(self): return self.total_length def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - # # Ensures that we won't request invalid segment indices if (start_frame >= self.get_num_samples()) or (end_frame <= start_frame): # Return (0 * num_channels) array of correct dtype diff --git a/src/spikeinterface/extractors/cbin_ibl.py b/src/spikeinterface/extractors/cbin_ibl.py index e5ff8ed371..a6da19408f 100644 --- a/src/spikeinterface/extractors/cbin_ibl.py +++ b/src/spikeinterface/extractors/cbin_ibl.py @@ -134,10 +134,6 @@ def get_num_samples(self): return self._cbuffer.shape[0] def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() if channel_indices is None: channel_indices = slice(None) diff --git a/src/spikeinterface/extractors/iblextractors.py b/src/spikeinterface/extractors/iblextractors.py index 6e3ee59cad..2444314aec 100644 --- a/src/spikeinterface/extractors/iblextractors.py +++ b/src/spikeinterface/extractors/iblextractors.py @@ -269,10 +269,6 @@ def get_num_samples(self): return self._file_streamer.ns def get_traces(self, start_frame: int, end_frame: int, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() if channel_indices is None: channel_indices = slice(None) traces = self._file_streamer.read(nsel=slice(start_frame, end_frame), volts=False) diff --git a/src/spikeinterface/extractors/nwbextractors.py b/src/spikeinterface/extractors/nwbextractors.py index 1f413ae2b0..ccb2ff4370 100644 --- a/src/spikeinterface/extractors/nwbextractors.py +++ b/src/spikeinterface/extractors/nwbextractors.py @@ -932,11 +932,6 @@ def get_num_samples(self): return self._num_samples def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - electrical_series_data = self.electrical_series_data if electrical_series_data.ndim == 1: traces = electrical_series_data[start_frame:end_frame][:, np.newaxis] diff --git a/src/spikeinterface/preprocessing/average_across_direction.py b/src/spikeinterface/preprocessing/average_across_direction.py index 71051f07ab..53f0d54147 100644 --- a/src/spikeinterface/preprocessing/average_across_direction.py +++ b/src/spikeinterface/preprocessing/average_across_direction.py @@ -116,11 +116,6 @@ def get_num_samples(self): return self.parent_recording_segment.get_num_samples() def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - parent_traces = self.parent_recording_segment.get_traces( start_frame=start_frame, end_frame=end_frame, diff --git a/src/spikeinterface/preprocessing/decimate.py b/src/spikeinterface/preprocessing/decimate.py index 8c4970c4e4..aa5c600182 100644 --- a/src/spikeinterface/preprocessing/decimate.py +++ b/src/spikeinterface/preprocessing/decimate.py @@ -123,13 +123,6 @@ def get_num_samples(self): return int(np.ceil((parent_n_samp - self._decimation_offset) / self._decimation_factor)) def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - end_frame = min(end_frame, self.get_num_samples()) - start_frame = min(start_frame, self.get_num_samples()) - # Account for offset and end when querying parent traces parent_start_frame = self._decimation_offset + start_frame * self._decimation_factor parent_end_frame = parent_start_frame + (end_frame - start_frame) * self._decimation_factor diff --git a/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py b/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py index 80b212deda..90dbdba6da 100644 --- a/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py +++ b/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py @@ -148,14 +148,6 @@ def __init__( def get_traces(self, start_frame, end_frame, channel_indices): from .generators import SpikeInterfaceRecordingSegmentGenerator - n_frames = self.parent_recording_segment.get_num_samples() - - if start_frame == None: - start_frame = 0 - - if end_frame == None: - end_frame = n_frames - # for frames that lack full training data (i.e. pre and post frames including omissinos), # just return uninterpolated if start_frame < self.pre_frame + self.pre_post_omission: diff --git a/src/spikeinterface/preprocessing/directional_derivative.py b/src/spikeinterface/preprocessing/directional_derivative.py index 5e77cc8ae6..f8aeac05fc 100644 --- a/src/spikeinterface/preprocessing/directional_derivative.py +++ b/src/spikeinterface/preprocessing/directional_derivative.py @@ -103,11 +103,6 @@ def __init__( self.unique_pos_other_dims, self.column_inds = np.unique(geom_other_dims, axis=0, return_inverse=True) def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - parent_traces = self.parent_recording_segment.get_traces( start_frame=start_frame, end_frame=end_frame, diff --git a/src/spikeinterface/preprocessing/phase_shift.py b/src/spikeinterface/preprocessing/phase_shift.py index ca93d58364..5d483b3ce2 100644 --- a/src/spikeinterface/preprocessing/phase_shift.py +++ b/src/spikeinterface/preprocessing/phase_shift.py @@ -84,10 +84,6 @@ def __init__(self, parent_recording_segment, sample_shifts, margin, dtype, tmp_d self.tmp_dtype = tmp_dtype def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() if channel_indices is None: channel_indices = slice(None) diff --git a/src/spikeinterface/preprocessing/remove_artifacts.py b/src/spikeinterface/preprocessing/remove_artifacts.py index 3c0f766737..d2aef6ba3a 100644 --- a/src/spikeinterface/preprocessing/remove_artifacts.py +++ b/src/spikeinterface/preprocessing/remove_artifacts.py @@ -263,11 +263,6 @@ def get_traces(self, start_frame, end_frame, channel_indices): traces = self.parent_recording_segment.get_traces(start_frame, end_frame, channel_indices) traces = traces.copy() - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - mask = (self.triggers >= start_frame) & (self.triggers < end_frame) triggers = self.triggers[mask] - start_frame labels = self.labels[mask] diff --git a/src/spikeinterface/preprocessing/resample.py b/src/spikeinterface/preprocessing/resample.py index 4843df5444..f8324817d4 100644 --- a/src/spikeinterface/preprocessing/resample.py +++ b/src/spikeinterface/preprocessing/resample.py @@ -115,11 +115,6 @@ def get_num_samples(self): return int(self._parent_segment.get_num_samples() / self._parent_rate * self.sampling_frequency) def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - # get parent traces with margin parent_start_frame, parent_end_frame = [ int((frame / self.sampling_frequency) * self._parent_rate) for frame in [start_frame, end_frame] diff --git a/src/spikeinterface/preprocessing/silence_periods.py b/src/spikeinterface/preprocessing/silence_periods.py index 74d370b3a9..3758d29554 100644 --- a/src/spikeinterface/preprocessing/silence_periods.py +++ b/src/spikeinterface/preprocessing/silence_periods.py @@ -111,12 +111,6 @@ def __init__(self, parent_recording_segment, periods, mode, noise_generator, seg def get_traces(self, start_frame, end_frame, channel_indices): traces = self.parent_recording_segment.get_traces(start_frame, end_frame, channel_indices) traces = traces.copy() - num_channels = traces.shape[1] - - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() if len(self.periods) > 0: new_interval = np.array([start_frame, end_frame]) diff --git a/src/spikeinterface/preprocessing/zero_channel_pad.py b/src/spikeinterface/preprocessing/zero_channel_pad.py index cf4ba6a4a2..0b2ff9449f 100644 --- a/src/spikeinterface/preprocessing/zero_channel_pad.py +++ b/src/spikeinterface/preprocessing/zero_channel_pad.py @@ -75,11 +75,6 @@ def __init__( super().__init__(parent_recording_segment=recording_segment) def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() - # This contains the padded elements by default and we add the original traces if necessary trace_size = end_frame - start_frame if isinstance(channel_indices, (np.ndarray, list)): @@ -200,10 +195,6 @@ def __init__(self, recording_segment: BaseRecordingSegment, num_channels: int, c self.channel_mapping = channel_mapping def get_traces(self, start_frame, end_frame, channel_indices): - if start_frame is None: - start_frame = 0 - if end_frame is None: - end_frame = self.get_num_samples() traces = np.zeros((end_frame - start_frame, self.num_channels)) traces[:, self.channel_mapping] = self.parent_recording_segment.get_traces( start_frame=start_frame, end_frame=end_frame, channel_indices=self.channel_mapping From 750f4cb124f53a2dc7d8cf73bf82160d86a93595 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 21 Jun 2024 11:54:56 +0200 Subject: [PATCH 174/248] A few more --- src/spikeinterface/core/baserecording.py | 2 +- src/spikeinterface/core/generate.py | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index f16707f31c..16f246f280 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -331,7 +331,7 @@ def get_traces( segment_index = self._check_segment_index(segment_index) channel_indices = self.ids_to_indices(channel_ids, prefer_slice=True) rs = self._recording_segments[segment_index] - start_frame = int(start_frame) if start_frame is not None else 0 + start_frame = int(max(0, start_frame)) if start_frame is not None else 0 end_frame = int(min(end_frame, rs.get_num_samples())) if end_frame is not None else rs.get_num_samples() traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) if order is not None: diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index 251678e675..70f3f120c8 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -1143,11 +1143,6 @@ def get_traces( end_frame: Union[int, None] = None, channel_indices: Union[List, None] = None, ) -> np.ndarray: - start_frame = 0 if start_frame is None else max(start_frame, 0) - end_frame = self.num_samples if end_frame is None else min(end_frame, self.num_samples) - start_frame = int(start_frame) - end_frame = int(end_frame) - start_frame_within_block = start_frame % self.noise_block_size end_frame_within_block = end_frame % self.noise_block_size num_samples = end_frame - start_frame @@ -1812,11 +1807,6 @@ def get_traces( end_frame: Union[int, None] = None, channel_indices: Union[List, None] = None, ) -> np.ndarray: - start_frame = 0 if start_frame is None else start_frame - end_frame = self.num_samples if end_frame is None else end_frame - start_frame = int(start_frame) - end_frame = int(end_frame) - if channel_indices is None: n_channels = self.templates.shape[2] elif isinstance(channel_indices, slice): From 04fef83c22ccdf8ddbd01dec648c93d9e72a02e5 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 21 Jun 2024 11:59:13 +0200 Subject: [PATCH 175/248] Fix deepinterpolation --- .../preprocessing/deepinterpolation/deepinterpolation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py b/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py index 90dbdba6da..31ebb90831 100644 --- a/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py +++ b/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py @@ -148,6 +148,8 @@ def __init__( def get_traces(self, start_frame, end_frame, channel_indices): from .generators import SpikeInterfaceRecordingSegmentGenerator + n_frames = self.parent_recording_segment.get_num_samples() + # for frames that lack full training data (i.e. pre and post frames including omissinos), # just return uninterpolated if start_frame < self.pre_frame + self.pre_post_omission: From c1c9f1f1d0d9967e3c08cf3e92aa520d9e9d289b Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 21 Jun 2024 12:03:42 +0200 Subject: [PATCH 176/248] Update src/spikeinterface/core/sorting_tools.py --- src/spikeinterface/core/sorting_tools.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 65a65875e1..6045442466 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -62,10 +62,9 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab unit_ids: np.array Unit ids absolute_index: bool, default False - Give spike indices absolut usefull when having a unique spike vector - or relative to segment usefull with a list of spike vectors - When a unique spike vectors (or amplitudes) is used then absolute_index should be True. - When a list of spikes (or amplitudes) is used then absolute_index should be False. + It True, return absolute spike indices, else spike indices are relative to the segment. + When a unique spike vector is used, then absolute_index should be True. + When a list of spikes per segment is used, then absolute_index should be False. Returns ------- From 391db33a9aad3c49718a415da6208c6d92add7d4 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 21 Jun 2024 12:04:13 +0200 Subject: [PATCH 177/248] Update src/spikeinterface/core/sorting_tools.py --- src/spikeinterface/core/sorting_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py index 6045442466..02f4529a98 100644 --- a/src/spikeinterface/core/sorting_tools.py +++ b/src/spikeinterface/core/sorting_tools.py @@ -62,7 +62,7 @@ def spike_vector_to_indices(spike_vector: list[np.array], unit_ids: np.array, ab unit_ids: np.array Unit ids absolute_index: bool, default False - It True, return absolute spike indices, else spike indices are relative to the segment. + It True, return absolute spike indices. If False, spike indices are relative to the segment. When a unique spike vector is used, then absolute_index should be True. When a list of spikes per segment is used, then absolute_index should be False. From a5926907a783410eb483ec68fef72b2a81bd06f1 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:01:54 -0400 Subject: [PATCH 178/248] fix the probe handling tutorial --- .../core/plot_3_handle_probe_info.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/tutorials/core/plot_3_handle_probe_info.py b/examples/tutorials/core/plot_3_handle_probe_info.py index 157efb683f..50905871bc 100644 --- a/examples/tutorials/core/plot_3_handle_probe_info.py +++ b/examples/tutorials/core/plot_3_handle_probe_info.py @@ -47,16 +47,24 @@ plot_probe(recording_2_shanks.get_probe()) ############################################################################### -# Now let's check what we have loaded. The `group_mode='by_shank'` automatically +# Now let's check what we have loaded. The :code:`group_mode='by_shank'` automatically # sets the 'group' property depending on the shank id. -# We can use this information to split the recording into two sub-recordings: +# We can use this information to split the recording into two sub-recordings. +# We can acccess this information either as a dict with :code:`outputs='dict'` (default) +# or as a list of recordings with :code:`outputs='list'`. print(recording_2_shanks) -print(recording_2_shanks.get_property("group")) +print(f'/nGroup Property: {recording_2_shanks.get_property("group")}/n') -rec0, rec1 = recording_2_shanks.split_by(property="group") -print(rec0) -print(rec1) +# Here we split as a dict +sub_recording_dict = recording_2_shanks.split_by(property="group", outputs='dict') +print(sub_recording_dict, '/n') + +# Then we can pull out the individual sub-recordings +sub_rec0 = sub_recording_dict[0] +sub_rec1 = sub_recording_dict[1] +print(sub_rec0, '/n') +print(sub_rec1) ############################################################################### # Note that some formats (MEArec, SpikeGLX) automatically handle the probe From 8f543534c529c90241bef81238cde9c4e2badd12 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:39:08 -0400 Subject: [PATCH 179/248] fix slashes --- examples/tutorials/core/plot_3_handle_probe_info.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/tutorials/core/plot_3_handle_probe_info.py b/examples/tutorials/core/plot_3_handle_probe_info.py index 50905871bc..99a92b8a0a 100644 --- a/examples/tutorials/core/plot_3_handle_probe_info.py +++ b/examples/tutorials/core/plot_3_handle_probe_info.py @@ -54,16 +54,15 @@ # or as a list of recordings with :code:`outputs='list'`. print(recording_2_shanks) -print(f'/nGroup Property: {recording_2_shanks.get_property("group")}/n') +print(f'\nGroup Property: {recording_2_shanks.get_property("group")}\n') # Here we split as a dict sub_recording_dict = recording_2_shanks.split_by(property="group", outputs='dict') -print(sub_recording_dict, '/n') # Then we can pull out the individual sub-recordings sub_rec0 = sub_recording_dict[0] sub_rec1 = sub_recording_dict[1] -print(sub_rec0, '/n') +print(sub_rec0, '\n') print(sub_rec1) ############################################################################### From a71eff4109c945bcfb1f384c39f6c4127629fa2f Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 21 Jun 2024 18:25:22 -0400 Subject: [PATCH 180/248] add typing --- src/spikeinterface/core/base.py | 6 +++--- src/spikeinterface/core/baserecordingsnippets.py | 8 ++++---- src/spikeinterface/core/basesorting.py | 10 ++++------ src/spikeinterface/core/binaryfolder.py | 2 +- src/spikeinterface/core/binaryrecordingextractor.py | 2 +- src/spikeinterface/core/core_tools.py | 13 ++++++++++--- src/spikeinterface/core/frameslicerecording.py | 2 +- src/spikeinterface/core/generate.py | 2 +- src/spikeinterface/core/numpyextractors.py | 2 +- src/spikeinterface/core/recording_tools.py | 12 +++++++++++- src/spikeinterface/core/sortinganalyzer.py | 2 +- src/spikeinterface/core/waveform_tools.py | 2 +- src/spikeinterface/sorters/basesorter.py | 6 +++--- 13 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/spikeinterface/core/base.py b/src/spikeinterface/core/base.py index 6fbc5ac289..4922707b35 100644 --- a/src/spikeinterface/core/base.py +++ b/src/spikeinterface/core/base.py @@ -550,7 +550,7 @@ def check_serializability(self, type): return False return self._serializability[type] - def check_if_memory_serializable(self): + def check_if_memory_serializable(self) -> bool: """ Check if the object is serializable to memory with pickle, including nested objects. @@ -561,7 +561,7 @@ def check_if_memory_serializable(self): """ return self.check_serializability("memory") - def check_if_json_serializable(self): + def check_if_json_serializable(self) -> bool: """ Check if the object is json serializable, including nested objects. @@ -574,7 +574,7 @@ def check_if_json_serializable(self): # is this needed ??? I think no. return self.check_serializability("json") - def check_if_pickle_serializable(self): + def check_if_pickle_serializable(self) -> bool: # is this needed ??? I think no. return self.check_serializability("pickle") diff --git a/src/spikeinterface/core/baserecordingsnippets.py b/src/spikeinterface/core/baserecordingsnippets.py index 2a9f075954..428472bf93 100644 --- a/src/spikeinterface/core/baserecordingsnippets.py +++ b/src/spikeinterface/core/baserecordingsnippets.py @@ -48,7 +48,7 @@ def get_num_channels(self): def get_dtype(self): return self._dtype - def has_scaleable_traces(self): + def has_scaleable_traces(self) -> bool: if self.get_property("gain_to_uV") is None or self.get_property("offset_to_uV") is None: return False else: @@ -62,10 +62,10 @@ def has_scaled(self): ) return self.has_scaleable_traces() - def has_probe(self): + def has_probe(self) -> bool: return "contact_vector" in self.get_property_keys() - def has_channel_location(self): + def has_channel_location(self) -> bool: return self.has_probe() or "location" in self.get_property_keys() def is_filtered(self): @@ -366,7 +366,7 @@ def get_channel_locations(self, channel_ids=None, axes: str = "xy"): locations = np.asarray(locations)[channel_indices] return select_axes(locations, axes) - def has_3d_locations(self): + def has_3d_locations(self) -> bool: return self.get_property("location").shape[1] == 3 def clear_channel_locations(self, channel_ids=None): diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index 7214d2780e..fd68df9dda 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import List, Optional, Union +from typing import Optional, Union import numpy as np @@ -73,7 +73,7 @@ def unit_ids(self): def sampling_frequency(self): return self._sampling_frequency - def get_unit_ids(self) -> List: + def get_unit_ids(self) -> list: return self._main_ids def get_num_units(self) -> int: @@ -121,7 +121,7 @@ def get_total_samples(self) -> int: s += self.get_num_samples(segment_index) return s - def get_total_duration(self): + def get_total_duration(self) -> float: """Returns the total duration in s of the associated recording. Returns @@ -219,7 +219,7 @@ def set_sorting_info(self, recording_dict, params_dict, log_dict): def has_recording(self): return self._recording is not None - def has_time_vector(self, segment_index=None): + def has_time_vector(self, segment_index=None) -> bool: """ Check if the segment of the registered recording has a time vector. """ @@ -515,8 +515,6 @@ def precompute_spike_trains(self, from_spike_vector=None): """ Pre-computes and caches all spike trains for this sorting - - Parameters ---------- from_spike_vector : None | bool, default: None diff --git a/src/spikeinterface/core/binaryfolder.py b/src/spikeinterface/core/binaryfolder.py index ec9bdfcc5e..546ac85f93 100644 --- a/src/spikeinterface/core/binaryfolder.py +++ b/src/spikeinterface/core/binaryfolder.py @@ -53,7 +53,7 @@ def __init__(self, folder_path): assert "num_chan" in self._bin_kwargs, "Cannot find num_channels or num_chan in binary.json" self._bin_kwargs["num_channels"] = self._bin_kwargs["num_chan"] - def is_binary_compatible(self): + def is_binary_compatible(self) -> bool: return True def get_binary_description(self): diff --git a/src/spikeinterface/core/binaryrecordingextractor.py b/src/spikeinterface/core/binaryrecordingextractor.py index 5d72532704..8fb9a78f2a 100644 --- a/src/spikeinterface/core/binaryrecordingextractor.py +++ b/src/spikeinterface/core/binaryrecordingextractor.py @@ -147,7 +147,7 @@ def write_recording(recording, file_paths, dtype=None, **job_kwargs): """ write_binary_recording(recording, file_paths=file_paths, dtype=dtype, **job_kwargs) - def is_binary_compatible(self): + def is_binary_compatible(self) -> bool: return True def get_binary_description(self): diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 664eac169f..f3d8b3df7f 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -168,9 +168,14 @@ def make_shared_array(shape, dtype): return arr, shm -def is_dict_extractor(d): +def is_dict_extractor(d: dict) -> bool: """ - Check if a dict describe an extractor. + Check if a dict describes an extractor. + + Returns + ------- + is_extractor : bool + Whether the dict describes an extractor """ if not isinstance(d, dict): return False @@ -283,6 +288,7 @@ def check_paths_relative(input_dict, relative_folder) -> bool: Returns ------- relative_possible: bool + Whether the given input can be made relative to the relative_folder """ path_list = _get_paths_list(input_dict) relative_folder = Path(relative_folder).resolve().absolute() @@ -513,7 +519,8 @@ def normal_pdf(x, mu: float = 0.0, sigma: float = 1.0): def retrieve_importing_provenance(a_class): """ - Retrieve the import provenance of a class, including its import name (that consists of the class name and the module), the top-level module, and the module version. + Retrieve the import provenance of a class, including its import name (that consists of the class name and the module), + the top-level module, and the module version. Parameters ---------- diff --git a/src/spikeinterface/core/frameslicerecording.py b/src/spikeinterface/core/frameslicerecording.py index 133cbf886c..5c91d3cae1 100644 --- a/src/spikeinterface/core/frameslicerecording.py +++ b/src/spikeinterface/core/frameslicerecording.py @@ -82,7 +82,7 @@ def __init__(self, parent_recording_segment, start_frame, end_frame): self.start_frame = start_frame self.end_frame = end_frame - def get_num_samples(self): + def get_num_samples(self) -> int: return self.end_frame - self.start_frame def get_traces(self, start_frame, end_frame, channel_indices): diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index 251678e675..370f5b42c6 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -1134,7 +1134,7 @@ def __init__( elif self.strategy == "on_the_fly": pass - def get_num_samples(self): + def get_num_samples(self) -> int: return self.num_samples def get_traces( diff --git a/src/spikeinterface/core/numpyextractors.py b/src/spikeinterface/core/numpyextractors.py index 62cd2fe2cf..0ba1c05417 100644 --- a/src/spikeinterface/core/numpyextractors.py +++ b/src/spikeinterface/core/numpyextractors.py @@ -110,7 +110,7 @@ def __init__(self, traces, sampling_frequency, t_start): self._traces = traces self.num_samples = traces.shape[0] - def get_num_samples(self): + def get_num_samples(self) -> int: return self.num_samples def get_traces(self, start_frame, end_frame, channel_indices): diff --git a/src/spikeinterface/core/recording_tools.py b/src/spikeinterface/core/recording_tools.py index 8b1b293543..b4c07e77c9 100644 --- a/src/spikeinterface/core/recording_tools.py +++ b/src/spikeinterface/core/recording_tools.py @@ -862,7 +862,17 @@ def order_channels_by_depth(recording, channel_ids=None, dimensions=("x", "y"), def check_probe_do_not_overlap(probes): """ When several probes this check that that they do not overlap in space - and so channel positions can be safly concatenated. + and so channel positions can be safely concatenated. + + Raises + ------ + Exception : + If probes are overlapping + + Returns + ------- + None : None + If the check is successful """ for i in range(len(probes)): probe_i = probes[i] diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 0094012013..e439ddf1ed 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -1229,7 +1229,7 @@ def get_computable_extensions(self): """ return get_available_analyzer_extensions() - def get_default_extension_params(self, extension_name: str): + def get_default_extension_params(self, extension_name: str) -> dict: """ Get the default params for an extension. diff --git a/src/spikeinterface/core/waveform_tools.py b/src/spikeinterface/core/waveform_tools.py index acc368b2e5..befc49d034 100644 --- a/src/spikeinterface/core/waveform_tools.py +++ b/src/spikeinterface/core/waveform_tools.py @@ -679,7 +679,7 @@ def split_waveforms_by_units(unit_ids, spikes, all_waveforms, sparsity_mask=None return waveforms_by_units -def has_exceeding_spikes(recording, sorting): +def has_exceeding_spikes(recording, sorting) -> bool: """ Check if the sorting objects has spikes exceeding the recording number of samples, for all segments diff --git a/src/spikeinterface/sorters/basesorter.py b/src/spikeinterface/sorters/basesorter.py index 8c52626703..a9513f9f5a 100644 --- a/src/spikeinterface/sorters/basesorter.py +++ b/src/spikeinterface/sorters/basesorter.py @@ -343,7 +343,7 @@ def get_result_from_folder(cls, output_folder, register_recording=True, sorting_ return sorting @classmethod - def check_compiled(cls): + def check_compiled(cls) -> bool: """ Checks if the sorter is running inside an image with matlab-compiled version @@ -370,7 +370,7 @@ def check_compiled(cls): return True @classmethod - def use_gpu(cls, params): + def use_gpu(cls, params) -> bool: return cls.gpu_capability != "not-supported" ############################################# @@ -436,7 +436,7 @@ def get_job_kwargs(params, verbose): return job_kwargs -def is_log_ok(output_folder): +def is_log_ok(output_folder) -> bool: # log is OK when run_time is not None if (output_folder / "spikeinterface_log.json").is_file(): with open(output_folder / "spikeinterface_log.json", mode="r", encoding="utf8") as logfile: From 048fa788a58580673f030af756be274b859bc65e Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Sat, 22 Jun 2024 15:54:52 +0200 Subject: [PATCH 181/248] Add checks for start/end_frames --- src/spikeinterface/core/baserecording.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 16f246f280..f69d6d25f8 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -331,8 +331,12 @@ def get_traces( segment_index = self._check_segment_index(segment_index) channel_indices = self.ids_to_indices(channel_ids, prefer_slice=True) rs = self._recording_segments[segment_index] - start_frame = int(max(0, start_frame)) if start_frame is not None else 0 + start_frame = int(start_frame) if start_frame is not None else 0 end_frame = int(min(end_frame, rs.get_num_samples())) if end_frame is not None else rs.get_num_samples() + if start_frame < 0: + raise ValueError("start_frame cannot be negative") + if start_frame > end_frame: + raise ValueError("start_frame cannot be greater than end_frame") traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) if order is not None: assert order in ["C", "F"] From a3a27cf217ab767b1531dd7082ad3bfed190ba3d Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Sat, 22 Jun 2024 16:00:34 +0200 Subject: [PATCH 182/248] Fix failing binaryrecordingextractor test --- src/spikeinterface/core/tests/test_binaryrecordingextractor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/tests/test_binaryrecordingextractor.py b/src/spikeinterface/core/tests/test_binaryrecordingextractor.py index 61af8f322d..8ea99e3d04 100644 --- a/src/spikeinterface/core/tests/test_binaryrecordingextractor.py +++ b/src/spikeinterface/core/tests/test_binaryrecordingextractor.py @@ -33,7 +33,7 @@ def test_BinaryRecordingExtractor(create_cache_folder): def test_round_trip(tmp_path): num_channels = 10 - num_samples = 50 + num_samples = 500 traces_list = [np.ones(shape=(num_samples, num_channels), dtype="int32")] sampling_frequency = 30_000.0 recording = NumpyRecording(traces_list=traces_list, sampling_frequency=sampling_frequency) From a18ea3b9d9dc5f5edc2b35c459daa7d573646141 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Sat, 22 Jun 2024 17:03:19 +0200 Subject: [PATCH 183/248] Remove check on start_frame > end_frame --- src/spikeinterface/core/baserecording.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index f69d6d25f8..4d924e9003 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -335,8 +335,6 @@ def get_traces( end_frame = int(min(end_frame, rs.get_num_samples())) if end_frame is not None else rs.get_num_samples() if start_frame < 0: raise ValueError("start_frame cannot be negative") - if start_frame > end_frame: - raise ValueError("start_frame cannot be greater than end_frame") traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) if order is not None: assert order in ["C", "F"] From bc933715b2d2895ee21e3de4064e4c06c30e2f27 Mon Sep 17 00:00:00 2001 From: Zach McKenzie <92116279+zm711@users.noreply.github.com> Date: Sat, 22 Jun 2024 14:48:21 -0400 Subject: [PATCH 184/248] Alessio's fix Co-authored-by: Alessio Buccino --- examples/tutorials/core/plot_3_handle_probe_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/tutorials/core/plot_3_handle_probe_info.py b/examples/tutorials/core/plot_3_handle_probe_info.py index 99a92b8a0a..deff58ebb7 100644 --- a/examples/tutorials/core/plot_3_handle_probe_info.py +++ b/examples/tutorials/core/plot_3_handle_probe_info.py @@ -50,7 +50,7 @@ # Now let's check what we have loaded. The :code:`group_mode='by_shank'` automatically # sets the 'group' property depending on the shank id. # We can use this information to split the recording into two sub-recordings. -# We can acccess this information either as a dict with :code:`outputs='dict'` (default) +# We can access this information either as a dict with :code:`outputs='dict'` (default) # or as a list of recordings with :code:`outputs='list'`. print(recording_2_shanks) From fd6369b8b6493258b4a36d88567f392125e0bf58 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Sat, 22 Jun 2024 17:59:28 -0600 Subject: [PATCH 185/248] use names as channel ids in plexon --- src/spikeinterface/extractors/neoextractors/plexon2.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/extractors/neoextractors/plexon2.py b/src/spikeinterface/extractors/neoextractors/plexon2.py index fe24ba6f46..256c112e6f 100644 --- a/src/spikeinterface/extractors/neoextractors/plexon2.py +++ b/src/spikeinterface/extractors/neoextractors/plexon2.py @@ -30,7 +30,12 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): def __init__(self, file_path, stream_id=None, stream_name=None, all_annotations=False): neo_kwargs = self.map_to_neo_kwargs(file_path) NeoBaseRecordingExtractor.__init__( - self, stream_id=stream_id, stream_name=stream_name, all_annotations=all_annotations, **neo_kwargs + self, + stream_id=stream_id, + stream_name=stream_name, + all_annotations=all_annotations, + use_names_as_ids=True, + **neo_kwargs, ) self._kwargs.update({"file_path": str(file_path)}) From 53b3ec9bdf49c1f93aa6e03a1ecdc55a1ba00a8f Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Sat, 22 Jun 2024 18:27:13 -0600 Subject: [PATCH 186/248] add docstring and propagate arugment to signature --- .../extractors/neoextractors/plexon2.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/extractors/neoextractors/plexon2.py b/src/spikeinterface/extractors/neoextractors/plexon2.py index 256c112e6f..941158def1 100644 --- a/src/spikeinterface/extractors/neoextractors/plexon2.py +++ b/src/spikeinterface/extractors/neoextractors/plexon2.py @@ -19,6 +19,13 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream id you want to load. stream_name : str, default: None If there are several streams, specify the stream name you want to load. + use_names_as_ids: + If True, the names of the signals are used as channel ids. If False, the channel ids are a combination of the + source id and the channel index. + + Example for widegain signals: + names: ["WB01", "WB02", "WB03", "WB04"] + ids: ["source3.1" , "source3.2", "source3.3", "source3.4"] all_annotations : bool, default: False Load exhaustively all annotations from neo. """ @@ -27,14 +34,14 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): NeoRawIOClass = "Plexon2RawIO" name = "plexon2" - def __init__(self, file_path, stream_id=None, stream_name=None, all_annotations=False): + def __init__(self, file_path, stream_id=None, stream_name=None, use_names_as_ids=True, all_annotations=False): neo_kwargs = self.map_to_neo_kwargs(file_path) NeoBaseRecordingExtractor.__init__( self, stream_id=stream_id, stream_name=stream_name, all_annotations=all_annotations, - use_names_as_ids=True, + use_names_as_ids=use_names_as_ids, **neo_kwargs, ) self._kwargs.update({"file_path": str(file_path)}) From f533225c7236b3c3133e657286b64a5435abc032 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Mon, 24 Jun 2024 11:38:50 +0200 Subject: [PATCH 187/248] Add unsigned offset to sinaps extractor, typing, docs, and cleaning --- .../extractors/sinapsrecordingextractor.py | 114 -------- .../extractors/sinapsrecordingextractors.py | 258 ++++++++++++++++++ .../extractors/sinapsrecordingh5extractor.py | 149 ---------- 3 files changed, 258 insertions(+), 263 deletions(-) delete mode 100644 src/spikeinterface/extractors/sinapsrecordingextractor.py create mode 100644 src/spikeinterface/extractors/sinapsrecordingextractors.py delete mode 100644 src/spikeinterface/extractors/sinapsrecordingh5extractor.py diff --git a/src/spikeinterface/extractors/sinapsrecordingextractor.py b/src/spikeinterface/extractors/sinapsrecordingextractor.py deleted file mode 100644 index 1f35407c33..0000000000 --- a/src/spikeinterface/extractors/sinapsrecordingextractor.py +++ /dev/null @@ -1,114 +0,0 @@ -from pathlib import Path -import numpy as np - -from probeinterface import get_probe - -from ..core import BinaryRecordingExtractor, ChannelSliceRecording -from ..core.core_tools import define_function_from_class - - -class SinapsResearchPlatformRecordingExtractor(ChannelSliceRecording): - extractor_name = "SinapsResearchPlatform" - mode = "file" - name = "sinaps_research_platform" - - def __init__(self, file_path, stream_name="filt"): - from ..preprocessing import UnsignedToSignedRecording - - file_path = Path(file_path) - meta_file = file_path.parent / f"metadata_{file_path.stem}.txt" - meta = parse_sinaps_meta(meta_file) - - num_aux_channels = meta["nbHWAux"] + meta["numberUserAUX"] - num_total_channels = 2 * meta["nbElectrodes"] + num_aux_channels - num_electrodes = meta["nbElectrodes"] - sampling_frequency = meta["samplingFreq"] - - probe_type = meta["probeType"] - # channel_locations = meta["electrodePhysicalPosition"] # will be depricated soon by Sam, switching to probeinterface - num_shanks = meta["nbShanks"] - num_electrodes_per_shank = meta["nbElectrodesShank"] - num_bits = int(np.log2(meta["nbADCLevels"])) - - # channel_groups = [] - # for i in range(num_shanks): - # channel_groups.extend([i] * num_electrodes_per_shank) - - gain_ephys = meta["voltageConverter"] - gain_aux = meta["voltageAUXConverter"] - - recording = BinaryRecordingExtractor( - file_path, sampling_frequency, dtype="uint16", num_channels=num_total_channels - ) - recording = UnsignedToSignedRecording(recording, bit_depth=num_bits) - - if stream_name == "raw": - channel_slice = recording.channel_ids[:num_electrodes] - renamed_channels = np.arange(num_electrodes) - # locations = channel_locations - # groups = channel_groups - gain = gain_ephys - elif stream_name == "filt": - channel_slice = recording.channel_ids[num_electrodes : 2 * num_electrodes] - renamed_channels = np.arange(num_electrodes) - # locations = channel_locations - # groups = channel_groups - gain = gain_ephys - elif stream_name == "aux": - channel_slice = recording.channel_ids[2 * num_electrodes :] - hw_chans = meta["hwAUXChannelName"][1:-1].split(",") - user_chans = meta["userAuxName"][1:-1].split(",") - renamed_channels = hw_chans + user_chans - # locations = None - # groups = None - gain = gain_aux - else: - raise ValueError("stream_name must be 'raw', 'filt', or 'aux'") - - ChannelSliceRecording.__init__(self, recording, channel_ids=channel_slice, renamed_channel_ids=renamed_channels) - # if locations is not None: - # self.set_channel_locations(locations) - # if groups is not None: - # self.set_channel_groups(groups) - - self.set_channel_gains(gain) - self.set_channel_offsets(0) - - if (stream_name == "filt") | (stream_name == "raw"): - if probe_type == "p1024s1NHP": - probe = get_probe(manufacturer="sinaps", probe_name="SiNAPS-p1024s1NHP") - # now wire the probe - channel_indices = np.arange(1024) - probe.set_device_channel_indices(channel_indices) - self.set_probe(probe, in_place=True) - else: - raise ValueError(f"Unknown probe type: {probe_type}") - - self._kwargs = {"file_path": str(file_path.absolute())} - - -read_sinaps_research_platform = define_function_from_class( - source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" -) - - -def parse_sinaps_meta(meta_file): - meta_dict = {} - with open(meta_file) as f: - lines = f.readlines() - for l in lines: - if "**" in l or "=" not in l: - continue - else: - key, val = l.split("=") - val = val.replace("\n", "") - try: - val = int(val) - except: - pass - try: - val = eval(val) - except: - pass - meta_dict[key] = val - return meta_dict diff --git a/src/spikeinterface/extractors/sinapsrecordingextractors.py b/src/spikeinterface/extractors/sinapsrecordingextractors.py new file mode 100644 index 0000000000..df86085dc7 --- /dev/null +++ b/src/spikeinterface/extractors/sinapsrecordingextractors.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import warnings +from pathlib import Path +import numpy as np + +from probeinterface import get_probe + +from ..core import BaseRecording, BaseRecordingSegment, BinaryRecordingExtractor, ChannelSliceRecording +from ..core.core_tools import define_function_from_class + + +class SinapsResearchPlatformRecordingExtractor(ChannelSliceRecording): + """ + Recording extractor for the SiNAPS research platform system saved in binary format. + + Parameters + ---------- + file_path : str | Path + Path to the SiNAPS .bin file. + stream_name : "filt" | "raw" | "aux", default: "filt" + The stream name to extract. + "filt" extracts the filtered data, "raw" extracts the raw data, and "aux" extracts the auxiliary data. + """ + + extractor_name = "SinapsResearchPlatform" + mode = "file" + name = "sinaps_research_platform" + + def __init__(self, file_path: str | Path, stream_name: str = "filt"): + from ..preprocessing import UnsignedToSignedRecording + + file_path = Path(file_path) + meta_file = file_path.parent / f"metadata_{file_path.stem}.txt" + meta = parse_sinaps_meta(meta_file) + + num_aux_channels = meta["nbHWAux"] + meta["numberUserAUX"] + num_total_channels = 2 * meta["nbElectrodes"] + num_aux_channels + num_electrodes = meta["nbElectrodes"] + sampling_frequency = meta["samplingFreq"] + + probe_type = meta["probeType"] + num_bits = int(np.log2(meta["nbADCLevels"])) + + gain_ephys = meta["voltageConverter"] + gain_aux = meta["voltageAUXConverter"] + + recording = BinaryRecordingExtractor( + file_path, sampling_frequency, dtype="uint16", num_channels=num_total_channels + ) + recording = UnsignedToSignedRecording(recording, bit_depth=num_bits) + + if stream_name == "raw": + channel_slice = recording.channel_ids[:num_electrodes] + renamed_channels = np.arange(num_electrodes) + gain = gain_ephys + elif stream_name == "filt": + channel_slice = recording.channel_ids[num_electrodes : 2 * num_electrodes] + renamed_channels = np.arange(num_electrodes) + gain = gain_ephys + elif stream_name == "aux": + channel_slice = recording.channel_ids[2 * num_electrodes :] + hw_chans = meta["hwAUXChannelName"][1:-1].split(",") + user_chans = meta["userAuxName"][1:-1].split(",") + renamed_channels = hw_chans + user_chans + gain = gain_aux + else: + raise ValueError("stream_name must be 'raw', 'filt', or 'aux'") + + ChannelSliceRecording.__init__(self, recording, channel_ids=channel_slice, renamed_channel_ids=renamed_channels) + + self.set_channel_gains(gain) + self.set_channel_offsets(0) + num_channels = self.get_num_channels() + + if (stream_name == "filt") | (stream_name == "raw"): + probe = get_sinaps_probe(probe_type, num_channels) + if probe is not None: + self.set_probe(probe, in_place=True) + + self._kwargs = {"file_path": str(file_path.absolute()), "stream_name": stream_name} + + +class SinapsResearchPlatformH5RecordingExtractor(BaseRecording): + """ + Recording extractor for the SiNAPS research platform system saved in HDF5 format. + + Parameters + ---------- + file_path : str | Path + Path to the SiNAPS .h5 file. + """ + + extractor_name = "SinapsResearchPlatformH5" + mode = "file" + name = "sinaps_research_platform_h5" + + def __init__(self, file_path: str | Path): + self._file_path = file_path + + sinaps_info = parse_sinapse_h5(self._file_path) + self._rf = sinaps_info["filehandle"] + + BaseRecording.__init__( + self, + sampling_frequency=sinaps_info["sampling_frequency"], + channel_ids=sinaps_info["channel_ids"], + dtype=sinaps_info["dtype"], + ) + + self.extra_requirements.append("h5py") + + recording_segment = SiNAPSH5RecordingSegment( + self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"] + ) + self.add_recording_segment(recording_segment) + + # set gain + self.set_channel_gains(sinaps_info["gain"]) + self.set_channel_offsets(sinaps_info["offset"]) + self.num_bits = sinaps_info["num_bits"] + num_channels = self.get_num_channels() + + # set probe + probe = get_sinaps_probe(sinaps_info["probe_type"], num_channels) + if probe is not None: + self.set_probe(probe, in_place=True) + + self._kwargs = {"file_path": str(Path(file_path).absolute())} + + def __del__(self): + self._rf.close() + + +class SiNAPSH5RecordingSegment(BaseRecordingSegment): + def __init__(self, rf, num_frames, sampling_frequency, num_bits): + BaseRecordingSegment.__init__(self, sampling_frequency=sampling_frequency) + self._rf = rf + self._num_samples = int(num_frames) + self._num_bits = num_bits + self._stream = self._rf.require_group("RealTimeProcessedData") + + def get_num_samples(self): + return self._num_samples + + def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): + if isinstance(channel_indices, slice): + traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T + else: + # channel_indices is np.ndarray + if np.array(channel_indices).size > 1 and np.any(np.diff(channel_indices) < 0): + # get around h5py constraint that it does not allow datasets + # to be indexed out of order + sorted_channel_indices = np.sort(channel_indices) + resorted_indices = np.array([list(sorted_channel_indices).index(ch) for ch in channel_indices]) + recordings = self._stream.get("FilteredData")[sorted_channel_indices, start_frame:end_frame].T + traces = recordings[:, resorted_indices] + else: + traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T + # convert uint16 to int16 here to simplify extractor + if traces.dtype == "uint16": + dtype_signed = "int16" + # upcast to int with double itemsize + signed_dtype = "int32" + offset = 2 ** (self._num_bits - 1) + traces = traces.astype(signed_dtype, copy=False) - offset + traces = traces.astype(dtype_signed, copy=False) + return traces + + +read_sinaps_research_platform = define_function_from_class( + source_class=SinapsResearchPlatformRecordingExtractor, name="read_sinaps_research_platform" +) + +read_sinaps_research_platform_h5 = define_function_from_class( + source_class=SinapsResearchPlatformH5RecordingExtractor, name="read_sinaps_research_platform_h5" +) + + +############################################## +# HELPER FUNCTIONS +############################################## + + +def get_sinaps_probe(probe_type, num_channels): + try: + probe = get_probe(manufacturer="sinaps", probe_name=f"SiNAPS-{probe_type}") + # now wire the probe + channel_indices = np.arange(num_channels) + probe.set_device_channel_indices(channel_indices) + return probe + except: + warnings.warn(f"Could not load probe information for {probe_type}") + return None + + +def parse_sinaps_meta(meta_file): + meta_dict = {} + with open(meta_file) as f: + lines = f.readlines() + for l in lines: + if "**" in l or "=" not in l: + continue + else: + key, val = l.split("=") + val = val.replace("\n", "") + try: + val = int(val) + except: + pass + try: + val = eval(val) + except: + pass + meta_dict[key] = val + return meta_dict + + +def parse_sinapse_h5(filename): + """Open an SiNAPS hdf5 file, read and return the recording info.""" + + import h5py + + rf = h5py.File(filename, "r") + + stream = rf.require_group("RealTimeProcessedData") + data = stream.get("FilteredData") + dtype = data.dtype + + parameters = rf.require_group("Parameters") + gain = parameters.get("VoltageConverter")[0] + offset = 0 + + nRecCh, nFrames = data.shape + + samplingRate = parameters.get("SamplingFrequency")[0] + + probe_type = str( + rf.require_group("Advanced Recording Parameters").require_group("Probe").get("probeType").asstr()[...] + ) + num_bits = int( + np.log2(rf.require_group("Advanced Recording Parameters").require_group("DAQ").get("nbADCLevels")[0]) + ) + + sinaps_info = { + "filehandle": rf, + "num_frames": nFrames, + "sampling_frequency": samplingRate, + "num_channels": nRecCh, + "channel_ids": np.arange(nRecCh), + "gain": gain, + "offset": offset, + "dtype": dtype, + "probe_type": probe_type, + "num_bits": num_bits, + } + + return sinaps_info diff --git a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py b/src/spikeinterface/extractors/sinapsrecordingh5extractor.py deleted file mode 100644 index dbfcb239fa..0000000000 --- a/src/spikeinterface/extractors/sinapsrecordingh5extractor.py +++ /dev/null @@ -1,149 +0,0 @@ -from pathlib import Path -import numpy as np - -from probeinterface import get_probe - -from ..core.core_tools import define_function_from_class -from ..core import BaseRecording, BaseRecordingSegment -from ..preprocessing import UnsignedToSignedRecording - - -class SinapsResearchPlatformH5RecordingExtractor_Unsigned(BaseRecording): - extractor_name = "SinapsResearchPlatformH5" - mode = "file" - name = "sinaps_research_platform_h5" - - def __init__(self, file_path): - - try: - import h5py - - self.installed = True - except ImportError: - self.installed = False - - assert self.installed, self.installation_mesg - self._file_path = file_path - - sinaps_info = openSiNAPSFile(self._file_path) - self._rf = sinaps_info["filehandle"] - - BaseRecording.__init__( - self, - sampling_frequency=sinaps_info["sampling_frequency"], - channel_ids=sinaps_info["channel_ids"], - dtype=sinaps_info["dtype"], - ) - - self.extra_requirements.append("h5py") - - recording_segment = SiNAPSRecordingSegment( - self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"] - ) - self.add_recording_segment(recording_segment) - - # set gain - self.set_channel_gains(sinaps_info["gain"]) - self.set_channel_offsets(sinaps_info["offset"]) - self.num_bits = sinaps_info["num_bits"] - - # set probe - if sinaps_info["probe_type"] == "p1024s1NHP": - probe = get_probe(manufacturer="sinaps", probe_name="SiNAPS-p1024s1NHP") - probe.set_device_channel_indices(np.arange(1024)) - self.set_probe(probe, in_place=True) - else: - raise ValueError(f"Unknown probe type: {sinaps_info['probe_type']}") - - # set other properties - - self._kwargs = {"file_path": str(Path(file_path).absolute())} - - def __del__(self): - self._rf.close() - - -class SiNAPSRecordingSegment(BaseRecordingSegment): - def __init__(self, rf, num_frames, sampling_frequency): - BaseRecordingSegment.__init__(self, sampling_frequency=sampling_frequency) - self._rf = rf - self._num_samples = int(num_frames) - self._stream = self._rf.require_group("RealTimeProcessedData") - - def get_num_samples(self): - return self._num_samples - - def get_traces(self, start_frame=None, end_frame=None, channel_indices=None): - if isinstance(channel_indices, slice): - traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T - else: - # channel_indices is np.ndarray - if np.array(channel_indices).size > 1 and np.any(np.diff(channel_indices) < 0): - # get around h5py constraint that it does not allow datasets - # to be indexed out of order - sorted_channel_indices = np.sort(channel_indices) - resorted_indices = np.array([list(sorted_channel_indices).index(ch) for ch in channel_indices]) - recordings = self._stream.get("FilteredData")[sorted_channel_indices, start_frame:end_frame].T - traces = recordings[:, resorted_indices] - else: - traces = self._stream.get("FilteredData")[channel_indices, start_frame:end_frame].T - return traces - - -class SinapsResearchPlatformH5RecordingExtractor(UnsignedToSignedRecording): - extractor_name = "SinapsResearchPlatformH5" - mode = "file" - name = "sinaps_research_platform_h5" - - def __init__(self, file_path): - recording = SinapsResearchPlatformH5RecordingExtractor_Unsigned(file_path) - UnsignedToSignedRecording.__init__(self, recording, bit_depth=recording.num_bits) - - self._kwargs = {"file_path": str(Path(file_path).absolute())} - - -read_sinaps_research_platform_h5 = define_function_from_class( - source_class=SinapsResearchPlatformH5RecordingExtractor, name="read_sinaps_research_platform_h5" -) - - -def openSiNAPSFile(filename): - """Open an SiNAPS hdf5 file, read and return the recording info.""" - - import h5py - - rf = h5py.File(filename, "r") - - stream = rf.require_group("RealTimeProcessedData") - data = stream.get("FilteredData") - dtype = data.dtype - - parameters = rf.require_group("Parameters") - gain = parameters.get("VoltageConverter")[0] - offset = 0 - - nRecCh, nFrames = data.shape - - samplingRate = parameters.get("SamplingFrequency")[0] - - probe_type = str( - rf.require_group("Advanced Recording Parameters").require_group("Probe").get("probeType").asstr()[...] - ) - num_bits = int( - np.log2(rf.require_group("Advanced Recording Parameters").require_group("DAQ").get("nbADCLevels")[0]) - ) - - sinaps_info = { - "filehandle": rf, - "num_frames": nFrames, - "sampling_frequency": samplingRate, - "num_channels": nRecCh, - "channel_ids": np.arange(nRecCh), - "gain": gain, - "offset": offset, - "dtype": dtype, - "probe_type": probe_type, - "num_bits": num_bits, - } - - return sinaps_info From c044633d0376f62c217e1b4f8bdf715082c4c6e4 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Mon, 24 Jun 2024 11:41:21 +0200 Subject: [PATCH 188/248] fix extractorlist --- src/spikeinterface/extractors/extractorlist.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/extractors/extractorlist.py b/src/spikeinterface/extractors/extractorlist.py index b226a2d838..8948aad606 100644 --- a/src/spikeinterface/extractors/extractorlist.py +++ b/src/spikeinterface/extractors/extractorlist.py @@ -45,8 +45,12 @@ from .herdingspikesextractors import HerdingspikesSortingExtractor, read_herdingspikes from .mdaextractors import MdaRecordingExtractor, MdaSortingExtractor, read_mda_recording, read_mda_sorting from .phykilosortextractors import PhySortingExtractor, KiloSortSortingExtractor, read_phy, read_kilosort -from .sinapsrecordingextractor import SinapsResearchPlatformRecordingExtractor, read_sinaps_research_platform -from .sinapsrecordingh5extractor import SinapsResearchPlatformH5RecordingExtractor, read_sinaps_research_platform_h5 +from .sinapsrecordingextractors import ( + SinapsResearchPlatformRecordingExtractor, + SinapsResearchPlatformH5RecordingExtractor, + read_sinaps_research_platform, + read_sinaps_research_platform_h5, +) # sorting in relation with simulator from .shybridextractors import ( From 465be4286fc65a9f6fe70703168ff973e7f7581d Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 24 Jun 2024 11:02:49 +0100 Subject: [PATCH 189/248] Fix a missing argument (num_bits) --- src/spikeinterface/extractors/sinapsrecordingextractors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractors.py b/src/spikeinterface/extractors/sinapsrecordingextractors.py index df86085dc7..1642fcf351 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractors.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractors.py @@ -111,7 +111,8 @@ def __init__(self, file_path: str | Path): self.extra_requirements.append("h5py") recording_segment = SiNAPSH5RecordingSegment( - self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"] + self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"], + num_bits = sinaps_info["num_bits"] ) self.add_recording_segment(recording_segment) From bfb42c5cc074793e6a297931a44c4bf772505931 Mon Sep 17 00:00:00 2001 From: Nina Kudryashova Date: Mon, 24 Jun 2024 11:09:33 +0100 Subject: [PATCH 190/248] Run black --- src/spikeinterface/extractors/sinapsrecordingextractors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/extractors/sinapsrecordingextractors.py b/src/spikeinterface/extractors/sinapsrecordingextractors.py index 1642fcf351..522f639760 100644 --- a/src/spikeinterface/extractors/sinapsrecordingextractors.py +++ b/src/spikeinterface/extractors/sinapsrecordingextractors.py @@ -111,8 +111,10 @@ def __init__(self, file_path: str | Path): self.extra_requirements.append("h5py") recording_segment = SiNAPSH5RecordingSegment( - self._rf, sinaps_info["num_frames"], sampling_frequency=sinaps_info["sampling_frequency"], - num_bits = sinaps_info["num_bits"] + self._rf, + sinaps_info["num_frames"], + sampling_frequency=sinaps_info["sampling_frequency"], + num_bits=sinaps_info["num_bits"], ) self.add_recording_segment(recording_segment) From d1e2d866610ccfc2932f6a3e45eb21fd2d353b6b Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Mon, 24 Jun 2024 12:49:39 +0200 Subject: [PATCH 191/248] Update src/spikeinterface/core/baserecording.py --- src/spikeinterface/core/baserecording.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 4d924e9003..71eecc15bc 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -332,7 +332,8 @@ def get_traces( channel_indices = self.ids_to_indices(channel_ids, prefer_slice=True) rs = self._recording_segments[segment_index] start_frame = int(start_frame) if start_frame is not None else 0 - end_frame = int(min(end_frame, rs.get_num_samples())) if end_frame is not None else rs.get_num_samples() + num_samples = rs.get_num_samples() + end_frame = int(min(end_frame, num_samples)) if end_frame is not None else num_samples if start_frame < 0: raise ValueError("start_frame cannot be negative") traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) From 5c78a567c54288716f75956ba5885789ca8b36f6 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:04:28 +0100 Subject: [PATCH 192/248] Update doc and docstrings for template_metric units --- doc/modules/core.rst | 2 +- doc/modules/postprocessing.rst | 12 ++++++-- .../postprocessing/template_metrics.py | 29 +++++++++++++------ 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/doc/modules/core.rst b/doc/modules/core.rst index 239e42bc3c..73d2217453 100644 --- a/doc/modules/core.rst +++ b/doc/modules/core.rst @@ -21,7 +21,7 @@ All classes support: * data on-demand (lazy loading) * multiple segments, where each segment is a contiguous piece of data (recording, sorting, events). - +.. _core-recording: Recording --------- diff --git a/doc/modules/postprocessing.rst b/doc/modules/postprocessing.rst index 6465e4af48..00ddefe979 100644 --- a/doc/modules/postprocessing.rst +++ b/doc/modules/postprocessing.rst @@ -304,11 +304,19 @@ By default, the following metrics are computed: * "peak_to_valley": duration between negative and positive peaks * "halfwidth": duration in s at 50% of the amplitude * "peak_to_trough_ratio": ratio between negative and positive peaks -* "recovery_slope": speed in V/s to recover from the negative peak to 0 -* "repolarization_slope": speed in V/s to repolarize from the positive peak to 0 +* "recovery_slope": speed to recover from the negative peak to 0 +* "repolarization_slope": speed to repolarize from the positive peak to 0 * "num_positive_peaks": the number of positive peaks * "num_negative_peaks": the number of negative peaks +The units of the results depend on the input. Voltages are based on the units of the +template, usually :math:`\mu V` (this depends on the :code:`return_scaled` +parameter, read more here: :ref:`core-recording`). Distances are based on the unit of the +underlying recording's probe's :code:`channel_locations`, usually :math:`\mu m`. +Times are always in seconds. E.g. if the templates are in units of :math:`mV` and channel +locations in :math:`\mu m` then: :code:`repolarization_slope` is in :math:`mV / s`; +:code:`peak_to_trough_ratio` is in :math:`\mu m` and the :code:`halfwidth` is in :math:`s`. + Optionally, the following multi-channel metrics can be computed by setting: :code:`include_multi_channel_metrics=True` diff --git a/src/spikeinterface/postprocessing/template_metrics.py b/src/spikeinterface/postprocessing/template_metrics.py index d7179ffefa..35d954389a 100644 --- a/src/spikeinterface/postprocessing/template_metrics.py +++ b/src/spikeinterface/postprocessing/template_metrics.py @@ -410,7 +410,8 @@ def get_repolarization_slope(template_single, sampling_frequency, trough_idx=Non After reaching it's maximum polarization, the neuron potential will recover. The repolarization slope is defined as the dV/dT of the action potential - between trough and baseline. + between trough and baseline. The returned slope is in units of (unit of template) + per second. Parameters ---------- @@ -454,12 +455,10 @@ def get_recovery_slope(template_single, sampling_frequency, peak_idx=None, **kwa Return the recovery slope of input waveforms. After repolarization, the neuron hyperpolarizes until it peaks. The recovery slope is the slope of the action potential after the peak, returning to the baseline - in dV/dT. The slope is computed within a user-defined window after + in dV/dT. The returned slope is in units of (unit of template) + per second. The slope is computed within a user-defined window after the peak. - Takes a numpy array of waveforms and returns an array with - recovery slopes per waveform. - Parameters ---------- template_single: numpy.ndarray @@ -619,7 +618,7 @@ def fit_velocity(peak_times, channel_dist): def get_velocity_above(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the velocity above the max channel of the template. + Compute the velocity above the max channel of the template in units (unit of channel locations) per second, usually um/s. Parameters ---------- @@ -697,7 +696,7 @@ def get_velocity_above(template, channel_locations, sampling_frequency, **kwargs def get_velocity_below(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the velocity below the max channel of the template. + Compute the velocity below the max channel of the template in units (unit of channel locations) per second, usually um/s. Parameters ---------- @@ -775,7 +774,8 @@ def get_velocity_below(template, channel_locations, sampling_frequency, **kwargs def get_exp_decay(template, channel_locations, sampling_frequency=None, **kwargs): """ - Compute the exponential decay of the template amplitude over distance. + Compute the exponential decay of the template amplitude over distance. The returned value + is in the same units as `channel_locations`, usually um. Parameters ---------- @@ -788,6 +788,11 @@ def get_exp_decay(template, channel_locations, sampling_frequency=None, **kwargs **kwargs: Required kwargs: - exp_peak_function: the function to use to compute the peak amplitude for the exp decay ("ptp" or "min") - min_r2_exp_decay: the minimum r2 to accept the exp decay fit + + Returns + ------- + exp_decay_value : float + The exponential decay of the template amplitude """ from scipy.optimize import curve_fit from sklearn.metrics import r2_score @@ -853,7 +858,8 @@ def exp_decay(x, decay, amp0, offset): def get_spread(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the spread of the template amplitude over distance. + Compute the spread of the template amplitude over distance. The returned value + is in the same units as `channel_locations`, usually um. Parameters ---------- @@ -867,6 +873,11 @@ def get_spread(template, channel_locations, sampling_frequency, **kwargs): - depth_direction: the direction to compute velocity above and below ("x", "y", or "z") - spread_threshold: the threshold to compute the spread - column_range: the range in um in the x-direction to consider channels for velocity + + Returns + ------- + spread : float + Spread of the template amplitude """ assert "depth_direction" in kwargs, "depth_direction must be given as kwarg" depth_direction = kwargs["depth_direction"] From a96fdbbe7f3f4ab0410118c4ca7e2b2464f03ff2 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Mon, 24 Jun 2024 14:41:25 +0100 Subject: [PATCH 193/248] Respond to reviews --- doc/modules/core.rst | 7 +++-- doc/modules/postprocessing.rst | 27 ++++++++++--------- .../postprocessing/template_metrics.py | 19 ++++++------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/doc/modules/core.rst b/doc/modules/core.rst index 73d2217453..5c0713fa21 100644 --- a/doc/modules/core.rst +++ b/doc/modules/core.rst @@ -162,7 +162,7 @@ Internally, any sorting object can construct 2 internal caches: 2. a unique numpy.array with structured dtype aka "spikes vector". This is useful for processing by small chunks of time, like for extracting amplitudes from a recording. - +.. _core-sorting-analyzer: SortingAnalyzer --------------- @@ -179,9 +179,8 @@ to perform further analysis, such as calculating :code:`waveforms` and :code:`te Importantly, the :py:class:`~spikeinterface.core.SortingAnalyzer` handles the *sparsity* and the physical *scaling*. Sparsity defines the channels on which waveforms and templates are calculated using, for example, a physical distance from the channel with the largest peak amplitude (see the :ref:`Sparsity` section). Scaling, set by -the :code:`return_scaled` argument, says whether the data has been converted from integer values to physical units such as -Voltage (see the end of the :ref:`Recording` section). - +the :code:`return_scaled` argument, determines whether the data is converted from integer values to :math:`\mu V` or not. +By default, it is converted and all traces have units of :math:`\mu V`. Now we will create a :code:`SortingAnalyzer` called :code:`sorting_analyzer`. diff --git a/doc/modules/postprocessing.rst b/doc/modules/postprocessing.rst index 00ddefe979..9aad8568f4 100644 --- a/doc/modules/postprocessing.rst +++ b/doc/modules/postprocessing.rst @@ -301,29 +301,30 @@ template_metrics This extension computes commonly used waveform/template metrics. By default, the following metrics are computed: -* "peak_to_valley": duration between negative and positive peaks -* "halfwidth": duration in s at 50% of the amplitude +* "peak_to_valley": duration in :math:`s` between negative and positive peaks +* "halfwidth": duration in :math:`s` at 50% of the amplitude * "peak_to_trough_ratio": ratio between negative and positive peaks * "recovery_slope": speed to recover from the negative peak to 0 * "repolarization_slope": speed to repolarize from the positive peak to 0 * "num_positive_peaks": the number of positive peaks * "num_negative_peaks": the number of negative peaks -The units of the results depend on the input. Voltages are based on the units of the -template, usually :math:`\mu V` (this depends on the :code:`return_scaled` -parameter, read more here: :ref:`core-recording`). Distances are based on the unit of the -underlying recording's probe's :code:`channel_locations`, usually :math:`\mu m`. -Times are always in seconds. E.g. if the templates are in units of :math:`mV` and channel -locations in :math:`\mu m` then: :code:`repolarization_slope` is in :math:`mV / s`; -:code:`peak_to_trough_ratio` is in :math:`\mu m` and the :code:`halfwidth` is in :math:`s`. +The units of :code:`recovery_slope` and :code:`repolarization_slope` depend on the +input. Voltages are based on the units of the template. By default this is :math:`\mu V` +but can be the raw output from the recording device (this depends on the +:code:`return_scaled` parameter, read more here: :ref:`core-sorting-analyzer`). +Distances are in :math:`\mu m` and times are in seconds. So, for example, if the +templates are in units of :math:`\mu V` then: :code:`repolarization_slope` is in +:math:`mV / s`; :code:`peak_to_trough_ratio` is in :math:`\mu m` and the +:code:`halfwidth` is in :math:`s`. Optionally, the following multi-channel metrics can be computed by setting: :code:`include_multi_channel_metrics=True` -* "velocity_above": the velocity above the max channel of the template -* "velocity_below": the velocity below the max channel of the template -* "exp_decay": the exponential decay of the template amplitude over distance -* "spread": the spread of the template amplitude over distance +* "velocity_above": the velocity in :math:`\mu m/s` above the max channel of the template +* "velocity_below": the velocity in :math:`\mu m/s` below the max channel of the template +* "exp_decay": the exponential decay in :math:`\mu m` of the template amplitude over distance +* "spread": the spread in :math:`\mu m` of the template amplitude over distance .. figure:: ../images/1d_waveform_features.png diff --git a/src/spikeinterface/postprocessing/template_metrics.py b/src/spikeinterface/postprocessing/template_metrics.py index 35d954389a..fdc4ef4719 100644 --- a/src/spikeinterface/postprocessing/template_metrics.py +++ b/src/spikeinterface/postprocessing/template_metrics.py @@ -411,7 +411,9 @@ def get_repolarization_slope(template_single, sampling_frequency, trough_idx=Non After reaching it's maximum polarization, the neuron potential will recover. The repolarization slope is defined as the dV/dT of the action potential between trough and baseline. The returned slope is in units of (unit of template) - per second. + per second. By default traces are scaled to units of uV, controlled + by `sorting_analyzer.return_scaled`. In this case this function returns the slope + in uV/s. Parameters ---------- @@ -456,8 +458,9 @@ def get_recovery_slope(template_single, sampling_frequency, peak_idx=None, **kwa the neuron hyperpolarizes until it peaks. The recovery slope is the slope of the action potential after the peak, returning to the baseline in dV/dT. The returned slope is in units of (unit of template) - per second. The slope is computed within a user-defined window after - the peak. + per second. By default traces are scaled to units of uV, controlled + by `sorting_analyzer.return_scaled`. In this case this function returns the slope + in uV/s. The slope is computed within a user-defined window after the peak. Parameters ---------- @@ -618,7 +621,7 @@ def fit_velocity(peak_times, channel_dist): def get_velocity_above(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the velocity above the max channel of the template in units (unit of channel locations) per second, usually um/s. + Compute the velocity above the max channel of the template in units um/s. Parameters ---------- @@ -696,7 +699,7 @@ def get_velocity_above(template, channel_locations, sampling_frequency, **kwargs def get_velocity_below(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the velocity below the max channel of the template in units (unit of channel locations) per second, usually um/s. + Compute the velocity below the max channel of the template in units um/s. Parameters ---------- @@ -774,8 +777,7 @@ def get_velocity_below(template, channel_locations, sampling_frequency, **kwargs def get_exp_decay(template, channel_locations, sampling_frequency=None, **kwargs): """ - Compute the exponential decay of the template amplitude over distance. The returned value - is in the same units as `channel_locations`, usually um. + Compute the exponential decay of the template amplitude over distance in units um/s. Parameters ---------- @@ -858,8 +860,7 @@ def exp_decay(x, decay, amp0, offset): def get_spread(template, channel_locations, sampling_frequency, **kwargs): """ - Compute the spread of the template amplitude over distance. The returned value - is in the same units as `channel_locations`, usually um. + Compute the spread of the template amplitude over distance in units um/s. Parameters ---------- From 227d91d02bc5c1b80dff106c3606632d8604c05c Mon Sep 17 00:00:00 2001 From: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Mon, 24 Jun 2024 15:08:55 +0100 Subject: [PATCH 194/248] Update doc/modules/core.rst Co-authored-by: Alessio Buccino --- doc/modules/core.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/modules/core.rst b/doc/modules/core.rst index 5c0713fa21..f8f410018b 100644 --- a/doc/modules/core.rst +++ b/doc/modules/core.rst @@ -180,7 +180,7 @@ Importantly, the :py:class:`~spikeinterface.core.SortingAnalyzer` handles the *s Sparsity defines the channels on which waveforms and templates are calculated using, for example, a physical distance from the channel with the largest peak amplitude (see the :ref:`Sparsity` section). Scaling, set by the :code:`return_scaled` argument, determines whether the data is converted from integer values to :math:`\mu V` or not. -By default, it is converted and all traces have units of :math:`\mu V`. +By default, :code:`return_scaled` is true and all processed data voltage values are in :math:`\mu V` (e.g., waveforms, templates, spike amplitudes, etc.). Now we will create a :code:`SortingAnalyzer` called :code:`sorting_analyzer`. From 99565a321c888a5bfe69cacc2466d0b651c37fb0 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Mon, 24 Jun 2024 17:10:47 +0200 Subject: [PATCH 195/248] Update src/spikeinterface/core/baserecording.py --- src/spikeinterface/core/baserecording.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spikeinterface/core/baserecording.py b/src/spikeinterface/core/baserecording.py index 71eecc15bc..ede59c3e66 100644 --- a/src/spikeinterface/core/baserecording.py +++ b/src/spikeinterface/core/baserecording.py @@ -334,8 +334,6 @@ def get_traces( start_frame = int(start_frame) if start_frame is not None else 0 num_samples = rs.get_num_samples() end_frame = int(min(end_frame, num_samples)) if end_frame is not None else num_samples - if start_frame < 0: - raise ValueError("start_frame cannot be negative") traces = rs.get_traces(start_frame=start_frame, end_frame=end_frame, channel_indices=channel_indices) if order is not None: assert order in ["C", "F"] From fc3e6331eb3284e592808e238ab6954cb394154f Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Mon, 24 Jun 2024 17:49:48 +0200 Subject: [PATCH 196/248] Add plot_drift_map --- src/spikeinterface/widgets/driftmap.py | 143 ++++++++++++++++++++++ src/spikeinterface/widgets/motion.py | 84 +++++-------- src/spikeinterface/widgets/widget_list.py | 3 + 3 files changed, 179 insertions(+), 51 deletions(-) create mode 100644 src/spikeinterface/widgets/driftmap.py diff --git a/src/spikeinterface/widgets/driftmap.py b/src/spikeinterface/widgets/driftmap.py new file mode 100644 index 0000000000..60e8df2972 --- /dev/null +++ b/src/spikeinterface/widgets/driftmap.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import numpy as np + +from .base import BaseWidget, to_attr + + +class DriftMapWidget(BaseWidget): + """ + Plot the a drift map from a motion info dictionary. + + Parameters + ---------- + peaks : np.array + The peaks array, with dtype ("sample_index", "channel_index", "amplitude", "segment_index") + peak_locations : np.array + The peak locations, with dtype ("x", "y") or ("x", "y", "z") + direction : "x" or "y", default: "y" + The direction to display + segment_index : int, default: None + The segment index to display. + recording : RecordingExtractor, default: None + The recording extractor object (only used to get "real" times) + segment_index : int, default: 0 + The segment index to display. + sampling_frequency : float, default: None + The sampling frequency (needed if recording is None) + depth_lim : tuple or None, default: None + The min and max depth to display, if None (min and max of the recording) + color_amplitude : bool, default: True + If True, the color of the scatter points is the amplitude of the peaks + scatter_decimate : int, default: None + If > 1, the scatter points are decimated + cmap : str, default: "inferno" + The colormap to use for the amplitude + clim : tuple or None, default: None + The min and max amplitude to display, if None (min and max of the amplitudes) + alpha : float, default: 1 + The alpha of the scatter points + """ + + def __init__( + self, + peaks, + peak_locations, + direction="y", + recording=None, + sampling_frequency=None, + segment_index=None, + depth_lim=None, + color_amplitude=True, + scatter_decimate=None, + cmap="inferno", + clim=None, + alpha=1, + backend=None, + **backend_kwargs, + ): + if segment_index is None: + assert ( + len(np.unique(peaks["segment_index"])) == 1 + ), "segment_index must be specified if there is only one segment in the peaks array" + assert recording or sampling_frequency, "recording or sampling_frequency must be specified" + if recording is not None: + sampling_frequency = recording.sampling_frequency + times = recording.get_times(segment_index=segment_index) + else: + times = None + + plot_data = dict( + peaks=peaks, + peak_locations=peak_locations, + direction=direction, + times=times, + sampling_frequency=sampling_frequency, + segment_index=segment_index, + depth_lim=depth_lim, + color_amplitude=color_amplitude, + scatter_decimate=scatter_decimate, + cmap=cmap, + clim=clim, + alpha=alpha, + recording=recording, + ) + BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) + + def plot_matplotlib(self, data_plot, **backend_kwargs): + import matplotlib.pyplot as plt + from .utils_matplotlib import make_mpl_figure + from matplotlib.colors import Normalize + + from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks + + dp = to_attr(data_plot) + + assert backend_kwargs["axes"] is None, "axes argument is not allowed in MotionWidget" + + self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) + fig = self.figure + + if dp.times is None: + # temporal_bins_plot = dp.temporal_bins + x = dp.peaks["sample_index"] / dp.sampling_frequency + else: + # use real times and adjust temporal bins with t_start + # temporal_bins_plot = dp.temporal_bins + dp.times[0] + x = dp.times[dp.peaks["sample_index"]] + + y = dp.peak_locations[dp.direction] + if dp.scatter_decimate is not None: + x = x[:: dp.scatter_decimate] + y = y[:: dp.scatter_decimate] + y2 = y2[:: dp.scatter_decimate] + + if dp.color_amplitude: + amps = dp.peaks["amplitude"] + amps_abs = np.abs(amps) + q_95 = np.quantile(amps_abs, 0.95) + if dp.scatter_decimate is not None: + amps = amps[:: dp.scatter_decimate] + amps_abs = amps_abs[:: dp.scatter_decimate] + cmap = plt.colormaps[dp.cmap] + if dp.clim is None: + amps = amps_abs + amps /= q_95 + c = cmap(amps) + else: + norm_function = Normalize(vmin=dp.clim[0], vmax=dp.clim[1], clip=True) + c = cmap(norm_function(amps)) + color_kwargs = dict( + color=None, + c=c, + alpha=dp.alpha, + ) + else: + color_kwargs = dict(color="k", c=None, alpha=dp.alpha) + + self.ax.scatter(x, y, s=1, **color_kwargs) + if dp.depth_lim is not None: + self.ax.set_ylim(*dp.depth_lim) + self.ax.set_title("Peak depth") + self.ax.set_xlabel("Times [s]") + self.ax.set_ylabel("Depth [$\\mu$m]") diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index fc0c91423d..7d733523df 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -3,6 +3,7 @@ import numpy as np from .base import BaseWidget, to_attr +from .driftmap import DriftMapWidget class MotionWidget(BaseWidget): @@ -107,7 +108,7 @@ class MotionInfoWidget(BaseWidget): Parameters ---------- motion_info : dict - The motion info return by correct_motion() or load back with load_motion_info() + The motion info returned by correct_motion() or loaded back with load_motion_info() segment_index : int, default: None The segment index to display. recording : RecordingExtractor, default: None @@ -153,7 +154,9 @@ def __init__( if len(motion.displacement) == 1: segment_index = 0 else: - raise ValueError("plot motion : teh Motion object is multi segment you must provide segmentindex=XX") + raise ValueError( + "plot drift map : the Motion object is multi-segment you must provide segment_index=XX" + ) times = recording.get_times() if recording is not None else None @@ -214,14 +217,6 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax1.sharex(ax0) ax1.sharey(ax0) - if dp.times is None: - # temporal_bins_plot = dp.temporal_bins - x = dp.peaks["sample_index"] / dp.sampling_frequency - else: - # use real times and adjust temporal bins with t_start - # temporal_bins_plot = dp.temporal_bins + dp.times[0] - x = dp.times[dp.peaks["sample_index"]] - corrected_location = correct_motion_on_peaks( dp.peaks, dp.peak_locations, @@ -229,47 +224,34 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): dp.recording, ) - y = dp.peak_locations[motion.direction] - y2 = corrected_location[motion.direction] - if dp.scatter_decimate is not None: - x = x[:: dp.scatter_decimate] - y = y[:: dp.scatter_decimate] - y2 = y2[:: dp.scatter_decimate] - - if dp.color_amplitude: - amps = dp.peaks["amplitude"] - amps_abs = np.abs(amps) - q_95 = np.quantile(amps_abs, 0.95) - if dp.scatter_decimate is not None: - amps = amps[:: dp.scatter_decimate] - amps_abs = amps_abs[:: dp.scatter_decimate] - cmap = plt.colormaps[dp.amplitude_cmap] - if dp.amplitude_clim is None: - amps = amps_abs - amps /= q_95 - c = cmap(amps) - else: - norm_function = Normalize(vmin=dp.amplitude_clim[0], vmax=dp.amplitude_clim[1], clip=True) - c = cmap(norm_function(amps)) - color_kwargs = dict( - color=None, - c=c, - alpha=dp.amplitude_alpha, - ) - else: - color_kwargs = dict(color="k", c=None, alpha=dp.amplitude_alpha) - - ax0.scatter(x, y, s=1, **color_kwargs) - if dp.depth_lim is not None: - ax0.set_ylim(*dp.depth_lim) - ax0.set_title("Peak depth") - ax0.set_xlabel("Times [s]") - ax0.set_ylabel("Depth [$\\mu$m]") - - ax1.scatter(x, y2, s=1, **color_kwargs) - ax1.set_xlabel("Times [s]") - ax1.set_ylabel("Depth [$\\mu$m]") - ax1.set_title("Corrected peak depth") + commpon_drift_map_kwargs = dict( + direction=dp.motion.direction, + recording=dp.recording, + segment_index=dp.segment_index, + depth_lim=dp.depth_lim, + color_amplitude=dp.color_amplitude, + scatter_decimate=dp.scatter_decimate, + cmap=dp.amplitude_cmap, + clim=dp.amplitude_clim, + alpha=dp.amplitude_alpha, + backend="matplotlib", + ) + + drift_map = DriftMapWidget( + dp.peaks, + dp.peak_locations, + ax=ax0, + immediate_plot=True, + **commpon_drift_map_kwargs, + ) + + drift_map_corrected = DriftMapWidget( + dp.peaks, + corrected_location, + ax=ax1, + immediate_plot=True, + **commpon_drift_map_kwargs, + ) ax2.plot(temporal_bins_s, displacement, alpha=0.2, color="black") ax2.plot(temporal_bins_s, np.mean(displacement, axis=1), color="C0") diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index 6367e098ea..8d4accaa7e 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -9,6 +9,7 @@ from .amplitudes import AmplitudesWidget from .autocorrelograms import AutoCorrelogramsWidget from .crosscorrelograms import CrossCorrelogramsWidget +from .driftmap import DriftMapWidget from .isi_distribution import ISIDistributionWidget from .motion import MotionWidget, MotionInfoWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget @@ -44,6 +45,7 @@ ConfusionMatrixWidget, ComparisonCollisionBySimilarityWidget, CrossCorrelogramsWidget, + DriftMapWidget, ISIDistributionWidget, MotionWidget, MotionInfoWidget, @@ -118,6 +120,7 @@ plot_confusion_matrix = ConfusionMatrixWidget plot_comparison_collision_by_similarity = ComparisonCollisionBySimilarityWidget plot_crosscorrelograms = CrossCorrelogramsWidget +plot_drift_map = DriftMapWidget plot_isi_distribution = ISIDistributionWidget plot_motion = MotionWidget plot_motion_info = MotionInfoWidget From baf1287215e41b020dc97b5d6428dbdc5446ef76 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Tue, 25 Jun 2024 11:20:06 +0100 Subject: [PATCH 197/248] Fix docstrings for extractors module --- doc/api.rst | 2 +- doc/modules/extractors.rst | 2 +- src/spikeinterface/extractors/cbin_ibl.py | 4 +++- .../extractors/herdingspikesextractors.py | 2 +- src/spikeinterface/extractors/iblextractors.py | 2 +- .../extractors/neoextractors/alphaomega.py | 5 +++++ .../extractors/neoextractors/biocam.py | 1 - .../extractors/neoextractors/blackrock.py | 3 ++- .../extractors/neoextractors/ced.py | 2 -- .../extractors/neoextractors/intan.py | 2 ++ .../extractors/neoextractors/maxwell.py | 2 ++ .../extractors/neoextractors/neuralynx.py | 11 ++++++----- .../extractors/neoextractors/plexon2.py | 2 +- .../extractors/neoextractors/spikegadgets.py | 2 +- .../extractors/neoextractors/tdt.py | 2 ++ src/spikeinterface/extractors/toy_example.py | 10 ++++++++-- src/spikeinterface/preprocessing/filter.py | 17 ++++++++--------- 17 files changed, 44 insertions(+), 27 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index a7476cd62f..c5c9ebe4dd 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -117,7 +117,7 @@ Non-NEO-based .. autofunction:: read_bids .. autofunction:: read_cbin_ibl .. autofunction:: read_combinato - .. autofunction:: read_ibl_streaming_recording + .. autofunction:: read_ibl_recording .. autofunction:: read_hdsort .. autofunction:: read_herdingspikes .. autofunction:: read_kilosort diff --git a/doc/modules/extractors.rst b/doc/modules/extractors.rst index 2d0e047672..ba08e45aca 100644 --- a/doc/modules/extractors.rst +++ b/doc/modules/extractors.rst @@ -125,7 +125,7 @@ For raw recording formats, we currently support: * **Biocam HDF5** :py:func:`~spikeinterface.extractors.read_biocam()` * **CED** :py:func:`~spikeinterface.extractors.read_ced()` * **EDF** :py:func:`~spikeinterface.extractors.read_edf()` -* **IBL streaming** :py:func:`~spikeinterface.extractors.read_ibl_streaming_recording()` +* **IBL streaming** :py:func:`~spikeinterface.extractors.read_ibl_recording()` * **Intan** :py:func:`~spikeinterface.extractors.read_intan()` * **MaxWell** :py:func:`~spikeinterface.extractors.read_maxwell()` * **MCS H5** :py:func:`~spikeinterface.extractors.read_mcsh5()` diff --git a/src/spikeinterface/extractors/cbin_ibl.py b/src/spikeinterface/extractors/cbin_ibl.py index a6da19408f..1687acb073 100644 --- a/src/spikeinterface/extractors/cbin_ibl.py +++ b/src/spikeinterface/extractors/cbin_ibl.py @@ -27,9 +27,11 @@ class CompressedBinaryIblExtractor(BaseRecording): load_sync_channel : bool, default: False Load or not the last channel (sync). If not then the probe is loaded. - stream_name : str, default: "ap". + stream_name : {"ap", "lp"}, default: "ap". Whether to load AP or LFP band, one of "ap" or "lp". + cbin_file : str or None, default None + The cbin file of the recording. If None, searches in `folder_path` for file. Returns ------- diff --git a/src/spikeinterface/extractors/herdingspikesextractors.py b/src/spikeinterface/extractors/herdingspikesextractors.py index 139d51d62e..87f7dd74c4 100644 --- a/src/spikeinterface/extractors/herdingspikesextractors.py +++ b/src/spikeinterface/extractors/herdingspikesextractors.py @@ -20,7 +20,7 @@ class HerdingspikesSortingExtractor(BaseSorting): Parameters ---------- - folder_path : str or Path + file_path : str or Path Path to the ALF folder. load_unit_info : bool, default: True Whether to load the unit info from the file. diff --git a/src/spikeinterface/extractors/iblextractors.py b/src/spikeinterface/extractors/iblextractors.py index 2444314aec..27bb95854f 100644 --- a/src/spikeinterface/extractors/iblextractors.py +++ b/src/spikeinterface/extractors/iblextractors.py @@ -41,7 +41,7 @@ class IblRecordingExtractor(BaseRecording): stream_name : str The name of the stream to load for the session. These can be retrieved from calling `StreamingIblExtractor.get_stream_names(session="")`. - load_sync_channels : bool, default: false + load_sync_channel : bool, default: false Load or not the last channel (sync). If not then the probe is loaded. cache_folder : str or None, default: None diff --git a/src/spikeinterface/extractors/neoextractors/alphaomega.py b/src/spikeinterface/extractors/neoextractors/alphaomega.py index 5c8e58d3a5..239928f66d 100644 --- a/src/spikeinterface/extractors/neoextractors/alphaomega.py +++ b/src/spikeinterface/extractors/neoextractors/alphaomega.py @@ -50,6 +50,11 @@ def map_to_neo_kwargs(cls, folder_path, lsx_files=None): class AlphaOmegaEventExtractor(NeoBaseEventExtractor): """ Class for reading events from AlphaOmega MPX file format + + Parameters + ---------- + folder_path : str or Path-like + The folder path to the AlphaOmega events. """ mode = "folder" diff --git a/src/spikeinterface/extractors/neoextractors/biocam.py b/src/spikeinterface/extractors/neoextractors/biocam.py index 96d4dd25a6..9f23575dba 100644 --- a/src/spikeinterface/extractors/neoextractors/biocam.py +++ b/src/spikeinterface/extractors/neoextractors/biocam.py @@ -42,7 +42,6 @@ def __init__( electrode_width=None, stream_id=None, stream_name=None, - block_index=None, all_annotations=False, ): neo_kwargs = self.map_to_neo_kwargs(file_path) diff --git a/src/spikeinterface/extractors/neoextractors/blackrock.py b/src/spikeinterface/extractors/neoextractors/blackrock.py index 5e28c4a20d..0015fd9f67 100644 --- a/src/spikeinterface/extractors/neoextractors/blackrock.py +++ b/src/spikeinterface/extractors/neoextractors/blackrock.py @@ -26,6 +26,8 @@ class BlackrockRecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream name you want to load. all_annotations : bool, default: False Load exhaustively all annotations from neo. + use_names_as_ids : bool or None, default: None + If True, use channel names as IDs. If None, use default IDs. """ mode = "file" @@ -37,7 +39,6 @@ def __init__( file_path, stream_id=None, stream_name=None, - block_index=None, all_annotations=False, use_names_as_ids=False, ): diff --git a/src/spikeinterface/extractors/neoextractors/ced.py b/src/spikeinterface/extractors/neoextractors/ced.py index 401c927fc7..e2c79478fa 100644 --- a/src/spikeinterface/extractors/neoextractors/ced.py +++ b/src/spikeinterface/extractors/neoextractors/ced.py @@ -23,8 +23,6 @@ class CedRecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream id you want to load. stream_name : str, default: None If there are several streams, specify the stream name you want to load. - block_index : int, default: None - If there are several blocks, specify the block index you want to load. all_annotations : bool, default: False Load exhaustively all annotations from neo. """ diff --git a/src/spikeinterface/extractors/neoextractors/intan.py b/src/spikeinterface/extractors/neoextractors/intan.py index c37ff47807..9d4db3103c 100644 --- a/src/spikeinterface/extractors/neoextractors/intan.py +++ b/src/spikeinterface/extractors/neoextractors/intan.py @@ -27,6 +27,8 @@ class IntanRecordingExtractor(NeoBaseRecordingExtractor): If True, data that violates integrity assumptions will be loaded. At the moment the only integrity check we perform is that timestamps are continuous. Setting this to True will ignore this check and set the attribute `discontinuous_timestamps` to True in the underlying neo object. + use_names_as_ids : bool or None, default: None + If True, use channel names as IDs. If None, use default IDs. """ mode = "file" diff --git a/src/spikeinterface/extractors/neoextractors/maxwell.py b/src/spikeinterface/extractors/neoextractors/maxwell.py index 3888b6d5a0..a66075b451 100644 --- a/src/spikeinterface/extractors/neoextractors/maxwell.py +++ b/src/spikeinterface/extractors/neoextractors/maxwell.py @@ -35,6 +35,8 @@ class MaxwellRecordingExtractor(NeoBaseRecordingExtractor): you want to extract. (rec_name='rec0000'). install_maxwell_plugin : bool, default: False If True, install the maxwell plugin for neo. + block_index : int, default: None + If there are several blocks (experiments), specify the block index you want to load """ mode = "file" diff --git a/src/spikeinterface/extractors/neoextractors/neuralynx.py b/src/spikeinterface/extractors/neoextractors/neuralynx.py index 25b6bb5b61..0670371ba9 100644 --- a/src/spikeinterface/extractors/neoextractors/neuralynx.py +++ b/src/spikeinterface/extractors/neoextractors/neuralynx.py @@ -26,16 +26,17 @@ class NeuralynxRecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream name you want to load. all_annotations : bool, default: False Load exhaustively all annotations from neo. - exlude_filename : list[str], default: None + exclude_filename : list[str], default: None List of filename to exclude from the loading. For example, use `exclude_filename=["events.nev"]` to skip loading the event file. strict_gap_mode : bool, default: False See neo documentation. Detect gaps using strict mode or not. - * strict_gap_mode = True then a gap is consider when timstamp difference between two - consecutive data packets is more than one sample interval. - * strict_gap_mode = False then a gap has an increased tolerance. Some new systems with different clocks need this option - otherwise, too many gaps are detected + * strict_gap_mode = True then a gap is consider when timstamp difference between + two consecutive data packets is more than one sample interval. + * strict_gap_mode = False then a gap has an increased tolerance. Some new systems + with different clocks need this option otherwise, too many gaps are detected + Note that here the default is False contrary to neo. """ diff --git a/src/spikeinterface/extractors/neoextractors/plexon2.py b/src/spikeinterface/extractors/neoextractors/plexon2.py index 941158def1..c7351a308b 100644 --- a/src/spikeinterface/extractors/neoextractors/plexon2.py +++ b/src/spikeinterface/extractors/neoextractors/plexon2.py @@ -19,7 +19,7 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream id you want to load. stream_name : str, default: None If there are several streams, specify the stream name you want to load. - use_names_as_ids: + use_names_as_ids : bool, default: True If True, the names of the signals are used as channel ids. If False, the channel ids are a combination of the source id and the channel index. diff --git a/src/spikeinterface/extractors/neoextractors/spikegadgets.py b/src/spikeinterface/extractors/neoextractors/spikegadgets.py index f326c49cd1..3d57817f88 100644 --- a/src/spikeinterface/extractors/neoextractors/spikegadgets.py +++ b/src/spikeinterface/extractors/neoextractors/spikegadgets.py @@ -32,7 +32,7 @@ class SpikeGadgetsRecordingExtractor(NeoBaseRecordingExtractor): NeoRawIOClass = "SpikeGadgetsRawIO" name = "spikegadgets" - def __init__(self, file_path, stream_id=None, stream_name=None, block_index=None, all_annotations=False): + def __init__(self, file_path, stream_id=None, stream_name=None, all_annotations=False): neo_kwargs = self.map_to_neo_kwargs(file_path) NeoBaseRecordingExtractor.__init__( self, stream_id=stream_id, stream_name=stream_name, all_annotations=all_annotations, **neo_kwargs diff --git a/src/spikeinterface/extractors/neoextractors/tdt.py b/src/spikeinterface/extractors/neoextractors/tdt.py index 146f6a4b4c..27b456102f 100644 --- a/src/spikeinterface/extractors/neoextractors/tdt.py +++ b/src/spikeinterface/extractors/neoextractors/tdt.py @@ -23,6 +23,8 @@ class TdtRecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream name you want to load. all_annotations : bool, default: False Load exhaustively all annotations from neo. + block_index : int, default: None + If there are several blocks (experiments), specify the block index you want to load """ mode = "folder" diff --git a/src/spikeinterface/extractors/toy_example.py b/src/spikeinterface/extractors/toy_example.py index 450044d07b..2f007cca88 100644 --- a/src/spikeinterface/extractors/toy_example.py +++ b/src/spikeinterface/extractors/toy_example.py @@ -57,12 +57,18 @@ def toy_example( Spike time in the recording spike_labels : np.array or list[nparray] or None, default: None Cluster label for each spike time (needs to specified both together). - # score_detection : int (between 0 and 1) - # Generate the sorting based on a subset of spikes compare with the trace generation firing_rate : float, default: 3.0 The firing rate for the units (in Hz) seed : int or None, default: None Seed for random initialization. + upsample_factor : None or int, default: None + A upsampling factor used only when templates are not provided. + num_columns : int, default: 1 + Number of columns in probe. + average_peak_amplitude : float, default: -100 + Average peak amplitude of generated templates + contact_spacing_um : float, default: 40.0 + Spacing between probe contacts. Returns ------- diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index 6a1733c57c..d18227ca83 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -10,15 +10,14 @@ _common_filter_docs = """**filter_kwargs : dict Certain keyword arguments for `scipy.signal` filters: - filter_order : order - The order of the filter - filter_mode : "sos" | "ba", default: "sos" - Filter form of the filter coefficients: - - second-order sections ("sos") - - numerator/denominator : ("ba") - ftype : str, default: "butter" - Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1". - """ + filter_order : order + The order of the filter + filter_mode : "sos" | "ba", default: "sos" + Filter form of the filter coefficients: + - second-order sections ("sos") + - numerator/denominator : ("ba") + ftype : str, default: "butter" + Filter type for `scipy.signal.iirfilter` e.g. "butter", "cheby1".""" class FilterRecording(BasePreprocessor): From 5c28ecfe9f93ed5deff88ae3ad5485e37ae4b1f6 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 25 Jun 2024 08:38:04 -0600 Subject: [PATCH 198/248] Add macos and windows to cache cron jobs (#3075) Add macos and windows to cron jobs for caching testing data --- .github/workflows/caches_cron_job.yml | 68 ++++++++++----------------- 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/.github/workflows/caches_cron_job.yml b/.github/workflows/caches_cron_job.yml index 20e2a55178..2454e97ad7 100644 --- a/.github/workflows/caches_cron_job.yml +++ b/.github/workflows/caches_cron_job.yml @@ -2,64 +2,35 @@ name: Create caches for gin ecephys data and virtual env on: workflow_dispatch: - push: # When someting is pushed into main this checks if caches need to re-created + push: # When something is pushed into main this checks if caches need to be re-created branches: - main schedule: - cron: "0 12 * * *" # Daily at noon UTC jobs: - - - - create-virtual-env-cache-if-missing: - name: Caching virtual env - runs-on: "ubuntu-latest" - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - name: Get current year-month - id: date - run: | - echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - - name: Get current dependencies hash - id: dependencies - run: | - echo "hash=${{hashFiles('**/pyproject.toml')}}" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 - id: cache-venv - with: - path: ${{ github.workspace }}/test_env - key: ${{ runner.os }}-venv-${{ steps.dependencies.outputs.hash }}-${{ steps.date.outputs.date }} - lookup-only: 'true' # Avoids downloading the data, saving behavior is not affected. - - name: Cache found? - run: echo "Cache-hit == ${{steps.cache-venv.outputs.cache-hit == 'true'}}" - - name: Create the virtual environment to be cached - if: steps.cache-venv.outputs.cache-hit != 'true' - uses: ./.github/actions/build-test-environment - - - - create-gin-data-cache-if-missing: name: Caching data env - runs-on: "ubuntu-latest" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.11' - name: Create the directory to store the data run: | - mkdir --parents --verbose $HOME/spikeinterface_datasets/ephy_testing_data/ - chmod -R 777 $HOME/spikeinterface_datasets - ls -l $HOME/spikeinterface_datasets + mkdir -p ~/spikeinterface_datasets/ephy_testing_data/ + ls -l ~/spikeinterface_datasets + shell: bash - name: Get current hash (SHA) of the ephy_testing_data repo id: repo_hash run: | echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT + shell: bash - uses: actions/cache@v4 id: cache-datasets with: @@ -68,6 +39,7 @@ jobs: lookup-only: 'true' # Avoids downloading the data, saving behavior is not affected. - name: Cache found? run: echo "Cache-hit == ${{steps.cache-datasets.outputs.cache-hit == 'true'}}" + shell: bash - name: Installing datalad and git-annex if: steps.cache-datasets.outputs.cache-hit != 'true' run: | @@ -75,20 +47,29 @@ jobs: git config --global user.name "CI Almighty" python -m pip install -U pip # Official recommended way pip install datalad-installer - datalad-installer --sudo ok git-annex --method datalad/packages + if [ ${{ runner.os }} == 'Linux' ]; then + datalad-installer --sudo ok git-annex --method datalad/packages + elif [ ${{ runner.os }} == 'macOS' ]; then + datalad-installer --sudo ok git-annex --method brew + elif [ ${{ runner.os }} == 'Windows' ]; then + datalad-installer --sudo ok git-annex --method datalad/git-annex:release + fi pip install datalad git config --global filter.annex.process "git-annex filter-process" # recommended for efficiency + shell: bash - name: Download dataset if: steps.cache-datasets.outputs.cache-hit != 'true' run: | datalad install --recursive --get-data https://gin.g-node.org/NeuralEnsemble/ephy_testing_data + shell: bash - name: Move the downloaded data to the right directory if: steps.cache-datasets.outputs.cache-hit != 'true' run: | - mv --force ./ephy_testing_data $HOME/spikeinterface_datasets/ + mv ./ephy_testing_data ~/spikeinterface_datasets/ + shell: bash - name: Show size of the cache to assert data is downloaded run: | - cd $HOME + cd ~ pwd du -hs spikeinterface_datasets # Should show the size of ephy_testing_data cd spikeinterface_datasets @@ -96,3 +77,4 @@ jobs: ls -lh # Should show ephy_testing_data cd ephy_testing_data ls -lh + shell: bash From 99cc04ef882a7695c08e473fd9f98df942feb2d8 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 25 Jun 2024 12:47:15 -0600 Subject: [PATCH 199/248] Add tests for windows and mac (#2937) * extend tests for windows and mac --------- Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/run_tests.sh | 7 +- .github/workflows/all-tests.yml | 129 ++++++++++++++++++ pyproject.toml | 7 +- src/spikeinterface/core/datasets.py | 56 +++++--- .../extractors/tests/common_tests.py | 5 +- .../tests/test_datalad_downloading.py | 13 +- .../extractors/tests/test_neoextractors.py | 9 +- .../tests/test_principal_component.py | 2 +- .../sorters/tests/test_container_tools.py | 5 +- 9 files changed, 197 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/all-tests.yml diff --git a/.github/run_tests.sh b/.github/run_tests.sh index 04a6b5ac6b..558e0b64d3 100644 --- a/.github/run_tests.sh +++ b/.github/run_tests.sh @@ -1,8 +1,13 @@ #!/bin/bash MARKER=$1 +NOVIRTUALENV=$2 + +# Check if the second argument is provided and if it is equal to --no-virtual-env +if [ -z "$NOVIRTUALENV" ] || [ "$NOVIRTUALENV" != "--no-virtual-env" ]; then + source $GITHUB_WORKSPACE/test_env/bin/activate +fi -source $GITHUB_WORKSPACE/test_env/bin/activate pytest -m "$MARKER" -vv -ra --durations=0 --durations-min=0.001 | tee report.txt; test ${PIPESTATUS[0]} -eq 0 || exit 1 echo "# Timing profile of ${MARKER}" >> $GITHUB_STEP_SUMMARY python $GITHUB_WORKSPACE/.github/build_job_summary.py report.txt >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/all-tests.yml b/.github/workflows/all-tests.yml new file mode 100644 index 0000000000..1c426ba11c --- /dev/null +++ b/.github/workflows/all-tests.yml @@ -0,0 +1,129 @@ +name: Complete tests + +on: + workflow_dispatch: + schedule: + - cron: "0 12 * * 0" # Weekly on Sunday at noon UTC + pull_request: + types: [synchronize, opened, reopened] + branches: + - main + +env: + KACHERY_CLOUD_CLIENT_ID: ${{ secrets.KACHERY_CLOUD_CLIENT_ID }} + KACHERY_CLOUD_PRIVATE_KEY: ${{ secrets.KACHERY_CLOUD_PRIVATE_KEY }} + +concurrency: # Cancel previous workflows on the same pull request + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + run: + name: ${{ matrix.os }} Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.12"] # Lower and higher versions we support + os: [macos-13, windows-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # cache: 'pip' # caching pip dependencies + + - name: Get current hash (SHA) of the ephy_testing_data repo + id: repo_hash + run: | + echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" + echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT + shell: bash + - name: Cache datasets + id: cache-datasets + uses: actions/cache/restore@v4 + with: + path: ~/spikeinterface_datasets + key: ${{ runner.os }}-datasets-${{ steps.repo_hash.outputs.dataset_hash }} + restore-keys: ${{ runner.os }}-datasets + + - name: Install packages + run: | + git config --global user.email "CI@example.com" + git config --global user.name "CI Almighty" + pip install -e .[test,extractors,streaming_extractors,full] + pip install tabulate + shell: bash + + - name: Installad datalad + run: | + pip install datalad-installer + if [ ${{ runner.os }} = 'Linux' ]; then + datalad-installer --sudo ok git-annex --method datalad/packages + elif [ ${{ runner.os }} = 'macOS' ]; then + datalad-installer --sudo ok git-annex --method brew + elif [ ${{ runner.os }} = 'Windows' ]; then + datalad-installer --sudo ok git-annex --method datalad/git-annex:release + fi + pip install datalad + git config --global filter.annex.process "git-annex filter-process" # recommended for efficiency + shell: bash + + - name: Set execute permissions on run_tests.sh + run: chmod +x .github/run_tests.sh + shell: bash + + - name: Test core + run: pytest -m "core" + shell: bash + + - name: Test extractors + env: + HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell + run: pytest -m "extractors" + shell: bash + + - name: Test preprocessing + run: ./.github/run_tests.sh "preprocessing and not deepinterpolation" --no-virtual-env + shell: bash + + - name: Test postprocessing + run: ./.github/run_tests.sh postprocessing --no-virtual-env + shell: bash + + - name: Test quality metrics + run: ./.github/run_tests.sh qualitymetrics --no-virtual-env + shell: bash + + - name: Test comparison + run: ./.github/run_tests.sh comparison --no-virtual-env + shell: bash + + - name: Test core sorters + run: ./.github/run_tests.sh sorters --no-virtual-env + shell: bash + + - name: Test internal sorters + run: ./.github/run_tests.sh sorters_internal --no-virtual-env + shell: bash + + - name: Test curation + run: ./.github/run_tests.sh curation --no-virtual-env + shell: bash + + - name: Test widgets + run: ./.github/run_tests.sh widgets --no-virtual-env + shell: bash + + - name: Test exporters + run: ./.github/run_tests.sh exporters --no-virtual-env + shell: bash + + - name: Test sortingcomponents + run: ./.github/run_tests.sh sortingcomponents --no-virtual-env + shell: bash + + - name: Test generation + run: ./.github/run_tests.sh generation --no-virtual-env + shell: bash diff --git a/pyproject.toml b/pyproject.toml index 58c0f66e44..b26337ad01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,10 +137,9 @@ test = [ # for sortingview backend "sortingview", - - # recent datalad need a too recent version for git-annex - # so we use an old one here - "datalad==0.16.2", + # Download data + "pooch>=1.8.2", + "datalad>=1.0.2", ## install tridesclous for testing ## "tridesclous>=1.6.8", diff --git a/src/spikeinterface/core/datasets.py b/src/spikeinterface/core/datasets.py index 59cfbfac55..c8d897d9fc 100644 --- a/src/spikeinterface/core/datasets.py +++ b/src/spikeinterface/core/datasets.py @@ -14,10 +14,13 @@ def download_dataset( remote_path: str = "mearec/mearec_test_10s.h5", local_folder: Path | None = None, update_if_exists: bool = False, - unlock: bool = False, ) -> Path: """ - Function to download dataset from a remote repository using datalad. + Function to download dataset from a remote repository using a combination of datalad and pooch. + + Pooch is designed to download single files from a remote repository. + Because our datasets in gin sometimes point just to a folder, we still use datalad to download + a list of all the files in the folder and then use pooch to download them one by one. Parameters ---------- @@ -25,19 +28,25 @@ def download_dataset( The repository to download the dataset from remote_path : str, default: "mearec/mearec_test_10s.h5" A specific subdirectory in the repository to download (e.g. Mearec, SpikeGLX, etc) - local_folder : str, default: None + local_folder : str, optional The destination folder / directory to download the dataset to. - defaults to the path "get_global_dataset_folder()" / f{repo_name} (see `spikeinterface.core.globals`) + if None, then the path "get_global_dataset_folder()" / f{repo_name} is used (see `spikeinterface.core.globals`) update_if_exists : bool, default: False Forces re-download of the dataset if it already exists, default: False - unlock : bool, default: False - Use to enable the edition of the downloaded file content, default: False Returns ------- Path The local path to the downloaded dataset + + Notes + ----- + The reason we use pooch is because have had problems with datalad not being able to download + data on windows machines. Especially in the CI. + + See https://handbook.datalad.org/en/latest/intro/windows.html """ + import pooch import datalad.api from datalad.support.gitrepo import GitRepo @@ -45,25 +54,40 @@ def download_dataset( base_local_folder = get_global_dataset_folder() base_local_folder.mkdir(exist_ok=True, parents=True) local_folder = base_local_folder / repo.split("/")[-1] + local_folder.mkdir(exist_ok=True, parents=True) + else: + if not local_folder.is_dir(): + local_folder.mkdir(exist_ok=True, parents=True) local_folder = Path(local_folder) if local_folder.exists() and GitRepo.is_valid_repo(local_folder): dataset = datalad.api.Dataset(path=local_folder) - # make sure git repo is in clean state - repo = dataset.repo - if update_if_exists: - repo.call_git(["checkout", "--force", "master"]) - dataset.update(merge=True) else: dataset = datalad.api.install(path=local_folder, source=repo) local_path = local_folder / remote_path + dataset_status = dataset.status(path=remote_path, annex="simple") + + # Download only files that also have a git-annex key + dataset_status_files = [status for status in dataset_status if status["type"] == "file"] + dataset_status_files = [status for status in dataset_status_files if "key" in status] - # This downloads the data set content - dataset.get(remote_path) + git_annex_hashing_algorithm = {"MD5E": "md5"} + for status in dataset_status_files: + hash_algorithm = git_annex_hashing_algorithm[status["backend"]] + hash = status["keyname"].split(".")[0] + known_hash = f"{hash_algorithm}:{hash}" + fname = Path(status["path"]).relative_to(local_folder) + url = f"{repo}/raw/master/{fname.as_posix()}" + expected_full_path = local_folder / fname - # Unlock files of a dataset in order to be able to edit the actual content - if unlock: - dataset.unlock(remote_path, recursive=True) + full_path = pooch.retrieve( + url=url, + fname=str(fname), + path=local_folder, + known_hash=known_hash, + progressbar=True, + ) + assert full_path == str(expected_full_path) return local_path diff --git a/src/spikeinterface/extractors/tests/common_tests.py b/src/spikeinterface/extractors/tests/common_tests.py index dcbd2304f1..5432efa9f3 100644 --- a/src/spikeinterface/extractors/tests/common_tests.py +++ b/src/spikeinterface/extractors/tests/common_tests.py @@ -18,8 +18,9 @@ class CommonTestSuite: downloads = [] entities = [] - def setUp(self): - for remote_path in self.downloads: + @classmethod + def setUpClass(cls): + for remote_path in cls.downloads: download_dataset(repo=gin_repo, remote_path=remote_path, local_folder=local_folder, update_if_exists=True) diff --git a/src/spikeinterface/extractors/tests/test_datalad_downloading.py b/src/spikeinterface/extractors/tests/test_datalad_downloading.py index 97e68146a6..8abccc6707 100644 --- a/src/spikeinterface/extractors/tests/test_datalad_downloading.py +++ b/src/spikeinterface/extractors/tests/test_datalad_downloading.py @@ -1,15 +1,12 @@ import pytest from spikeinterface.core import download_dataset +import importlib.util -try: - import datalad - HAVE_DATALAD = True -except: - HAVE_DATALAD = False - - -@pytest.mark.skipif(not HAVE_DATALAD, reason="No datalad") +@pytest.mark.skipif( + importlib.util.find_spec("pooch") is None or importlib.util.find_spec("datalad") is None, + reason="Either pooch or datalad is not installed", +) def test_download_dataset(): repo = "https://gin.g-node.org/NeuralEnsemble/ephy_testing_data" remote_path = "mearec" diff --git a/src/spikeinterface/extractors/tests/test_neoextractors.py b/src/spikeinterface/extractors/tests/test_neoextractors.py index 379bf00c6b..acd7ebe8ad 100644 --- a/src/spikeinterface/extractors/tests/test_neoextractors.py +++ b/src/spikeinterface/extractors/tests/test_neoextractors.py @@ -351,8 +351,10 @@ def test_pickling(self): pass -# We run plexon2 tests only if we have dependencies (wine) -@pytest.mark.skipif(not has_plexon2_dependencies(), reason="Required dependencies not installed") +# TODO solve plexon bug +@pytest.mark.skipif( + not has_plexon2_dependencies() or platform.system() == "Windows", reason="There is a bug on windows" +) class Plexon2RecordingTest(RecordingCommonTestSuite, unittest.TestCase): ExtractorClass = Plexon2RecordingExtractor downloads = ["plexon"] @@ -361,6 +363,7 @@ class Plexon2RecordingTest(RecordingCommonTestSuite, unittest.TestCase): ] +@pytest.mark.skipif(not has_plexon2_dependencies() or platform.system() == "Windows", reason="There is a bug") @pytest.mark.skipif(not has_plexon2_dependencies(), reason="Required dependencies not installed") class Plexon2EventTest(EventCommonTestSuite, unittest.TestCase): ExtractorClass = Plexon2EventExtractor @@ -370,7 +373,7 @@ class Plexon2EventTest(EventCommonTestSuite, unittest.TestCase): ] -@pytest.mark.skipif(not has_plexon2_dependencies(), reason="Required dependencies not installed") +@pytest.mark.skipif(not has_plexon2_dependencies() or platform.system() == "Windows", reason="There is a bug") class Plexon2SortingTest(SortingCommonTestSuite, unittest.TestCase): ExtractorClass = Plexon2SortingExtractor downloads = ["plexon"] diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py index 08ec32c6c2..38ae3b2c5e 100644 --- a/src/spikeinterface/postprocessing/tests/test_principal_component.py +++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py @@ -136,7 +136,7 @@ def test_compute_for_all_spikes(self, sparse): ext.run_for_all_spikes(pc_file2, chunk_size=10000, n_jobs=2) all_pc2 = np.load(pc_file2) - assert np.array_equal(all_pc1, all_pc2) + np.testing.assert_almost_equal(all_pc1, all_pc2, decimal=3) def test_project_new(self): """ diff --git a/src/spikeinterface/sorters/tests/test_container_tools.py b/src/spikeinterface/sorters/tests/test_container_tools.py index 3ae03abff1..0369bca860 100644 --- a/src/spikeinterface/sorters/tests/test_container_tools.py +++ b/src/spikeinterface/sorters/tests/test_container_tools.py @@ -8,6 +8,7 @@ from spikeinterface import generate_ground_truth_recording from spikeinterface.sorters.container_tools import find_recording_folders, ContainerClient, install_package_in_container +import platform ON_GITHUB = bool(os.getenv("GITHUB_ACTIONS")) @@ -58,7 +59,9 @@ def test_find_recording_folders(setup_module): assert str(f2[0]) == str((cache_folder / "multi").absolute()) # in this case the paths are in 3 separate drives - assert len(f3) == 3 + # Not a good test on windows because all the paths resolve to C when absolute in `find_recording_folders` + if platform.system() != "Windows": + assert len(f3) == 3 @pytest.mark.skipif(ON_GITHUB, reason="Docker tests don't run on github: test locally") From 921ec82c6ab955a1622fb28e60b05dbb455529c6 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Wed, 26 Jun 2024 10:43:49 +0200 Subject: [PATCH 200/248] Template similarity lags (#2941) Extend template similarity with lags and distance metrics --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alessio Buccino --- .../comparison/basecomparison.py | 8 +- .../comparison/multicomparisons.py | 9 +- .../comparison/paircomparisons.py | 47 +++--- .../postprocessing/template_similarity.py | 143 ++++++++++++++++-- .../tests/test_template_similarity.py | 17 ++- 5 files changed, 179 insertions(+), 45 deletions(-) diff --git a/src/spikeinterface/comparison/basecomparison.py b/src/spikeinterface/comparison/basecomparison.py index 0fdda745b2..f1d2130d38 100644 --- a/src/spikeinterface/comparison/basecomparison.py +++ b/src/spikeinterface/comparison/basecomparison.py @@ -313,9 +313,11 @@ class MixinTemplateComparison: """ Mixin for template comparisons to define: * similarity method - * sparsity + * support + * num_shifts """ - def __init__(self, similarity_method="cosine_similarity", sparsity_dict=None): + def __init__(self, similarity_method="cosine", support="union", num_shifts=0): self.similarity_method = similarity_method - self.sparsity_dict = sparsity_dict + self.support = support + self.num_shifts = num_shifts diff --git a/src/spikeinterface/comparison/multicomparisons.py b/src/spikeinterface/comparison/multicomparisons.py index 7cde985b37..35c298d4ac 100644 --- a/src/spikeinterface/comparison/multicomparisons.py +++ b/src/spikeinterface/comparison/multicomparisons.py @@ -333,8 +333,9 @@ def __init__( match_score=0.8, chance_score=0.3, verbose=False, - similarity_method="cosine_similarity", - sparsity_dict=None, + similarity_method="cosine", + support="union", + num_shifts=0, do_matching=True, ): if name_list is None: @@ -347,7 +348,9 @@ def __init__( chance_score=chance_score, verbose=verbose, ) - MixinTemplateComparison.__init__(self, similarity_method=similarity_method, sparsity_dict=sparsity_dict) + MixinTemplateComparison.__init__( + self, similarity_method=similarity_method, support=support, num_shifts=num_shifts + ) if do_matching: self._compute_all() diff --git a/src/spikeinterface/comparison/paircomparisons.py b/src/spikeinterface/comparison/paircomparisons.py index ea4b72b200..7d5f04dfdd 100644 --- a/src/spikeinterface/comparison/paircomparisons.py +++ b/src/spikeinterface/comparison/paircomparisons.py @@ -697,24 +697,26 @@ class TemplateComparison(BasePairComparison, MixinTemplateComparison): Parameters ---------- sorting_analyzer_1 : SortingAnalyzer - The first SortingAnalyzer to get templates to compare + The first SortingAnalyzer to get templates to compare. sorting_analyzer_2 : SortingAnalyzer - The second SortingAnalyzer to get templates to compare + The second SortingAnalyzer to get templates to compare. unit_ids1 : list, default: None - List of units from sorting_analyzer_1 to compare + List of units from sorting_analyzer_1 to compare. unit_ids2 : list, default: None - List of units from sorting_analyzer_2 to compare - similarity_method : str, default: "cosine_similarity" - Method for the similaroty matrix - sparsity_dict : dict, default: None - Dictionary for sparsity + List of units from sorting_analyzer_2 to compare. + similarity_method : "cosine" | "l1" | "l2", default: "cosine" + Method for the similarity matrix. + support : "dense" | "union" | "intersection", default: "union" + The support to compute the similarity matrix. + num_shifts : int, default: 0 + Number of shifts to use to shift templates to maximize similarity. verbose : bool, default: False - If True, output is verbose + If True, output is verbose. Returns ------- comparison : TemplateComparison - The output TemplateComparison object + The output TemplateComparison object. """ def __init__( @@ -727,8 +729,9 @@ def __init__( unit_ids2=None, match_score=0.7, chance_score=0.3, - similarity_method="cosine_similarity", - sparsity_dict=None, + similarity_method="cosine", + support="union", + num_shifts=0, verbose=False, ): if name1 is None: @@ -745,7 +748,9 @@ def __init__( chance_score=chance_score, verbose=verbose, ) - MixinTemplateComparison.__init__(self, similarity_method=similarity_method, sparsity_dict=sparsity_dict) + MixinTemplateComparison.__init__( + self, similarity_method=similarity_method, support=support, num_shifts=num_shifts + ) self.sorting_analyzer_1 = sorting_analyzer_1 self.sorting_analyzer_2 = sorting_analyzer_2 @@ -754,10 +759,9 @@ def __init__( # two options: all channels are shared or partial channels are shared if sorting_analyzer_1.recording.get_num_channels() != sorting_analyzer_2.recording.get_num_channels(): - raise NotImplementedError + raise ValueError("The two recordings must have the same number of channels") if np.any([ch1 != ch2 for (ch1, ch2) in zip(channel_ids1, channel_ids2)]): - # TODO: here we can check location and run it on the union. Might be useful for reconfigurable probes - raise NotImplementedError + raise ValueError("The two recordings must have the same channel ids") self.matches = dict() @@ -768,11 +772,6 @@ def __init__( unit_ids2 = sorting_analyzer_2.sorting.get_unit_ids() self.unit_ids = [unit_ids1, unit_ids2] - if sparsity_dict is not None: - raise NotImplementedError - else: - self.sparsity = None - self._do_agreement() self._do_matching() @@ -781,7 +780,11 @@ def _do_agreement(self): print("Agreement scores...") agreement_scores = compute_template_similarity_by_pair( - self.sorting_analyzer_1, self.sorting_analyzer_2, method=self.similarity_method + self.sorting_analyzer_1, + self.sorting_analyzer_2, + method=self.similarity_method, + support=self.support, + num_shifts=self.num_shifts, ) import pandas as pd diff --git a/src/spikeinterface/postprocessing/template_similarity.py b/src/spikeinterface/postprocessing/template_similarity.py index 15a1fe34ce..777f84dfd7 100644 --- a/src/spikeinterface/postprocessing/template_similarity.py +++ b/src/spikeinterface/postprocessing/template_similarity.py @@ -1,6 +1,7 @@ from __future__ import annotations import numpy as np +import warnings from spikeinterface.core.sortinganalyzer import register_result_extension, AnalyzerExtension from ..core.template_tools import get_dense_templates_array @@ -9,13 +10,26 @@ class ComputeTemplateSimilarity(AnalyzerExtension): """Compute similarity between templates with several methods. + Similarity is defined as 1 - distance(T_1, T_2) for two templates T_1, T_2 + Parameters ---------- - sorting_analyzer: SortingAnalyzer + sorting_analyzer : SortingAnalyzer The SortingAnalyzer object - method: str, default: "cosine_similarity" - The method to compute the similarity + method : str, default: "cosine" + The method to compute the similarity. Can be in ["cosine", "l2", "l1"] + max_lag_ms : float, default: 0 + If specified, the best distance for all given lag within max_lag_ms is kept, for every template + support : "dense" | "union" | "intersection", default: "union" + Support that should be considered to compute the distances between the templates, given their sparsities. + Can be either ["dense", "union", "intersection"] + + In case of "l1" or "l2", the formula used is: + similarity = 1 - norm(T_1 - T_2)/(norm(T_1) + norm(T_2)) + + In case of cosine this is: + similarity = 1 - sum(T_1.T_2)/(norm(T_1)norm(T_2)) Returns ------- @@ -32,8 +46,15 @@ class ComputeTemplateSimilarity(AnalyzerExtension): def __init__(self, sorting_analyzer): AnalyzerExtension.__init__(self, sorting_analyzer) - def _set_params(self, method="cosine_similarity"): - params = dict(method=method) + def _set_params(self, method="cosine", max_lag_ms=0, support="union"): + if method == "cosine_similarity": + warnings.warn( + "The method 'cosine_similarity' is deprecated and will be removed in the next version. Use 'cosine' instead.", + DeprecationWarning, + stacklevel=2, + ) + method = "cosine" + params = dict(method=method, max_lag_ms=max_lag_ms, support=support) return params def _select_extension_data(self, unit_ids): @@ -43,11 +64,19 @@ def _select_extension_data(self, unit_ids): return dict(similarity=new_similarity) def _run(self, verbose=False): + num_shifts = int(self.params["max_lag_ms"] * self.sorting_analyzer.sampling_frequency / 1000) templates_array = get_dense_templates_array( self.sorting_analyzer, return_scaled=self.sorting_analyzer.return_scaled ) + sparsity = self.sorting_analyzer.sparsity similarity = compute_similarity_with_templates_array( - templates_array, templates_array, method=self.params["method"] + templates_array, + templates_array, + method=self.params["method"], + num_shifts=num_shifts, + support=self.params["support"], + sparsity=sparsity, + other_sparsity=sparsity, ) self.data["similarity"] = similarity @@ -60,25 +89,109 @@ def _get_data(self): compute_template_similarity = ComputeTemplateSimilarity.function_factory() -def compute_similarity_with_templates_array(templates_array, other_templates_array, method): +def compute_similarity_with_templates_array( + templates_array, other_templates_array, method, support="union", num_shifts=0, sparsity=None, other_sparsity=None +): + import sklearn.metrics.pairwise if method == "cosine_similarity": - assert templates_array.shape[0] == other_templates_array.shape[0] - templates_flat = templates_array.reshape(templates_array.shape[0], -1) - other_templates_flat = templates_array.reshape(other_templates_array.shape[0], -1) - similarity = sklearn.metrics.pairwise.cosine_similarity(templates_flat, other_templates_flat) - + method = "cosine" + + all_metrics = ["cosine", "l1", "l2"] + + if method not in all_metrics: + raise ValueError(f"compute_template_similarity (method {method}) not exists") + + assert ( + templates_array.shape[1] == other_templates_array.shape[1] + ), "The number of samples in the templates should be the same for both arrays" + assert ( + templates_array.shape[2] == other_templates_array.shape[2] + ), "The number of channels in the templates should be the same for both arrays" + num_templates = templates_array.shape[0] + num_samples = templates_array.shape[1] + num_channels = templates_array.shape[2] + other_num_templates = other_templates_array.shape[0] + + mask = None + if sparsity is not None and other_sparsity is not None: + if support == "intersection": + mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :]) + elif support == "union": + mask = np.logical_and(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :]) + units_overlaps = np.sum(mask, axis=2) > 0 + mask = np.logical_or(sparsity.mask[:, np.newaxis, :], other_sparsity.mask[np.newaxis, :, :]) + mask[~units_overlaps] = False + if mask is not None: + units_overlaps = np.sum(mask, axis=2) > 0 + overlapping_templates = {} + for i in range(num_templates): + overlapping_templates[i] = np.flatnonzero(units_overlaps[i]) else: - raise ValueError(f"compute_template_similarity(method {method}) not exists") + # here we make a dense mask and overlapping templates + overlapping_templates = {i: np.arange(other_num_templates) for i in range(num_templates)} + mask = np.ones((num_templates, other_num_templates, num_channels), dtype=bool) + + assert num_shifts < num_samples, "max_lag is too large" + num_shifts_both_sides = 2 * num_shifts + 1 + distances = np.ones((num_shifts_both_sides, num_templates, other_num_templates), dtype=np.float32) + + # We can use the fact that dist[i,j] at lag t is equal to dist[j,i] at time -t + # So the matrix can be computed only for negative lags and be transposed + for count, shift in enumerate(range(-num_shifts, 1)): + src_sliced_templates = templates_array[:, num_shifts : num_samples - num_shifts] + tgt_sliced_templates = other_templates_array[:, num_shifts + shift : num_samples - num_shifts + shift] + for i in range(num_templates): + src_template = src_sliced_templates[i] + tgt_templates = tgt_sliced_templates[overlapping_templates[i]] + for gcount, j in enumerate(overlapping_templates[i]): + # symmetric values are handled later + if num_templates == other_num_templates and j < i: + continue + src = src_template[:, mask[i, j]].reshape(1, -1) + tgt = (tgt_templates[gcount][:, mask[i, j]]).reshape(1, -1) + + if method == "l1": + norm_i = np.sum(np.abs(src)) + norm_j = np.sum(np.abs(tgt)) + distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(src, tgt, metric="l1") + distances[count, i, j] /= norm_i + norm_j + elif method == "l2": + norm_i = np.linalg.norm(src, ord=2) + norm_j = np.linalg.norm(tgt, ord=2) + distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(src, tgt, metric="l2") + distances[count, i, j] /= norm_i + norm_j + else: + distances[count, i, j] = sklearn.metrics.pairwise.pairwise_distances(src, tgt, metric="cosine") + if num_templates == other_num_templates: + distances[count, j, i] = distances[count, i, j] + + if num_shifts != 0: + distances[num_shifts_both_sides - count - 1] = distances[count].T + + distances = np.min(distances, axis=0) + similarity = 1 - distances return similarity -def compute_template_similarity_by_pair(sorting_analyzer_1, sorting_analyzer_2, method="cosine_similarity"): +def compute_template_similarity_by_pair( + sorting_analyzer_1, sorting_analyzer_2, method="cosine", support="union", num_shifts=0 +): templates_array_1 = get_dense_templates_array(sorting_analyzer_1, return_scaled=True) templates_array_2 = get_dense_templates_array(sorting_analyzer_2, return_scaled=True) - similarity = compute_similarity_with_templates_array(templates_array_1, templates_array_2, method) + sparsity_1 = sorting_analyzer_1.sparsity + sparsity_2 = sorting_analyzer_2.sparsity + similarity = compute_similarity_with_templates_array( + templates_array_1, + templates_array_2, + method=method, + support=support, + num_shifts=num_shifts, + sparsity=sparsity_1, + other_sparsity=sparsity_2, + ) return similarity diff --git a/src/spikeinterface/postprocessing/tests/test_template_similarity.py b/src/spikeinterface/postprocessing/tests/test_template_similarity.py index a4de2a3a90..f98a5624db 100644 --- a/src/spikeinterface/postprocessing/tests/test_template_similarity.py +++ b/src/spikeinterface/postprocessing/tests/test_template_similarity.py @@ -1,3 +1,5 @@ +import pytest + from spikeinterface.postprocessing.tests.common_extension_tests import ( AnalyzerExtensionCommonTestSuite, ) @@ -7,8 +9,19 @@ class TestSimilarityExtension(AnalyzerExtensionCommonTestSuite): - def test_extension(self): - self.run_extension_tests(ComputeTemplateSimilarity, params=dict(method="cosine_similarity")) + @pytest.mark.parametrize( + "params", + [ + dict(method="cosine"), + dict(method="l2"), + dict(method="l1", max_lag_ms=0.2), + dict(method="l1", support="intersection"), + dict(method="l2", support="union"), + dict(method="cosine", support="dense"), + ], + ) + def test_extension(self, params): + self.run_extension_tests(ComputeTemplateSimilarity, params=params) def test_check_equal_template_with_distribution_overlap(self): """ From 2867d7c09977cd5c77a9bf5fdf0793e3e1f05314 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 26 Jun 2024 06:32:08 -0600 Subject: [PATCH 201/248] remove cached dependencies (#3080) --- .../actions/show-test-environment/action.yml | 23 ------------------- .github/workflows/full-test-with-codecov.yml | 11 --------- .github/workflows/full-test.yml | 8 ------- 3 files changed, 42 deletions(-) delete mode 100644 .github/actions/show-test-environment/action.yml diff --git a/.github/actions/show-test-environment/action.yml b/.github/actions/show-test-environment/action.yml deleted file mode 100644 index 3bc062d414..0000000000 --- a/.github/actions/show-test-environment/action.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Log test environment -description: Shows installed packages by pip, git-annex and cached testing files - -inputs: {} - -runs: - using: "composite" - steps: - - name: git-annex version - run: | - git-annex version - shell: bash - - name: Packages installed - run: | - source ${{ github.workspace }}/test_env/bin/activate - pip list - shell: bash - - name: Check ephy_testing_data files - run: | - if [ -d "$HOME/spikeinterface_datasets" ]; then - find $HOME/spikeinterface_datasets - fi - shell: bash diff --git a/.github/workflows/full-test-with-codecov.yml b/.github/workflows/full-test-with-codecov.yml index 75847759f6..ab4a083ae1 100644 --- a/.github/workflows/full-test-with-codecov.yml +++ b/.github/workflows/full-test-with-codecov.yml @@ -23,15 +23,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.10' - - name: Get current year-month - id: date - run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - - name: Restore cached virtual environment with dependencies - uses: actions/cache/restore@v4 - id: cache-venv - with: - path: ${{ github.workspace }}/test_env - key: ${{ runner.os }}-venv-${{ hashFiles('**/pyproject.toml') }}-${{ steps.date.outputs.date }} - name: Get ephy_testing_data current head hash # the key depends on the last comit repo https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git id: vars @@ -49,8 +40,6 @@ jobs: restore-keys: ${{ runner.os }}-datasets - name: Install packages uses: ./.github/actions/build-test-environment - - name: Shows installed packages by pip, git-annex and cached testing files - uses: ./.github/actions/show-test-environment - name: run tests env: HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell diff --git a/.github/workflows/full-test.yml b/.github/workflows/full-test.yml index b432fbd4d5..ed2f28dc23 100644 --- a/.github/workflows/full-test.yml +++ b/.github/workflows/full-test.yml @@ -31,12 +31,6 @@ jobs: - name: Get current year-month id: date run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - - name: Restore cached virtual environment with dependencies - uses: actions/cache/restore@v4 - id: cache-venv - with: - path: ${{ github.workspace }}/test_env - key: ${{ runner.os }}-venv-${{ hashFiles('**/pyproject.toml') }}-${{ steps.date.outputs.date }} - name: Get ephy_testing_data current head hash # the key depends on the last comit repo https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git id: vars @@ -54,8 +48,6 @@ jobs: restore-keys: ${{ runner.os }}-datasets - name: Install packages uses: ./.github/actions/build-test-environment - - name: Shows installed packages by pip, git-annex and cached testing files - uses: ./.github/actions/show-test-environment - name: Get changed files id: changed-files uses: tj-actions/changed-files@v41 From b88ddcb9969e01c019452e1a0d1832b092390ea8 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:17:09 +0100 Subject: [PATCH 202/248] Respond to review --- src/spikeinterface/extractors/neoextractors/intan.py | 4 ++-- src/spikeinterface/extractors/toy_example.py | 6 +++--- src/spikeinterface/preprocessing/filter.py | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/extractors/neoextractors/intan.py b/src/spikeinterface/extractors/neoextractors/intan.py index 9d4db3103c..50fda79123 100644 --- a/src/spikeinterface/extractors/neoextractors/intan.py +++ b/src/spikeinterface/extractors/neoextractors/intan.py @@ -27,8 +27,8 @@ class IntanRecordingExtractor(NeoBaseRecordingExtractor): If True, data that violates integrity assumptions will be loaded. At the moment the only integrity check we perform is that timestamps are continuous. Setting this to True will ignore this check and set the attribute `discontinuous_timestamps` to True in the underlying neo object. - use_names_as_ids : bool or None, default: None - If True, use channel names as IDs. If None, use default IDs. + use_names_as_ids : bool, default: False + If True, use channel names as IDs. If False, use default IDs inherited from neo. """ mode = "file" diff --git a/src/spikeinterface/extractors/toy_example.py b/src/spikeinterface/extractors/toy_example.py index 2f007cca88..55b787f3ed 100644 --- a/src/spikeinterface/extractors/toy_example.py +++ b/src/spikeinterface/extractors/toy_example.py @@ -62,13 +62,13 @@ def toy_example( seed : int or None, default: None Seed for random initialization. upsample_factor : None or int, default: None - A upsampling factor used only when templates are not provided. + An upsampling factor, used only when templates are not provided. num_columns : int, default: 1 Number of columns in probe. average_peak_amplitude : float, default: -100 - Average peak amplitude of generated templates + Average peak amplitude of generated templates. contact_spacing_um : float, default: 40.0 - Spacing between probe contacts. + Spacing between probe contacts in micrometers. Returns ------- diff --git a/src/spikeinterface/preprocessing/filter.py b/src/spikeinterface/preprocessing/filter.py index d18227ca83..93462ac5d8 100644 --- a/src/spikeinterface/preprocessing/filter.py +++ b/src/spikeinterface/preprocessing/filter.py @@ -11,7 +11,9 @@ _common_filter_docs = """**filter_kwargs : dict Certain keyword arguments for `scipy.signal` filters: filter_order : order - The order of the filter + The order of the filter. Note as filtering is applied with scipy's + `filtfilt` functions (i.e. acausal, zero-phase) the effective + order will be double the `filter_order`. filter_mode : "sos" | "ba", default: "sos" Filter form of the filter coefficients: - second-order sections ("sos") From 926afdbccafb3a6caf38aa411b4d80d4187afa56 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Wed, 26 Jun 2024 22:56:15 +0200 Subject: [PATCH 203/248] Adding option to overwrite --- src/spikeinterface/preprocessing/motion.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 8023bd4367..a98bdc171a 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -204,6 +204,7 @@ def correct_motion( recording, preset="nonrigid_accurate", folder=None, + overwrite=False, output_motion_info=False, detect_kwargs={}, select_kwargs={}, @@ -253,6 +254,8 @@ def correct_motion( The preset name folder : Path str or None, default: None If not None then intermediate motion info are saved into a folder + overwrite : bool, default False + If folder is not None and already existing, should we overwrite output_motion_info : bool, default: False If True, then the function returns a `motion_info` dictionary that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) @@ -316,6 +319,13 @@ def correct_motion( if folder is not None: folder = Path(folder) + if overwrite: + if folder.exists(): + import shutil + shutil.rmtree(folder) + else: + assert not folder.exists(), f"Folder {folder} already exists" + folder.mkdir(exist_ok=True, parents=True) (folder / "parameters.json").write_text(json.dumps(parameters, indent=4, cls=SIJsonEncoder), encoding="utf8") From cb957b838e06cf719e0ebb68fbf1ff1c08a118e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 26 Jun 2024 20:59:44 +0000 Subject: [PATCH 204/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/preprocessing/motion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index a98bdc171a..71ae3f3ebb 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,8 +320,9 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.exists(): + if folder.exists(): import shutil + shutil.rmtree(folder) else: assert not folder.exists(), f"Folder {folder} already exists" From a166e5a3d419c49aa6afc69f0e2f98ea7eb9d0c3 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 26 Jun 2024 15:33:51 -0600 Subject: [PATCH 205/248] add recording iterator --- src/spikeinterface/core/core_tools.py | 58 +++++++++++++++++-- src/spikeinterface/sorters/container_tools.py | 11 +--- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index f3d8b3df7f..3fe4939524 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -1,6 +1,6 @@ from __future__ import annotations from pathlib import Path, WindowsPath -from typing import Union +from typing import Union, Generator import os import sys import datetime @@ -8,6 +8,7 @@ from copy import deepcopy import importlib from math import prod +from collections import namedtuple import numpy as np @@ -183,6 +184,50 @@ def is_dict_extractor(d: dict) -> bool: return is_extractor +recording_dict_element = namedtuple(typename="recording_dict_element", field_names=["value", "name", "access_path"]) + + +def recording_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_element]: + """ + Iterator for recursive traversal of a dictionary. + This function explores the dictionary recursively and yields the path to each value along with the value itself. + + By path here we mean the keys that lead to the value in the dictionary: + e.g. for the dictionary {'a': {'b': 1}}, the path to the value 1 is ('a', 'b'). + + See `BaseExtractor.to_dict()` for a description of `extractor_dict` structure. + + Parameters + ---------- + extractor_dict : dict + Input dictionary + + Yields + ------ + recording_dict_element + Named tuple containing the value, the name, and the access_path to the value in the dictionary. + + """ + + def _recording_dict_iterator(dict_list_or_value, access_path=(), name=""): + if isinstance(dict_list_or_value, dict): + for k, v in dict_list_or_value.items(): + yield from _recording_dict_iterator(v, access_path + (k,), name=k) + elif isinstance(dict_list_or_value, list): + for i, v in enumerate(dict_list_or_value): + yield from _recording_dict_iterator( + v, access_path + (i,), name=name + ) # Propagate name of list to children + else: + yield recording_dict_element( + value=dict_list_or_value, + name=name, + access_path=access_path, + ) + + yield from _recording_dict_iterator(extractor_dict) + + def recursive_path_modifier(d, func, target="path", copy=True) -> dict: """ Generic function for recursive modification of paths in an extractor dict. @@ -250,15 +295,16 @@ def recursive_path_modifier(d, func, target="path", copy=True) -> dict: raise ValueError(f"{k} key for path must be str or list[str]") -def _get_paths_list(d): +def _get_paths_list(d: dict) -> list[str | Path]: # this explore a dict and get all paths flatten in a list # the trick is to use a closure func called by recursive_path_modifier() - path_list = [] - def append_to_path(p): - path_list.append(p) + element_is_path = lambda element: "path" in element.name and isinstance(element.value, (str, Path)) + path_list = [e.value for e in recording_dict_iterator(d) if element_is_path(e)] + + # if check_if_exists: TODO: Enable this once container_tools test uses proper mocks + # path_list = [p for p in path_list if Path(p).exists()] - recursive_path_modifier(d, append_to_path, target="path", copy=True) return path_list diff --git a/src/spikeinterface/sorters/container_tools.py b/src/spikeinterface/sorters/container_tools.py index 60eb080ae5..8e03090eaf 100644 --- a/src/spikeinterface/sorters/container_tools.py +++ b/src/spikeinterface/sorters/container_tools.py @@ -9,19 +9,14 @@ # TODO move this inside functions -from spikeinterface.core.core_tools import recursive_path_modifier +from spikeinterface.core.core_tools import recursive_path_modifier, _get_paths_list def find_recording_folders(d): """Finds all recording folders 'paths' in a dict""" - folders_to_mount = [] - def append_parent_folder(p): - p = Path(p) - folders_to_mount.append(p.resolve().absolute().parent) - return p - - _ = recursive_path_modifier(d, append_parent_folder, target="path", copy=True) + path_list = _get_paths_list(d=d) + folders_to_mount = [Path(p).resolve().parent for p in path_list] try: # this will fail if on different drives (Windows) base_folders_to_mount = [Path(os.path.commonpath(folders_to_mount))] From 27a7c9a96c2e8f008109c99d8dd90ac52ac5fd3e Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 26 Jun 2024 16:58:39 -0600 Subject: [PATCH 206/248] add and fix tests --- src/spikeinterface/core/core_tools.py | 83 ++++++++-- .../core/tests/test_core_tools.py | 153 ++++++++++++------ 2 files changed, 170 insertions(+), 66 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 3fe4939524..9e90b56c8d 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -187,7 +187,7 @@ def is_dict_extractor(d: dict) -> bool: recording_dict_element = namedtuple(typename="recording_dict_element", field_names=["value", "name", "access_path"]) -def recording_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_element]: +def extractor_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_element]: """ Iterator for recursive traversal of a dictionary. This function explores the dictionary recursively and yields the path to each value along with the value itself. @@ -209,13 +209,13 @@ def recording_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_el """ - def _recording_dict_iterator(dict_list_or_value, access_path=(), name=""): + def _extractor_dict_iterator(dict_list_or_value, access_path=(), name=""): if isinstance(dict_list_or_value, dict): for k, v in dict_list_or_value.items(): - yield from _recording_dict_iterator(v, access_path + (k,), name=k) + yield from _extractor_dict_iterator(v, access_path + (k,), name=k) elif isinstance(dict_list_or_value, list): for i, v in enumerate(dict_list_or_value): - yield from _recording_dict_iterator( + yield from _extractor_dict_iterator( v, access_path + (i,), name=name ) # Propagate name of list to children else: @@ -225,7 +225,32 @@ def _recording_dict_iterator(dict_list_or_value, access_path=(), name=""): access_path=access_path, ) - yield from _recording_dict_iterator(extractor_dict) + yield from _extractor_dict_iterator(extractor_dict) + + +def set_value_in_recording_dict(extractor_dict: dict, access_path: tuple, new_value): + """ + In place modification of a value in a nested dictionary given its access path. + + Parameters + ---------- + extractor_dict : dict + The dictionary to modify + access_path : tuple + The path to the value in the dictionary + new_value : object + The new value to set + + Returns + ------- + dict + The modified dictionary + """ + + current = extractor_dict + for key in access_path[:-1]: + current = current[key] + current[access_path[-1]] = new_value def recursive_path_modifier(d, func, target="path", copy=True) -> dict: @@ -295,12 +320,13 @@ def recursive_path_modifier(d, func, target="path", copy=True) -> dict: raise ValueError(f"{k} key for path must be str or list[str]") -def _get_paths_list(d: dict) -> list[str | Path]: - # this explore a dict and get all paths flatten in a list - # the trick is to use a closure func called by recursive_path_modifier() +# This is the current definition that an element in a recording_dict is a path +# This is shared across a couple of definition so it is here for DNRY +element_is_path = lambda element: "path" in element.name and isinstance(element.value, (str, Path)) + - element_is_path = lambda element: "path" in element.name and isinstance(element.value, (str, Path)) - path_list = [e.value for e in recording_dict_iterator(d) if element_is_path(e)] +def _get_paths_list(d: dict) -> list[str | Path]: + path_list = [e.value for e in extractor_dict_iterator(d) if element_is_path(e)] # if check_if_exists: TODO: Enable this once container_tools test uses proper mocks # path_list = [p for p in path_list if Path(p).exists()] @@ -364,7 +390,7 @@ def check_paths_relative(input_dict, relative_folder) -> bool: return len(not_possible) == 0 -def make_paths_relative(input_dict, relative_folder) -> dict: +def make_paths_relative(input_dict: dict, relative_folder: str | Path) -> dict: """ Recursively transform a dict describing an BaseExtractor to make every path relative to a folder. @@ -380,9 +406,22 @@ def make_paths_relative(input_dict, relative_folder) -> dict: output_dict: dict A copy of the input dict with modified paths. """ + relative_folder = Path(relative_folder).resolve().absolute() - func = lambda p: _relative_to(p, relative_folder) - output_dict = recursive_path_modifier(input_dict, func, target="path", copy=True) + + path_elements_in_dict = [e for e in extractor_dict_iterator(input_dict) if element_is_path(e)] + # Only paths that exist are made relative + path_elements_in_dict = [e for e in path_elements_in_dict if Path(e.value).exists()] + + output_dict = deepcopy(input_dict) + for element in path_elements_in_dict: + new_value = _relative_to(element.value, relative_folder) + set_value_in_recording_dict( + extractor_dict=output_dict, + access_path=element.access_path, + new_value=new_value, + ) + return output_dict @@ -405,12 +444,28 @@ def make_paths_absolute(input_dict, base_folder): base_folder = Path(base_folder) # use as_posix instead of str to make the path unix like even on window func = lambda p: (base_folder / p).resolve().absolute().as_posix() - output_dict = recursive_path_modifier(input_dict, func, target="path", copy=True) + + path_elements_in_dict = [e for e in extractor_dict_iterator(input_dict) if element_is_path(e)] + output_dict = deepcopy(input_dict) + + output_dict = deepcopy(input_dict) + for element in path_elements_in_dict: + absolute_path = (base_folder / element.value).resolve() + if Path(absolute_path).exists(): + new_value = absolute_path.as_posix() # Not so sure about this, Sam + set_value_in_recording_dict( + extractor_dict=output_dict, + access_path=element.access_path, + new_value=new_value, + ) + return output_dict def recursive_key_finder(d, key): # Find all values for a key on a dictionary, even if nested + # TODO refactor to use extractor_dict_iterator + for k, v in d.items(): if isinstance(v, dict): yield from recursive_key_finder(v, key) diff --git a/src/spikeinterface/core/tests/test_core_tools.py b/src/spikeinterface/core/tests/test_core_tools.py index 8e00dcb779..043e0cabf3 100644 --- a/src/spikeinterface/core/tests/test_core_tools.py +++ b/src/spikeinterface/core/tests/test_core_tools.py @@ -51,14 +51,9 @@ def test_path_utils_functions(): assert d2["kwargs"]["path"].startswith("/yop") assert d2["kwargs"]["recording"]["kwargs"]["path"].startswith("/yop") - d3 = make_paths_relative(d, Path("/yep")) - assert d3["kwargs"]["path"] == "sub/path1" - assert d3["kwargs"]["recording"]["kwargs"]["path"] == "sub/path2" - - d4 = make_paths_absolute(d3, "/yop") - assert d4["kwargs"]["path"].startswith("/yop") - assert d4["kwargs"]["recording"]["kwargs"]["path"].startswith("/yop") +@pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") +def test_relative_path_on_windows(): if platform.system() == "Windows": # test for windows Path d = { @@ -74,57 +69,111 @@ def test_path_utils_functions(): } } - d2 = make_paths_relative(d, "c:\\yep") - # the str be must unix like path even on windows for more portability - assert d2["kwargs"]["path"] == "sub/path1" - assert d2["kwargs"]["recording"]["kwargs"]["path"] == "sub/path2" - # same drive assert check_paths_relative(d, r"c:\yep") # not the same drive assert not check_paths_relative(d, r"d:\yep") - d = { - "kwargs": { - "path": r"\\host\share\yep\sub\path1", - } - } - # UNC cannot be relative to d: drive - assert not check_paths_relative(d, r"d:\yep") - # UNC can be relative to the same UNC - assert check_paths_relative(d, r"\\host\share") - - def test_convert_string_to_bytes(): - # Test SI prefixes - assert convert_string_to_bytes("1k") == 1000 - assert convert_string_to_bytes("1M") == 1000000 - assert convert_string_to_bytes("1G") == 1000000000 - assert convert_string_to_bytes("1T") == 1000000000000 - assert convert_string_to_bytes("1P") == 1000000000000000 - # Test IEC prefixes - assert convert_string_to_bytes("1Ki") == 1024 - assert convert_string_to_bytes("1Mi") == 1048576 - assert convert_string_to_bytes("1Gi") == 1073741824 - assert convert_string_to_bytes("1Ti") == 1099511627776 - assert convert_string_to_bytes("1Pi") == 1125899906842624 - # Test mixed values - assert convert_string_to_bytes("1.5k") == 1500 - assert convert_string_to_bytes("2.5M") == 2500000 - assert convert_string_to_bytes("0.5G") == 500000000 - assert convert_string_to_bytes("1.2T") == 1200000000000 - assert convert_string_to_bytes("1.5Pi") == 1688849860263936 - # Test zero values - assert convert_string_to_bytes("0k") == 0 - assert convert_string_to_bytes("0Ki") == 0 - # Test invalid inputs (should raise assertion error) - with pytest.raises(AssertionError) as e: - convert_string_to_bytes("1Z") - assert str(e.value) == "Unknown suffix: Z" - - with pytest.raises(AssertionError) as e: - convert_string_to_bytes("1Xi") - assert str(e.value) == "Unknown suffix: Xi" +@pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") +def test_universal_naming_convention(): + d = { + "kwargs": { + "path": r"\\host\share\yep\sub\path1", + } + } + # UNC cannot be relative to d: drive + assert not check_paths_relative(d, r"d:\yep") + + # UNC can be relative to the same UNC + assert check_paths_relative(d, r"\\host\share") + + +def test_make_paths_relative(tmp_path): + + path_1 = tmp_path / "sub" / "path1" + path_2 = tmp_path / "sub" / "path2" + + # Create the objects in the path + path_1.mkdir(parents=True, exist_ok=True) + path_2.mkdir(parents=True, exist_ok=True) + extractor_dict = { + "kwargs": { + "path": str(path_1), # Note this is different in windows and posix + "electrical_series_path": "/acquisition/timeseries", # non-existent path-like objects should not be modified + "recording": { + "module": "mock_module", + "class": "mock_class", + "version": "1.2", + "annotations": {}, + "kwargs": {"path": str(path_2)}, + }, + } + } + modified_extractor_dict = make_paths_relative(extractor_dict, tmp_path) + assert modified_extractor_dict["kwargs"]["path"] == "sub/path1" + assert modified_extractor_dict["kwargs"]["recording"]["kwargs"]["path"] == "sub/path2" + assert modified_extractor_dict["kwargs"]["electrical_series_path"] == "/acquisition/timeseries" + + +def test_make_paths_absolute(tmp_path): + + path_1 = tmp_path / "sub" / "path1" + path_2 = tmp_path / "sub" / "path2" + + path_1.mkdir(parents=True, exist_ok=True) + path_2.mkdir(parents=True, exist_ok=True) + + extractor_dict = { + "kwargs": { + "path": "sub/path1", + "electrical_series_path": "/acquisition/timeseries", # non-existent path-like objects should not be modified + "recording": { + "module": "mock_module", + "class": "mock_class", + "version": "1.2", + "annotations": {}, + "kwargs": {"path": "sub/path2"}, + }, + } + } + + modified_extractor_dict = make_paths_absolute(extractor_dict, tmp_path) + assert modified_extractor_dict["kwargs"]["path"].startswith(str(tmp_path)) + assert modified_extractor_dict["kwargs"]["recording"]["kwargs"]["path"].startswith(str(tmp_path)) + assert modified_extractor_dict["kwargs"]["electrical_series_path"] == "/acquisition/timeseries" + + +def test_convert_string_to_bytes(): + # Test SI prefixes + assert convert_string_to_bytes("1k") == 1000 + assert convert_string_to_bytes("1M") == 1000000 + assert convert_string_to_bytes("1G") == 1000000000 + assert convert_string_to_bytes("1T") == 1000000000000 + assert convert_string_to_bytes("1P") == 1000000000000000 + # Test IEC prefixes + assert convert_string_to_bytes("1Ki") == 1024 + assert convert_string_to_bytes("1Mi") == 1048576 + assert convert_string_to_bytes("1Gi") == 1073741824 + assert convert_string_to_bytes("1Ti") == 1099511627776 + assert convert_string_to_bytes("1Pi") == 1125899906842624 + # Test mixed values + assert convert_string_to_bytes("1.5k") == 1500 + assert convert_string_to_bytes("2.5M") == 2500000 + assert convert_string_to_bytes("0.5G") == 500000000 + assert convert_string_to_bytes("1.2T") == 1200000000000 + assert convert_string_to_bytes("1.5Pi") == 1688849860263936 + # Test zero values + assert convert_string_to_bytes("0k") == 0 + assert convert_string_to_bytes("0Ki") == 0 + # Test invalid inputs (should raise assertion error) + with pytest.raises(AssertionError) as e: + convert_string_to_bytes("1Z") + assert str(e.value) == "Unknown suffix: Z" + + with pytest.raises(AssertionError) as e: + convert_string_to_bytes("1Xi") + assert str(e.value) == "Unknown suffix: Xi" def test_normal_pdf() -> None: From b3b85b2fe5670217d80c4adec1a751d1e1d5d024 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Wed, 26 Jun 2024 17:21:45 -0600 Subject: [PATCH 207/248] naming --- src/spikeinterface/core/core_tools.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 9e90b56c8d..d5480d6f00 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -228,7 +228,7 @@ def _extractor_dict_iterator(dict_list_or_value, access_path=(), name=""): yield from _extractor_dict_iterator(extractor_dict) -def set_value_in_recording_dict(extractor_dict: dict, access_path: tuple, new_value): +def set_value_in_extractor_dict(extractor_dict: dict, access_path: tuple, new_value): """ In place modification of a value in a nested dictionary given its access path. @@ -416,7 +416,7 @@ def make_paths_relative(input_dict: dict, relative_folder: str | Path) -> dict: output_dict = deepcopy(input_dict) for element in path_elements_in_dict: new_value = _relative_to(element.value, relative_folder) - set_value_in_recording_dict( + set_value_in_extractor_dict( extractor_dict=output_dict, access_path=element.access_path, new_value=new_value, @@ -453,7 +453,7 @@ def make_paths_absolute(input_dict, base_folder): absolute_path = (base_folder / element.value).resolve() if Path(absolute_path).exists(): new_value = absolute_path.as_posix() # Not so sure about this, Sam - set_value_in_recording_dict( + set_value_in_extractor_dict( extractor_dict=output_dict, access_path=element.access_path, new_value=new_value, From d794c8220e9e2ed2431636e53aee9b7b8d6b998b Mon Sep 17 00:00:00 2001 From: h-mayorquin Date: Thu, 27 Jun 2024 00:39:58 -0600 Subject: [PATCH 208/248] windows test remove inner conditional --- .../core/tests/test_core_tools.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/spikeinterface/core/tests/test_core_tools.py b/src/spikeinterface/core/tests/test_core_tools.py index 043e0cabf3..ed13bd46fd 100644 --- a/src/spikeinterface/core/tests/test_core_tools.py +++ b/src/spikeinterface/core/tests/test_core_tools.py @@ -54,25 +54,24 @@ def test_path_utils_functions(): @pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") def test_relative_path_on_windows(): - if platform.system() == "Windows": - # test for windows Path - d = { - "kwargs": { - "path": r"c:\yep\sub\path1", - "recording": { - "module": "mock_module", - "class": "mock_class", - "version": "1.2", - "annotations": {}, - "kwargs": {"path": r"c:\yep\sub\path2"}, - }, - } + + d = { + "kwargs": { + "path": r"c:\yep\sub\path1", + "recording": { + "module": "mock_module", + "class": "mock_class", + "version": "1.2", + "annotations": {}, + "kwargs": {"path": r"c:\yep\sub\path2"}, + }, } + } - # same drive - assert check_paths_relative(d, r"c:\yep") - # not the same drive - assert not check_paths_relative(d, r"d:\yep") + # same drive + assert check_paths_relative(d, r"c:\yep") + # not the same drive + assert not check_paths_relative(d, r"d:\yep") @pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") @@ -139,8 +138,8 @@ def test_make_paths_absolute(tmp_path): } modified_extractor_dict = make_paths_absolute(extractor_dict, tmp_path) - assert modified_extractor_dict["kwargs"]["path"].startswith(str(tmp_path)) - assert modified_extractor_dict["kwargs"]["recording"]["kwargs"]["path"].startswith(str(tmp_path)) + assert modified_extractor_dict["kwargs"]["path"].startswith(str(tmp_path.as_posix())) + assert modified_extractor_dict["kwargs"]["recording"]["kwargs"]["path"].startswith(str(tmp_path.as_posix())) assert modified_extractor_dict["kwargs"]["electrical_series_path"] == "/acquisition/timeseries" From c1e4eee519c289899f2650d98e6210d631ae42f2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Jun 2024 00:41:00 +0000 Subject: [PATCH 209/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/core/tests/test_core_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/tests/test_core_tools.py b/src/spikeinterface/core/tests/test_core_tools.py index ed13bd46fd..724517577c 100644 --- a/src/spikeinterface/core/tests/test_core_tools.py +++ b/src/spikeinterface/core/tests/test_core_tools.py @@ -54,7 +54,7 @@ def test_path_utils_functions(): @pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") def test_relative_path_on_windows(): - + d = { "kwargs": { "path": r"c:\yep\sub\path1", From 0d993421fc2f4bb6e35facf25164b3a370d28c03 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 27 Jun 2024 00:58:09 -0600 Subject: [PATCH 210/248] Add machinery to run test only on changed files (#3084) Improve full tests and file changed machinery Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alessio Buccino --- .github/determine_testing_environment.py | 118 ++++++++++++++ .github/workflows/all-tests.yml | 150 ++++++++++++++---- pyproject.toml | 5 + .../tests/test_templatecomparison.py | 7 +- 4 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 .github/determine_testing_environment.py diff --git a/.github/determine_testing_environment.py b/.github/determine_testing_environment.py new file mode 100644 index 0000000000..4945ccc807 --- /dev/null +++ b/.github/determine_testing_environment.py @@ -0,0 +1,118 @@ +from pathlib import Path +import argparse +import os + + +# We get the list of files change as an input +parser = argparse.ArgumentParser() +parser.add_argument("changed_files_in_the_pull_request", nargs="*", help="List of changed files") +args = parser.parse_args() + +changed_files_in_the_pull_request = args.changed_files_in_the_pull_request +changed_files_in_the_pull_request_paths = [Path(file) for file in changed_files_in_the_pull_request] + +# We assume nothing has been changed + +core_changed = False +pyproject_toml_changed = False +neobaseextractor_changed = False +extractors_changed = False +plexon2_changed = False +preprocessing_changed = False +postprocessing_changed = False +qualitymetrics_changed = False +sorters_changed = False +sorters_external_changed = False +sorters_internal_changed = False +comparison_changed = False +curation_changed = False +widgets_changed = False +exporters_changed = False +sortingcomponents_changed = False +generation_changed = False + + +for changed_file in changed_files_in_the_pull_request_paths: + + file_is_in_src = changed_file.parts[0] == "src" + + if not file_is_in_src: + + if changed_file.name == "pyproject.toml": + pyproject_toml_changed = True + + else: + if changed_file.name == "neobaseextractor.py": + neobaseextractor_changed = True + elif changed_file.name == "plexon2.py": + extractors_changed = True + elif "core" in changed_file.parts: + conditions_changed = True + elif "extractors" in changed_file.parts: + extractors_changed = True + elif "preprocessing" in changed_file.parts: + preprocessing_changed = True + elif "postprocessing" in changed_file.parts: + postprocessing_changed = True + elif "qualitymetrics" in changed_file.parts: + qualitymetrics_changed = True + elif "comparison" in changed_file.parts: + comparison_changed = True + elif "curation" in changed_file.parts: + curation_changed = True + elif "widgets" in changed_file.parts: + widgets_changed = True + elif "exporters" in changed_file.parts: + exporters_changed = True + elif "sortingcomponents" in changed_file.parts: + sortingcomponents_changed = True + elif "generation" in changed_file.parts: + generation_changed = True + elif "sorters" in changed_file.parts: + if "external" in changed_file.parts: + sorters_external_changed = True + elif "internal" in changed_file.parts: + sorters_internal_changed = True + else: + sorters_changed = True + + +run_everything = core_changed or pyproject_toml_changed or neobaseextractor_changed +run_generation_tests = run_everything or generation_changed +run_extractor_tests = run_everything or extractors_changed +run_preprocessing_tests = run_everything or preprocessing_changed +run_postprocessing_tests = run_everything or postprocessing_changed +run_qualitymetrics_tests = run_everything or qualitymetrics_changed +run_curation_tests = run_everything or curation_changed +run_sortingcomponents_tests = run_everything or sortingcomponents_changed + +run_comparison_test = run_everything or run_generation_tests or comparison_changed +run_widgets_test = run_everything or run_qualitymetrics_tests or run_preprocessing_tests or widgets_changed +run_exporters_test = run_everything or run_widgets_test or exporters_changed + +run_sorters_test = run_everything or sorters_changed +run_internal_sorters_test = run_everything or run_sortingcomponents_tests or sorters_internal_changed + +install_plexon_dependencies = plexon2_changed + +environment_varaiables_to_add = { + "RUN_EXTRACTORS_TESTS": run_extractor_tests, + "RUN_PREPROCESSING_TESTS": run_preprocessing_tests, + "RUN_POSTPROCESSING_TESTS": run_postprocessing_tests, + "RUN_QUALITYMETRICS_TESTS": run_qualitymetrics_tests, + "RUN_CURATION_TESTS": run_curation_tests, + "RUN_SORTINGCOMPONENTS_TESTS": run_sortingcomponents_tests, + "RUN_GENERATION_TESTS": run_generation_tests, + "RUN_COMPARISON_TESTS": run_comparison_test, + "RUN_WIDGETS_TESTS": run_widgets_test, + "RUN_EXPORTERS_TESTS": run_exporters_test, + "RUN_SORTERS_TESTS": run_sorters_test, + "RUN_INTERNAL_SORTERS_TESTS": run_internal_sorters_test, + "INSTALL_PLEXON_DEPENDENCIES": install_plexon_dependencies, +} + +# Write the conditions to the GITHUB_ENV file +env_file = os.getenv("GITHUB_ENV") +with open(env_file, "a") as f: + for key, value in environment_varaiables_to_add.items(): + f.write(f"{key}={value}\n") diff --git a/.github/workflows/all-tests.yml b/.github/workflows/all-tests.yml index 1c426ba11c..cce73a9008 100644 --- a/.github/workflows/all-tests.yml +++ b/.github/workflows/all-tests.yml @@ -32,14 +32,64 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - # cache: 'pip' # caching pip dependencies - - name: Get current hash (SHA) of the ephy_testing_data repo - id: repo_hash + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v41 + + - name: List all changed files + shell: bash + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + run: | + for file in ${ALL_CHANGED_FILES}; do + echo "$file was changed" + done + + - name: Set testing environment # This decides which tests are run and whether to install especial dependencies + shell: bash + run: | + changed_files="${{ steps.changed-files.outputs.all_changed_files }}" + python .github/determine_testing_environment.py $changed_files + + - name: Display testing environment + shell: bash + run: | + echo "RUN_EXTRACTORS_TESTS=${RUN_EXTRACTORS_TESTS}" + echo "RUN_PREPROCESSING_TESTS=${RUN_PREPROCESSING_TESTS}" + echo "RUN_POSTPROCESSING_TESTS=${RUN_POSTPROCESSING_TESTS}" + echo "RUN_QUALITYMETRICS_TESTS=${RUN_QUALITYMETRICS_TESTS}" + echo "RUN_CURATION_TESTS=${RUN_CURATION_TESTS}" + echo "RUN_SORTINGCOMPONENTS_TESTS=${RUN_SORTINGCOMPONENTS_TESTS}" + echo "RUN_GENERATION_TESTS=${RUN_GENERATION_TESTS}" + echo "RUN_COMPARISON_TESTS=${RUN_COMPARISON_TESTS}" + echo "RUN_WIDGETS_TESTS=${RUN_WIDGETS_TESTS}" + echo "RUN_EXPORTERS_TESTS=${RUN_EXPORTERS_TESTS}" + echo "RUN_SORTERS_TESTS=${RUN_SORTERS_TESTS}" + echo "RUN_INTERNAL_SORTERS_TESTS=${RUN_INTERNAL_SORTERS_TESTS}" + echo "INSTALL_PLEXON_DEPENDENCIES=${INSTALL_PLEXON_DEPENDENCIES}" + + - name: Install packages run: | - echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" - echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT + pip install -e .[test_core] shell: bash + + - name: Test core + run: pytest -m "core" + shell: bash + + - name: Install Other Testing Dependencies + run: | + pip install -e .[test] + pip install tabulate + pip install pandas + shell: bash + + - name: Get current hash (SHA) of the ephy_testing_data repo + shell: bash + id: repo_hash + run: echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT + - name: Cache datasets id: cache-datasets uses: actions/cache/restore@v4 @@ -48,82 +98,114 @@ jobs: key: ${{ runner.os }}-datasets-${{ steps.repo_hash.outputs.dataset_hash }} restore-keys: ${{ runner.os }}-datasets - - name: Install packages - run: | - git config --global user.email "CI@example.com" - git config --global user.name "CI Almighty" - pip install -e .[test,extractors,streaming_extractors,full] - pip install tabulate + - name: Install git-annex shell: bash - - - name: Installad datalad + if: env.RUN_EXTRACTORS_TESTS == 'true' run: | pip install datalad-installer if [ ${{ runner.os }} = 'Linux' ]; then - datalad-installer --sudo ok git-annex --method datalad/packages + wget https://downloads.kitenet.net/git-annex/linux/current/git-annex-standalone-amd64.tar.gz + mkdir /home/runner/work/installation + mv git-annex-standalone-amd64.tar.gz /home/runner/work/installation/ + workdir=$(pwd) + cd /home/runner/work/installation + tar xvzf git-annex-standalone-amd64.tar.gz + echo "$(pwd)/git-annex.linux" >> $GITHUB_PATH + cd $workdir elif [ ${{ runner.os }} = 'macOS' ]; then datalad-installer --sudo ok git-annex --method brew elif [ ${{ runner.os }} = 'Windows' ]; then datalad-installer --sudo ok git-annex --method datalad/git-annex:release fi - pip install datalad git config --global filter.annex.process "git-annex filter-process" # recommended for efficiency - shell: bash - - name: Set execute permissions on run_tests.sh - run: chmod +x .github/run_tests.sh - shell: bash - - name: Test core - run: pytest -m "core" + - name: Set execute permissions on run_tests.sh shell: bash + run: chmod +x .github/run_tests.sh - name: Test extractors + shell: bash env: HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell - run: pytest -m "extractors" - shell: bash + if: env.RUN_EXTRACTORS_TESTS == 'true' + run: | + pip install -e .[extractors,streaming_extractors] + ./.github/run_tests.sh "extractors and not streaming_extractors" --no-virtual-env - name: Test preprocessing - run: ./.github/run_tests.sh "preprocessing and not deepinterpolation" --no-virtual-env shell: bash + if: env.RUN_PREPROCESSING_TESTS == 'true' + run: | + pip install -e .[preprocessing] + ./.github/run_tests.sh "preprocessing and not deepinterpolation" --no-virtual-env - name: Test postprocessing - run: ./.github/run_tests.sh postprocessing --no-virtual-env shell: bash + if: env.RUN_POSTPROCESSING_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh postprocessing --no-virtual-env - name: Test quality metrics - run: ./.github/run_tests.sh qualitymetrics --no-virtual-env shell: bash + if: env.RUN_QUALITYMETRICS_TESTS == 'true' + run: | + pip install -e .[qualitymetrics] + ./.github/run_tests.sh qualitymetrics --no-virtual-env - name: Test comparison - run: ./.github/run_tests.sh comparison --no-virtual-env shell: bash + if: env.RUN_COMPARISON_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh comparison --no-virtual-env - name: Test core sorters - run: ./.github/run_tests.sh sorters --no-virtual-env shell: bash + if: env.RUN_SORTERS_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh sorters --no-virtual-env - name: Test internal sorters - run: ./.github/run_tests.sh sorters_internal --no-virtual-env shell: bash + if: env.RUN_INTERNAL_SORTERS_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh sorters_internal --no-virtual-env - name: Test curation - run: ./.github/run_tests.sh curation --no-virtual-env shell: bash + if: env.RUN_CURATION_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh curation --no-virtual-env - name: Test widgets - run: ./.github/run_tests.sh widgets --no-virtual-env shell: bash + if: env.RUN_WIDGETS_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh widgets --no-virtual-env - name: Test exporters - run: ./.github/run_tests.sh exporters --no-virtual-env shell: bash + if: env.RUN_EXPORTERS_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh exporters --no-virtual-env - name: Test sortingcomponents - run: ./.github/run_tests.sh sortingcomponents --no-virtual-env shell: bash + if: env.RUN_SORTINGCOMPONENTS_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh sortingcomponents --no-virtual-env - name: Test generation - run: ./.github/run_tests.sh generation --no-virtual-env shell: bash + if: env.RUN_GENERATION_TESTS == 'true' + run: | + pip install -e .[full] + ./.github/run_tests.sh generation --no-virtual-env diff --git a/pyproject.toml b/pyproject.toml index b26337ad01..72bf376a31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,11 @@ streaming_extractors = [ "s3fs" ] +preprocessing = [ + "scipy", +] + + full = [ "h5py", "pandas", diff --git a/src/spikeinterface/comparison/tests/test_templatecomparison.py b/src/spikeinterface/comparison/tests/test_templatecomparison.py index 871bdeaed3..a7f30cfc45 100644 --- a/src/spikeinterface/comparison/tests/test_templatecomparison.py +++ b/src/spikeinterface/comparison/tests/test_templatecomparison.py @@ -16,7 +16,12 @@ def test_compare_multiple_templates(): duration = 60 num_channels = 8 - rec, sort = generate_ground_truth_recording(durations=[duration], num_channels=num_channels) + seed = 0 + rec, sort = generate_ground_truth_recording( + durations=[duration], + num_channels=num_channels, + seed=seed, + ) # split recording in 3 equal slices fs = rec.get_sampling_frequency() From efede134e52a0a01e1665cffb5543a696673b525 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Thu, 27 Jun 2024 08:50:11 +0100 Subject: [PATCH 211/248] use_names_as_ids update --- src/spikeinterface/extractors/neoextractors/blackrock.py | 5 +++-- src/spikeinterface/extractors/neoextractors/intan.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/extractors/neoextractors/blackrock.py b/src/spikeinterface/extractors/neoextractors/blackrock.py index 0015fd9f67..ab3710e05e 100644 --- a/src/spikeinterface/extractors/neoextractors/blackrock.py +++ b/src/spikeinterface/extractors/neoextractors/blackrock.py @@ -26,8 +26,9 @@ class BlackrockRecordingExtractor(NeoBaseRecordingExtractor): If there are several streams, specify the stream name you want to load. all_annotations : bool, default: False Load exhaustively all annotations from neo. - use_names_as_ids : bool or None, default: None - If True, use channel names as IDs. If None, use default IDs. + use_names_as_ids : bool, default: False + If False, use default IDs inherited from Neo. If True, use channel names as IDs. + """ mode = "file" diff --git a/src/spikeinterface/extractors/neoextractors/intan.py b/src/spikeinterface/extractors/neoextractors/intan.py index 50fda79123..43439b80c9 100644 --- a/src/spikeinterface/extractors/neoextractors/intan.py +++ b/src/spikeinterface/extractors/neoextractors/intan.py @@ -28,7 +28,9 @@ class IntanRecordingExtractor(NeoBaseRecordingExtractor): check we perform is that timestamps are continuous. Setting this to True will ignore this check and set the attribute `discontinuous_timestamps` to True in the underlying neo object. use_names_as_ids : bool, default: False - If True, use channel names as IDs. If False, use default IDs inherited from neo. + If False, use default IDs inherited from Neo. If True, use channel names as IDs. + + """ mode = "file" From 713a6612af89db7983f621bd03de1b5b22a754de Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 10:16:57 +0200 Subject: [PATCH 212/248] Add ibllib to pteprocessing requirements --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 72bf376a31..c801f1f735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ streaming_extractors = [ preprocessing = [ "scipy", + "ibllib>=2.36.0", # for IBL preprocessing ] From dbe3ef2b095af84b4ab8ebc0b0396b97de576ef0 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 10:26:21 +0200 Subject: [PATCH 213/248] Move iblibb in test dependencies --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c801f1f735..69f4067d13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,6 @@ streaming_extractors = [ preprocessing = [ "scipy", - "ibllib>=2.36.0", # for IBL preprocessing ] @@ -137,6 +136,9 @@ test = [ "xarray", "huggingface_hub", + # preprocessing + "ibllib>=2.36.0", # for IBL + # tridesclous "numba", "hdbscan>=0.8.33", # Previous version had a broken wheel From 1aa036885b3fefc3bf8440ee2a7cd71295badf0f Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:04:03 +0200 Subject: [PATCH 214/248] Move drift_raster_map to motion, typing, docs, and tests --- src/spikeinterface/widgets/driftmap.py | 143 --------- src/spikeinterface/widgets/motion.py | 284 +++++++++++++++--- .../widgets/tests/test_widgets.py | 48 +-- src/spikeinterface/widgets/widget_list.py | 7 +- 4 files changed, 266 insertions(+), 216 deletions(-) delete mode 100644 src/spikeinterface/widgets/driftmap.py diff --git a/src/spikeinterface/widgets/driftmap.py b/src/spikeinterface/widgets/driftmap.py deleted file mode 100644 index 60e8df2972..0000000000 --- a/src/spikeinterface/widgets/driftmap.py +++ /dev/null @@ -1,143 +0,0 @@ -from __future__ import annotations - -import numpy as np - -from .base import BaseWidget, to_attr - - -class DriftMapWidget(BaseWidget): - """ - Plot the a drift map from a motion info dictionary. - - Parameters - ---------- - peaks : np.array - The peaks array, with dtype ("sample_index", "channel_index", "amplitude", "segment_index") - peak_locations : np.array - The peak locations, with dtype ("x", "y") or ("x", "y", "z") - direction : "x" or "y", default: "y" - The direction to display - segment_index : int, default: None - The segment index to display. - recording : RecordingExtractor, default: None - The recording extractor object (only used to get "real" times) - segment_index : int, default: 0 - The segment index to display. - sampling_frequency : float, default: None - The sampling frequency (needed if recording is None) - depth_lim : tuple or None, default: None - The min and max depth to display, if None (min and max of the recording) - color_amplitude : bool, default: True - If True, the color of the scatter points is the amplitude of the peaks - scatter_decimate : int, default: None - If > 1, the scatter points are decimated - cmap : str, default: "inferno" - The colormap to use for the amplitude - clim : tuple or None, default: None - The min and max amplitude to display, if None (min and max of the amplitudes) - alpha : float, default: 1 - The alpha of the scatter points - """ - - def __init__( - self, - peaks, - peak_locations, - direction="y", - recording=None, - sampling_frequency=None, - segment_index=None, - depth_lim=None, - color_amplitude=True, - scatter_decimate=None, - cmap="inferno", - clim=None, - alpha=1, - backend=None, - **backend_kwargs, - ): - if segment_index is None: - assert ( - len(np.unique(peaks["segment_index"])) == 1 - ), "segment_index must be specified if there is only one segment in the peaks array" - assert recording or sampling_frequency, "recording or sampling_frequency must be specified" - if recording is not None: - sampling_frequency = recording.sampling_frequency - times = recording.get_times(segment_index=segment_index) - else: - times = None - - plot_data = dict( - peaks=peaks, - peak_locations=peak_locations, - direction=direction, - times=times, - sampling_frequency=sampling_frequency, - segment_index=segment_index, - depth_lim=depth_lim, - color_amplitude=color_amplitude, - scatter_decimate=scatter_decimate, - cmap=cmap, - clim=clim, - alpha=alpha, - recording=recording, - ) - BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) - - def plot_matplotlib(self, data_plot, **backend_kwargs): - import matplotlib.pyplot as plt - from .utils_matplotlib import make_mpl_figure - from matplotlib.colors import Normalize - - from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks - - dp = to_attr(data_plot) - - assert backend_kwargs["axes"] is None, "axes argument is not allowed in MotionWidget" - - self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) - fig = self.figure - - if dp.times is None: - # temporal_bins_plot = dp.temporal_bins - x = dp.peaks["sample_index"] / dp.sampling_frequency - else: - # use real times and adjust temporal bins with t_start - # temporal_bins_plot = dp.temporal_bins + dp.times[0] - x = dp.times[dp.peaks["sample_index"]] - - y = dp.peak_locations[dp.direction] - if dp.scatter_decimate is not None: - x = x[:: dp.scatter_decimate] - y = y[:: dp.scatter_decimate] - y2 = y2[:: dp.scatter_decimate] - - if dp.color_amplitude: - amps = dp.peaks["amplitude"] - amps_abs = np.abs(amps) - q_95 = np.quantile(amps_abs, 0.95) - if dp.scatter_decimate is not None: - amps = amps[:: dp.scatter_decimate] - amps_abs = amps_abs[:: dp.scatter_decimate] - cmap = plt.colormaps[dp.cmap] - if dp.clim is None: - amps = amps_abs - amps /= q_95 - c = cmap(amps) - else: - norm_function = Normalize(vmin=dp.clim[0], vmax=dp.clim[1], clip=True) - c = cmap(norm_function(amps)) - color_kwargs = dict( - color=None, - c=c, - alpha=dp.alpha, - ) - else: - color_kwargs = dict(color="k", c=None, alpha=dp.alpha) - - self.ax.scatter(x, y, s=1, **color_kwargs) - if dp.depth_lim is not None: - self.ax.set_ylim(*dp.depth_lim) - self.ax.set_title("Peak depth") - self.ax.set_xlabel("Times [s]") - self.ax.set_ylabel("Depth [$\\mu$m]") diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 7d733523df..ee1599822f 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -3,31 +3,32 @@ import numpy as np from .base import BaseWidget, to_attr -from .driftmap import DriftMapWidget + +from spikeinterface.core import BaseRecording, SortingAnalyzer +from spikeinterface.sortingcomponents.motion_utils import Motion class MotionWidget(BaseWidget): """ - Plot the Motion object + Plot the Motion object. Parameters ---------- motion : Motion - The motion object - segment_index : None | int - If Motion is multi segment, the must be not None - mode : "auto" | "line" | "map" - How to plot map or lines. - "auto" make it automatic if the number of depth is too high. + The motion object. + segment_index : int | None, default: None + If Motion is multi segment, the must be not None. + mode : "auto" | "line" | "map", default: "line" + How to plot map or lines. "auto" makes it automatic if the number of motion depths is too high. """ def __init__( self, - motion, - segment_index=None, - mode="line", - motion_lim=None, - backend=None, + motion: Motion, + segment_index: int | None = None, + mode: str = "line", + motion_lim: float | None = None, + backend: str | None = None, **backend_kwargs, ): if isinstance(motion, dict): @@ -51,19 +52,15 @@ def __init__( BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) def plot_matplotlib(self, data_plot, **backend_kwargs): - import matplotlib.pyplot as plt from .utils_matplotlib import make_mpl_figure - from matplotlib.colors import Normalize dp = to_attr(data_plot) - motion = data_plot["motion"] - segment_index = data_plot["segment_index"] - assert backend_kwargs["axes"] is None self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) + motion = dp.motion displacement = motion.displacement[dp.segment_index] temporal_bins_s = motion.temporal_bins_s[dp.segment_index] depth = motion.spatial_bins_um @@ -97,55 +94,241 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): ax.set_ylabel("Depth [um]") +class DriftRasterMapWidget(BaseWidget): + """ + Plot the drift raster map from peaks or a SortingAnalyzer. + + Parameters + ---------- + peaks : np.array | None, default: None + The peaks array, with dtype ("sample_index", "channel_index", "amplitude", "segment_index"), + as returned by the `detect_peaks` or `correct_motion` functions. + peak_locations : np.array | None, default: None + The peak locations, with dtype ("x", "y") or ("x", "y", "z"), as returned by the + `localize_peaks` or `correct_motion` functions. + sorting_analyzer : SortingAnalyzer | None, default: None + The sorting analyzer object. To use this function, the `SortingAnalyzer` must have the + "spike_locations" extension computed. + direction : "x" or "y", default: "y" + The direction to display. + segment_index : int, default: None + The segment index to display. + recording : RecordingExtractor | None, default: None + The recording extractor object (only used to get "real" times). + segment_index : int, default: 0 + The segment index to display. + sampling_frequency : float, default: None + The sampling frequency (needed if recording is None). + depth_lim : tuple or None, default: None + The min and max depth to display, if None (min and max of the recording). + scatter_decimate : int, default: None + If > 1, the scatter points are decimated. + color_amplitude : bool, default: True + If True, the color of the scatter points is the amplitude of the peaks. + cmap : str, default: "inferno" + The colormap to use for the amplitude. + color : str, default: "Gray" + The color of the scatter points if color_amplitude is False. + clim : tuple or None, default: None + The min and max amplitude to display, if None (min and max of the amplitudes). + alpha : float, default: 1 + The alpha of the scatter points. + """ + + def __init__( + self, + peaks: np.array | None = None, + peak_locations: np.array | None = None, + sorting_analyzer: SortingAnalyzer | None = None, + direction: str = "y", + recording: BaseRecording | None = None, + sampling_frequency: float | None = None, + segment_index: int | None = None, + depth_lim: tuple[float, float] | None = None, + color_amplitude: bool = True, + scatter_decimate: int | None = None, + cmap: str = "inferno", + color: str = "Gray", + clim: tuple[float, float] | None = None, + alpha: float = 1, + backend: str | None = None, + **backend_kwargs, + ): + assert peaks is not None or sorting_analyzer is not None + if peaks is not None: + assert peak_locations is not None + if recording is None: + assert sampling_frequency is not None, "If recording is None, you must provide the sampling frequency" + else: + sampling_frequency = recording.sampling_frequency + peak_amplitudes = peaks["amplitude"] + if sorting_analyzer is not None: + if sorting_analyzer.has_recording(): + recording = sorting_analyzer.recording + else: + recording = None + sampling_frequency = sorting_analyzer.sampling_frequency + peaks = sorting_analyzer.sorting.to_spike_vector() + assert sorting_analyzer.has_extension( + "spike_locations" + ), "The sorting analyzer must have the 'spike_locations' extension to use this function" + peak_locations = sorting_analyzer.get_extension("spike_locations").get_data() + if color_amplitude: + assert sorting_analyzer.has_extension("spike_amplitudes"), ( + "The sorting analyzer must have the 'spike_amplitudes' extension to use color_amplitude=True. " + "You can compute it or set color_amplitude=False." + ) + if sorting_analyzer.has_extension("spike_amplitudes"): + peak_amplitudes = sorting_analyzer.get_extension("spike_amplitudes").get_data() + else: + peak_amplitudes = None + times = recording.get_times(segment_index=segment_index) if recording is not None else None + + if segment_index is None: + assert ( + len(np.unique(peaks["segment_index"])) == 1 + ), "segment_index must be specified if there is only one segment in the peaks array" + segment_index = 0 + else: + peak_mask = peaks["segment_index"] == segment_index + peaks = peaks[peak_mask] + peak_locations = peak_locations[peak_mask] + if peak_amplitudes is not None: + peak_amplitudes = peak_amplitudes[peak_mask] + + if recording is not None: + sampling_frequency = recording.sampling_frequency + times = recording.get_times(segment_index=segment_index) + else: + times = None + + plot_data = dict( + peaks=peaks, + peak_locations=peak_locations, + peak_amplitudes=peak_amplitudes, + direction=direction, + times=times, + sampling_frequency=sampling_frequency, + segment_index=segment_index, + depth_lim=depth_lim, + color_amplitude=color_amplitude, + color=color, + scatter_decimate=scatter_decimate, + cmap=cmap, + clim=clim, + alpha=alpha, + recording=recording, + ) + BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) + + def plot_matplotlib(self, data_plot, **backend_kwargs): + import matplotlib.pyplot as plt + from matplotlib.colors import Normalize + from .utils_matplotlib import make_mpl_figure + + from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks + + dp = to_attr(data_plot) + + assert backend_kwargs["axes"] is None, "axes argument is not allowed in MotionWidget" + + self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) + fig = self.figure + + if dp.times is None: + x = dp.peaks["sample_index"] / dp.sampling_frequency + else: + x = dp.times[dp.peaks["sample_index"]] + + y = dp.peak_locations[dp.direction] + if dp.scatter_decimate is not None: + x = x[:: dp.scatter_decimate] + y = y[:: dp.scatter_decimate] + y2 = y2[:: dp.scatter_decimate] + + if dp.color_amplitude: + amps = dp.peak_amplitudes + amps_abs = np.abs(amps) + q_95 = np.quantile(amps_abs, 0.95) + if dp.scatter_decimate is not None: + amps = amps[:: dp.scatter_decimate] + amps_abs = amps_abs[:: dp.scatter_decimate] + cmap = plt.colormaps[dp.cmap] + if dp.clim is None: + amps = amps_abs + amps /= q_95 + c = cmap(amps) + else: + norm_function = Normalize(vmin=dp.clim[0], vmax=dp.clim[1], clip=True) + c = cmap(norm_function(amps)) + color_kwargs = dict( + color=None, + c=c, + alpha=dp.alpha, + ) + else: + color_kwargs = dict(color=dp.color, c=None, alpha=dp.alpha) + + self.ax.scatter(x, y, s=1, **color_kwargs) + if dp.depth_lim is not None: + self.ax.set_ylim(*dp.depth_lim) + self.ax.set_title("Peak depth") + self.ax.set_xlabel("Times [s]") + self.ax.set_ylabel("Depth [$\\mu$m]") + + class MotionInfoWidget(BaseWidget): """ - Plot motion information from the motion_info dict returned by correct_motion(). - This plot: - * the motion iself - * the peak depth vs time before correction - * the peak depth vs time after correction + Plot motion information from the motion_info dictionary returned by the `correct_motion()` funciton. + This widget plots:: + * the motion iself + * the drift raster map (peak depth vs time) before correction + * the drift raster map (peak depth vs time) after correction Parameters ---------- motion_info : dict - The motion info returned by correct_motion() or loaded back with load_motion_info() + The motion info returned by correct_motion() or loaded back with load_motion_info(). segment_index : int, default: None The segment index to display. recording : RecordingExtractor, default: None - The recording extractor object (only used to get "real" times) + The recording extractor object (only used to get "real" times). segment_index : int, default: 0 The segment index to display. sampling_frequency : float, default: None - The sampling frequency (needed if recording is None) + The sampling frequency (needed if recording is None). depth_lim : tuple or None, default: None - The min and max depth to display, if None (min and max of the recording) + The min and max depth to display, if None (min and max of the recording). motion_lim : tuple or None, default: None - The min and max motion to display, if None (min and max of the motion) - color_amplitude : bool, default: False - If True, the color of the scatter points is the amplitude of the peaks + The min and max motion to display, if None (min and max of the motion). scatter_decimate : int, default: None - If > 1, the scatter points are decimated + If > 1, the scatter points are decimated. + color_amplitude : bool, default: False + If True, the color of the scatter points is the amplitude of the peaks. amplitude_cmap : str, default: "inferno" - The colormap to use for the amplitude + The colormap to use for the amplitude. + amplitude_color : str, default: "Gray" + The color of the scatter points if color_amplitude is False. amplitude_clim : tuple or None, default: None - The min and max amplitude to display, if None (min and max of the amplitudes) + The min and max amplitude to display, if None (min and max of the amplitudes). amplitude_alpha : float, default: 1 - The alpha of the scatter points + The alpha of the scatter points. """ def __init__( self, - motion_info, - segment_index=None, - recording=None, - depth_lim=None, - motion_lim=None, - color_amplitude=False, - scatter_decimate=None, - amplitude_cmap="inferno", - amplitude_clim=None, - amplitude_alpha=1, - backend=None, + motion_info: dict, + segment_index: int | None = None, + recording: BaseRecording | None = None, + depth_lim: tuple[float, float] | None = None, + motion_lim: tuple[float, float] | None = None, + color_amplitude: bool = False, + scatter_decimate: int | None = None, + amplitude_cmap: str = "inferno", + amplitude_color: str = "Gray", + amplitude_clim: tuple[float, float] | None = None, + amplitude_alpha: float = 1, + backend: str | None = None, **backend_kwargs, ): @@ -169,6 +352,7 @@ def __init__( color_amplitude=color_amplitude, scatter_decimate=scatter_decimate, amplitude_cmap=amplitude_cmap, + amplitude_color=amplitude_color, amplitude_clim=amplitude_clim, amplitude_alpha=amplitude_alpha, recording=recording, @@ -178,9 +362,7 @@ def __init__( BaseWidget.__init__(self, plot_data, backend=backend, **backend_kwargs) def plot_matplotlib(self, data_plot, **backend_kwargs): - import matplotlib.pyplot as plt from .utils_matplotlib import make_mpl_figure - from matplotlib.colors import Normalize from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks @@ -229,15 +411,17 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): recording=dp.recording, segment_index=dp.segment_index, depth_lim=dp.depth_lim, - color_amplitude=dp.color_amplitude, scatter_decimate=dp.scatter_decimate, + color_amplitude=dp.color_amplitude, + color=dp.amplitude_color, cmap=dp.amplitude_cmap, clim=dp.amplitude_clim, alpha=dp.amplitude_alpha, backend="matplotlib", ) - drift_map = DriftMapWidget( + # with immediate_plot=True the widgets are plotted immediately + _ = DriftRasterMapWidget( dp.peaks, dp.peak_locations, ax=ax0, @@ -245,7 +429,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): **commpon_drift_map_kwargs, ) - drift_map_corrected = DriftMapWidget( + _ = DriftRasterMapWidget( dp.peaks, corrected_location, ax=ax1, diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index e841a1c93b..0eef8539cc 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -22,7 +22,7 @@ import spikeinterface.widgets as sw import spikeinterface.comparison as sc -from spikeinterface.preprocessing import scale +from spikeinterface.preprocessing import scale, correct_motion ON_GITHUB = bool(os.getenv("GITHUB_ACTIONS")) @@ -56,6 +56,9 @@ def setUpClass(cls): cls.recording = recording cls.sorting = sorting + # estimate motion for motion widgets + _, cls.motion_info = correct_motion(recording, preset="kilosort_like", output_motion_info=True) + cls.num_units = len(cls.sorting.get_unit_ids()) extensions_to_compute = dict( @@ -581,9 +584,7 @@ def test_plot_multicomparison(self): sw.plot_multicomparison_agreement_by_sorter(mcmp, axes=axes) def test_plot_motion(self): - from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion - - motion = make_fake_motion() + motion = self.motion_info["motion"] possible_backends = list(sw.MotionWidget.get_possible_backends()) for backend in possible_backends: @@ -591,22 +592,31 @@ def test_plot_motion(self): sw.plot_motion(motion, backend=backend, mode="line") sw.plot_motion(motion, backend=backend, mode="map") - def test_plot_motion_info(self): - from spikeinterface.sortingcomponents.tests.test_motion_utils import make_fake_motion - - motion = make_fake_motion() - rng = np.random.default_rng(seed=2205) - peak_locations = np.zeros(self.peaks.size, dtype=[("x", "float64"), ("y", "float64")]) - peak_locations["y"] = rng.uniform(motion.spatial_bins_um[0], motion.spatial_bins_um[-1], size=self.peaks.size) - - motion_info = dict( - motion=motion, - parameters=dict(sampling_frequency=30000.0), - run_times=dict(), - peaks=self.peaks, - peak_locations=peak_locations, - ) + def test_drift_raster_map(self): + peaks = self.motion_info["peaks"] + recording = self.recording + peak_locations = self.motion_info["peak_locations"] + analyzer = self.sorting_analyzer_sparse + possible_backends = list(sw.MotionWidget.get_possible_backends()) + for backend in possible_backends: + if backend not in self.skip_backends: + # with recoridng + sw.plot_drift_raster_map( + peaks=peaks, peak_locations=peak_locations, recording=recording, color_amplitude=True + ) + # without recording + sw.plot_drift_raster_map( + peaks=peaks, + peak_locations=peak_locations, + sampling_frequency=recording.sampling_frequency, + color_amplitude=False, + ) + # with analyzer + sw.plot_drift_raster_map(sorting_analyzer=analyzer, color_amplitude=True) + + def test_plot_motion_info(self): + motion_info = self.motion_info possible_backends = list(sw.MotionWidget.get_possible_backends()) for backend in possible_backends: if backend not in self.skip_backends: diff --git a/src/spikeinterface/widgets/widget_list.py b/src/spikeinterface/widgets/widget_list.py index 8d4accaa7e..8163271ec4 100644 --- a/src/spikeinterface/widgets/widget_list.py +++ b/src/spikeinterface/widgets/widget_list.py @@ -9,9 +9,8 @@ from .amplitudes import AmplitudesWidget from .autocorrelograms import AutoCorrelogramsWidget from .crosscorrelograms import CrossCorrelogramsWidget -from .driftmap import DriftMapWidget from .isi_distribution import ISIDistributionWidget -from .motion import MotionWidget, MotionInfoWidget +from .motion import DriftRasterMapWidget, MotionWidget, MotionInfoWidget from .multicomparison import MultiCompGraphWidget, MultiCompGlobalAgreementWidget, MultiCompAgreementBySorterWidget from .peak_activity import PeakActivityMapWidget from .peaks_on_probe import PeaksOnProbeWidget @@ -45,7 +44,7 @@ ConfusionMatrixWidget, ComparisonCollisionBySimilarityWidget, CrossCorrelogramsWidget, - DriftMapWidget, + DriftRasterMapWidget, ISIDistributionWidget, MotionWidget, MotionInfoWidget, @@ -120,7 +119,7 @@ plot_confusion_matrix = ConfusionMatrixWidget plot_comparison_collision_by_similarity = ComparisonCollisionBySimilarityWidget plot_crosscorrelograms = CrossCorrelogramsWidget -plot_drift_map = DriftMapWidget +plot_drift_raster_map = DriftRasterMapWidget plot_isi_distribution = ISIDistributionWidget plot_motion = MotionWidget plot_motion_info = MotionInfoWidget From 30b60e7eab49bfa47696593e8f7f3506113cda53 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:05:47 +0200 Subject: [PATCH 215/248] Add explanation on what drift rastermap is --- src/spikeinterface/widgets/motion.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index ee1599822f..66ef2a3f01 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -97,6 +97,8 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): class DriftRasterMapWidget(BaseWidget): """ Plot the drift raster map from peaks or a SortingAnalyzer. + The drift raster map is a scatter plot of the estimated peak depth vs time and it is + useful to visualize the drift over the course of the recording. Parameters ---------- From 31064ec453f65cac23baa2379991b0996492618b Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:06:39 +0200 Subject: [PATCH 216/248] Add explanation on 'y' direction --- src/spikeinterface/widgets/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 66ef2a3f01..31edbf2f4d 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -112,7 +112,7 @@ class DriftRasterMapWidget(BaseWidget): The sorting analyzer object. To use this function, the `SortingAnalyzer` must have the "spike_locations" extension computed. direction : "x" or "y", default: "y" - The direction to display. + The direction to display. "y" is the depth direction. segment_index : int, default: None The segment index to display. recording : RecordingExtractor | None, default: None From cc550b9622bee8bf11a11b585ee9ff02cb829423 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:07:35 +0200 Subject: [PATCH 217/248] Fix segment index error --- src/spikeinterface/widgets/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 31edbf2f4d..31a938829d 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -189,7 +189,7 @@ def __init__( if segment_index is None: assert ( len(np.unique(peaks["segment_index"])) == 1 - ), "segment_index must be specified if there is only one segment in the peaks array" + ), "segment_index must be specified if there are multiple segments" segment_index = 0 else: peak_mask = peaks["segment_index"] == segment_index From 80ba2e512f568a2b96ea3e38095bc19f9a987480 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:12:03 +0200 Subject: [PATCH 218/248] Review suggestions and test with scatter_decimate --- src/spikeinterface/widgets/motion.py | 16 +++++++--------- src/spikeinterface/widgets/tests/test_widgets.py | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 31a938829d..895a8733c7 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -232,21 +232,19 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): dp = to_attr(data_plot) - assert backend_kwargs["axes"] is None, "axes argument is not allowed in MotionWidget" + assert backend_kwargs["axes"] is None, "axes argument is not allowed in DriftRasterMapWidget. Use ax instead." self.figure, self.axes, self.ax = make_mpl_figure(**backend_kwargs) - fig = self.figure if dp.times is None: - x = dp.peaks["sample_index"] / dp.sampling_frequency + peak_times = dp.peaks["sample_index"] / dp.sampling_frequency else: - x = dp.times[dp.peaks["sample_index"]] + peak_times = dp.times[dp.peaks["sample_index"]] - y = dp.peak_locations[dp.direction] + peak_locs = dp.peak_locations[dp.direction] if dp.scatter_decimate is not None: - x = x[:: dp.scatter_decimate] - y = y[:: dp.scatter_decimate] - y2 = y2[:: dp.scatter_decimate] + peak_times = peak_times[:: dp.scatter_decimate] + peak_locs = peak_locs[:: dp.scatter_decimate] if dp.color_amplitude: amps = dp.peak_amplitudes @@ -271,7 +269,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): else: color_kwargs = dict(color=dp.color, c=None, alpha=dp.alpha) - self.ax.scatter(x, y, s=1, **color_kwargs) + self.ax.scatter(peak_times, peak_locs, s=1, **color_kwargs) if dp.depth_lim is not None: self.ax.set_ylim(*dp.depth_lim) self.ax.set_title("Peak depth") diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index 0eef8539cc..7887ecda66 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -613,7 +613,7 @@ def test_drift_raster_map(self): color_amplitude=False, ) # with analyzer - sw.plot_drift_raster_map(sorting_analyzer=analyzer, color_amplitude=True) + sw.plot_drift_raster_map(sorting_analyzer=analyzer, color_amplitude=True, scatter_decimate=2) def test_plot_motion_info(self): motion_info = self.motion_info From 3e9f342e6a8d7695186c2aef4e12cde30d984cea Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:22:09 +0200 Subject: [PATCH 219/248] Mark failing sorter test on Windows*Python3.12 as xfail --- src/spikeinterface/sorters/tests/test_runsorter.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/spikeinterface/sorters/tests/test_runsorter.py b/src/spikeinterface/sorters/tests/test_runsorter.py index 470bdc3602..6bd73c5691 100644 --- a/src/spikeinterface/sorters/tests/test_runsorter.py +++ b/src/spikeinterface/sorters/tests/test_runsorter.py @@ -1,7 +1,9 @@ import os +import platform import pytest from pathlib import Path import shutil +from packaging.version import parse from spikeinterface import generate_ground_truth_recording from spikeinterface.sorters import run_sorter @@ -19,6 +21,10 @@ def generate_recording(): return _generate_recording() +@pytest.mark.xfail( + platform.system() == "Windows" and parse(platform.python_version()) > parse("3.12"), + reason="3rd parth threadpoolctl issue: OSError('GetModuleFileNameEx failed')", +) def test_run_sorter_local(generate_recording, create_cache_folder): recording = generate_recording cache_folder = create_cache_folder From b37ee282d3009250be6890e3220dcff8930c3a43 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 12:37:04 +0200 Subject: [PATCH 220/248] exists() -> is_dir() --- src/spikeinterface/preprocessing/motion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 71ae3f3ebb..ce6b9bb337 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,12 +320,12 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.exists(): + if folder.is_dir(): import shutil shutil.rmtree(folder) else: - assert not folder.exists(), f"Folder {folder} already exists" + assert not folder.is_dir(), f"Folder {folder} already exists" folder.mkdir(exist_ok=True, parents=True) From d1d65f6ca6338ac2dd8d6f9c99ee657f0db76d21 Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 11:58:23 +0100 Subject: [PATCH 221/248] estimate_sparsity arg ordering --- src/spikeinterface/core/sortinganalyzer.py | 2 +- src/spikeinterface/core/sparsity.py | 6 +++--- src/spikeinterface/core/tests/test_sparsity.py | 4 ++-- .../postprocessing/tests/common_extension_tests.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index 53e060262b..62b7f9e7c0 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -127,7 +127,7 @@ def create_sorting_analyzer( recording.channel_ids, sparsity.channel_ids ), "create_sorting_analyzer(): if external sparsity is given unit_ids must correspond" elif sparse: - sparsity = estimate_sparsity(recording, sorting, **sparsity_kwargs) + sparsity = estimate_sparsity(sorting, recording, **sparsity_kwargs) else: sparsity = None diff --git a/src/spikeinterface/core/sparsity.py b/src/spikeinterface/core/sparsity.py index cefd7bd950..1cd7822f99 100644 --- a/src/spikeinterface/core/sparsity.py +++ b/src/spikeinterface/core/sparsity.py @@ -539,8 +539,8 @@ def compute_sparsity( def estimate_sparsity( - recording: BaseRecording, sorting: BaseSorting, + recording: BaseRecording, num_spikes_for_sparsity: int = 100, ms_before: float = 1.0, ms_after: float = 2.5, @@ -563,10 +563,10 @@ def estimate_sparsity( Parameters ---------- - recording: BaseRecording - The recording sorting: BaseSorting The sorting + recording: BaseRecording + The recording num_spikes_for_sparsity: int, default: 100 How many spikes per units to compute the sparsity ms_before: float, default: 1.0 diff --git a/src/spikeinterface/core/tests/test_sparsity.py b/src/spikeinterface/core/tests/test_sparsity.py index 98d033d8ea..a192d90502 100644 --- a/src/spikeinterface/core/tests/test_sparsity.py +++ b/src/spikeinterface/core/tests/test_sparsity.py @@ -166,8 +166,8 @@ def test_estimate_sparsity(): # small radius should give a very sparse = one channel per unit sparsity = estimate_sparsity( - recording, sorting, + recording, num_spikes_for_sparsity=50, ms_before=1.0, ms_after=2.0, @@ -182,8 +182,8 @@ def test_estimate_sparsity(): # best_channel : the mask should exactly 3 channels per units sparsity = estimate_sparsity( - recording, sorting, + recording, num_spikes_for_sparsity=50, ms_before=1.0, ms_after=2.0, diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index bf462a9466..8c46fa5e24 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -79,7 +79,7 @@ class AnalyzerExtensionCommonTestSuite: def setUpClass(cls): cls.recording, cls.sorting = get_dataset() # sparsity is computed once for all cases to save processing time and force a small radius - cls.sparsity = estimate_sparsity(cls.recording, cls.sorting, method="radius", radius_um=20) + cls.sparsity = estimate_sparsity(cls.sorting, cls.recording, method="radius", radius_um=20) @property def extension_name(self): From 02ae32a857c9ce59a54deffcc1465a3d975342aa Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 14:22:18 +0200 Subject: [PATCH 222/248] Update src/spikeinterface/widgets/motion.py Co-authored-by: Zach McKenzie <92116279+zm711@users.noreply.github.com> --- src/spikeinterface/widgets/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 895a8733c7..5f0e02fdab 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -280,7 +280,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): class MotionInfoWidget(BaseWidget): """ Plot motion information from the motion_info dictionary returned by the `correct_motion()` funciton. - This widget plots:: + This widget plots: * the motion iself * the drift raster map (peak depth vs time) before correction * the drift raster map (peak depth vs time) after correction From c111cfcacb1c80b4166320c3f3753a2a7d629f69 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 14:22:28 +0200 Subject: [PATCH 223/248] Update src/spikeinterface/widgets/tests/test_widgets.py Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> --- src/spikeinterface/widgets/tests/test_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/tests/test_widgets.py b/src/spikeinterface/widgets/tests/test_widgets.py index 7887ecda66..012b1ac07c 100644 --- a/src/spikeinterface/widgets/tests/test_widgets.py +++ b/src/spikeinterface/widgets/tests/test_widgets.py @@ -601,7 +601,7 @@ def test_drift_raster_map(self): possible_backends = list(sw.MotionWidget.get_possible_backends()) for backend in possible_backends: if backend not in self.skip_backends: - # with recoridng + # with recording sw.plot_drift_raster_map( peaks=peaks, peak_locations=peak_locations, recording=recording, color_amplitude=True ) From 12d823bb0dc7e1536486508c473f0ce5562e395a Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 14:37:10 +0200 Subject: [PATCH 224/248] Better docs for plot mode (line, map, auto) --- src/spikeinterface/widgets/motion.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 895a8733c7..766938299a 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -19,7 +19,10 @@ class MotionWidget(BaseWidget): segment_index : int | None, default: None If Motion is multi segment, the must be not None. mode : "auto" | "line" | "map", default: "line" - How to plot map or lines. "auto" makes it automatic if the number of motion depths is too high. + How to plot the motion. + "line" plots estimated motion at different depths as lines. + "map" plots estimated motion at different depths as a heatmap. + "auto" makes it automatic depending on the number of motion depths. """ def __init__( From a3deed8211f9b20e3acbe41f9b7297e285ba68ed Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Thu, 27 Jun 2024 14:39:07 +0200 Subject: [PATCH 225/248] Remove duplicated line --- 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 bf9010c144..0b79350a62 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -187,7 +187,6 @@ def __init__( peak_amplitudes = sorting_analyzer.get_extension("spike_amplitudes").get_data() else: peak_amplitudes = None - times = recording.get_times(segment_index=segment_index) if recording is not None else None if segment_index is None: assert ( From 2cc719986e5d6fceb9ea828206d7cf1d9a3fef9a Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 27 Jun 2024 08:11:55 -0600 Subject: [PATCH 226/248] @alejo91 suggestion --- src/spikeinterface/core/core_tools.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index d5480d6f00..066ab58d8c 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -184,10 +184,10 @@ def is_dict_extractor(d: dict) -> bool: return is_extractor -recording_dict_element = namedtuple(typename="recording_dict_element", field_names=["value", "name", "access_path"]) +extractor_dict_element = namedtuple(typename="extractor_dict_element", field_names=["value", "name", "access_path"]) -def extractor_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_element]: +def extractor_dict_iterator(extractor_dict: dict) -> Generator[extractor_dict_element]: """ Iterator for recursive traversal of a dictionary. This function explores the dictionary recursively and yields the path to each value along with the value itself. @@ -204,7 +204,7 @@ def extractor_dict_iterator(extractor_dict: dict) -> Generator[recording_dict_el Yields ------ - recording_dict_element + extractor_dict_element Named tuple containing the value, the name, and the access_path to the value in the dictionary. """ @@ -219,7 +219,7 @@ def _extractor_dict_iterator(dict_list_or_value, access_path=(), name=""): v, access_path + (i,), name=name ) # Propagate name of list to children else: - yield recording_dict_element( + yield extractor_dict_element( value=dict_list_or_value, name=name, access_path=access_path, @@ -320,7 +320,7 @@ def recursive_path_modifier(d, func, target="path", copy=True) -> dict: raise ValueError(f"{k} key for path must be str or list[str]") -# This is the current definition that an element in a recording_dict is a path +# This is the current definition that an element in a extractor_dict is a path # This is shared across a couple of definition so it is here for DNRY element_is_path = lambda element: "path" in element.name and isinstance(element.value, (str, Path)) From 61060781eef87597461241aec077aac27baff69b Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 15:15:14 +0100 Subject: [PATCH 227/248] SpikeRetriever arg switch --- src/spikeinterface/core/node_pipeline.py | 16 +-- .../core/tests/test_node_pipeline.py | 4 +- .../tests/test_train_manual_curation.py | 120 ++++++++++++++++++ .../postprocessing/amplitude_scalings.py | 2 +- .../postprocessing/spike_amplitudes.py | 2 +- .../postprocessing/spike_locations.py | 2 +- 6 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 src/spikeinterface/curation/tests/test_train_manual_curation.py diff --git a/src/spikeinterface/core/node_pipeline.py b/src/spikeinterface/core/node_pipeline.py index 1c0107d235..0722ede23f 100644 --- a/src/spikeinterface/core/node_pipeline.py +++ b/src/spikeinterface/core/node_pipeline.py @@ -152,29 +152,29 @@ class SpikeRetriever(PeakSource): * compute_spike_amplitudes() * compute_principal_components() + sorting : BaseSorting + The sorting object. recording : BaseRecording The recording object. - sorting: BaseSorting - The sorting object. - channel_from_template: bool, default: True + channel_from_template : bool, default: True If True, then the channel_index is inferred from the template and `extremum_channel_inds` must be provided. If False, the max channel is computed for each spike given a radius around the template max channel. - extremum_channel_inds: dict of int | None, default: None + extremum_channel_inds : dict of int | None, default: None The extremum channel index dict given from template. - radius_um: float, default: 50 + radius_um : float, default: 50 The radius to find the real max channel. Used only when channel_from_template=False - peak_sign: "neg" | "pos", default: "neg" + peak_sign : "neg" | "pos", default: "neg" Peak sign to find the max channel. Used only when channel_from_template=False - include_spikes_in_margin: bool, default False + include_spikes_in_margin : bool, default False If not None then spikes in margin are added and an extra filed in dtype is added """ def __init__( self, - recording, sorting, + recording, channel_from_template=True, extremum_channel_inds=None, radius_um=50, diff --git a/src/spikeinterface/core/tests/test_node_pipeline.py b/src/spikeinterface/core/tests/test_node_pipeline.py index 03acc9fed1..8d788acbad 100644 --- a/src/spikeinterface/core/tests/test_node_pipeline.py +++ b/src/spikeinterface/core/tests/test_node_pipeline.py @@ -87,12 +87,12 @@ def test_run_node_pipeline(cache_folder_creation): peak_retriever = PeakRetriever(recording, peaks) # channel index is from template spike_retriever_T = SpikeRetriever( - recording, sorting, channel_from_template=True, extremum_channel_inds=extremum_channel_inds + sorting, recording, channel_from_template=True, extremum_channel_inds=extremum_channel_inds ) # channel index is per spike spike_retriever_S = SpikeRetriever( - recording, sorting, + recording, channel_from_template=False, extremum_channel_inds=extremum_channel_inds, radius_um=50, diff --git a/src/spikeinterface/curation/tests/test_train_manual_curation.py b/src/spikeinterface/curation/tests/test_train_manual_curation.py new file mode 100644 index 0000000000..f0f9ff4d75 --- /dev/null +++ b/src/spikeinterface/curation/tests/test_train_manual_curation.py @@ -0,0 +1,120 @@ +import pytest +import pandas as pd +import os +import shutil + +from spikeinterface.curation.train_manual_curation import CurationModelTrainer, Objective, train_model + +# Sample data for testing +data = { + 'num_spikes': [1, 2, 3, 4, 5, 6], + 'firing_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'presence_ratio': [0.9, 0.8, 0.7, 0.6, 0.5, 0.4], + 'isi_violations_ratio': [0.01, 0.02, 0.03, 0.04, 0.05, 0.06], + 'amplitude_cutoff': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'amplitude_median': [0.2, 0.3, 0.4, 0.5, 0.6, 0.7], + 'amplitude_cv_median': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'amplitude_cv_range': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'sync_spike_2': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'sync_spike_4': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'sync_spike_8': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'firing_range': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'drift_ptp': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'drift_std': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'drift_mad': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'isolation_distance': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'l_ratio': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'd_prime': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'silhouette': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'nn_hit_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'nn_miss_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'peak_to_valley': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'peak_trough_ratio': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'half_width': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'repolarization_slope': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'recovery_slope': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'num_positive_peaks': [1, 2, 3, 4, 5, 6], + 'num_negative_peaks': [1, 2, 3, 4, 5, 6], + 'velocity_above': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'velocity_below': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'exp_decay': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'spread': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + 'is_noise': [0, 1, 0, 1, 0, 1], + 'is_sua': [1, 0, 1, 0, 1, 0], + 'majority_vote': ['good', 'bad', 'good', 'bad', 'good', 'bad'] +} + +df = pd.DataFrame(data) + +# Test initialization +def test_initialization(): + trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') + assert trainer.output_folder == '/tmp' + assert trainer.curator_column == 'num_spikes' + assert trainer.imputation_strategies is not None + assert trainer.scaling_techniques is not None + +# Test load_data_file +def test_load_data_file(): + trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') + df.to_csv('/tmp/test.csv', index=False) + trainer.load_data_file('/tmp/test.csv') + assert trainer.testing_metrics is not None + assert 0 in trainer.testing_metrics + +# Test process_test_data_for_classification +def test_process_test_data_for_classification(): + trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') + trainer.testing_metrics = {0: df} + trainer.process_test_data_for_classification() + assert trainer.noise_test is not None + assert trainer.sua_mua_test is not None + +# Test apply_scaling_imputation +def test_apply_scaling_imputation(): + trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') + X_train = df.drop(columns=['is_noise', 'is_sua', 'majority_vote']) + X_val = df.drop(columns=['is_noise', 'is_sua', 'majority_vote']) + y_train = df['is_noise'] + y_val = df['is_noise'] + result = trainer.apply_scaling_imputation('median', trainer.scaling_techniques[0][1], X_train, X_val, y_train, y_val) + assert result is not None + +# Test get_classifier_search_space +def test_get_classifier_search_space(): + from sklearn.linear_model import LogisticRegression + trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') + model, param_space = trainer.get_classifier_search_space(LogisticRegression) + assert model is not None + assert param_space is not None + +# Test Objective Enum +def test_objective_enum(): + assert Objective.Noise == Objective(1) + assert Objective.SUA == Objective(2) + assert str(Objective.Noise) == "Objective.Noise" + assert str(Objective.SUA) == "Objective.SUA" + +# Test train_model function +def test_train_model(monkeypatch): + output_folder = '/tmp/output' + os.makedirs(output_folder, exist_ok=True) + df.to_csv('/tmp/metrics.csv', index=False) + + def mock_load_and_preprocess_full(self, path): + self.testing_metrics = {0: df} + self.process_test_data_for_classification() + + monkeypatch.setattr(CurationModelTrainer, 'load_and_preprocess_full', mock_load_and_preprocess_full) + + trainer = train_model('/tmp/metrics.csv', output_folder, 'is_noise') + assert trainer is not None + assert trainer.testing_metrics is not None + assert 0 in trainer.testing_metrics + +# Clean up temporary files +@pytest.fixture(scope="module", autouse=True) +def cleanup(request): + def remove_tmp(): + shutil.rmtree('/tmp', ignore_errors=True) + request.addfinalizer(remove_tmp) diff --git a/src/spikeinterface/postprocessing/amplitude_scalings.py b/src/spikeinterface/postprocessing/amplitude_scalings.py index 2e544d086b..8ff9cc5666 100644 --- a/src/spikeinterface/postprocessing/amplitude_scalings.py +++ b/src/spikeinterface/postprocessing/amplitude_scalings.py @@ -170,8 +170,8 @@ def _get_pipeline_nodes(self): sparsity_mask = sparsity.mask spike_retriever_node = SpikeRetriever( - recording, sorting, + recording, channel_from_template=True, extremum_channel_inds=extremum_channels_indices, include_spikes_in_margin=True, diff --git a/src/spikeinterface/postprocessing/spike_amplitudes.py b/src/spikeinterface/postprocessing/spike_amplitudes.py index aebfd1fd78..72cbcb651f 100644 --- a/src/spikeinterface/postprocessing/spike_amplitudes.py +++ b/src/spikeinterface/postprocessing/spike_amplitudes.py @@ -95,7 +95,7 @@ def _get_pipeline_nodes(self): peak_shifts = get_template_extremum_channel_peak_shift(self.sorting_analyzer, peak_sign=peak_sign) spike_retriever_node = SpikeRetriever( - recording, sorting, channel_from_template=True, extremum_channel_inds=extremum_channels_indices + sorting, recording, channel_from_template=True, extremum_channel_inds=extremum_channels_indices ) spike_amplitudes_node = SpikeAmplitudeNode( recording, diff --git a/src/spikeinterface/postprocessing/spike_locations.py b/src/spikeinterface/postprocessing/spike_locations.py index 52a91342b6..23301292e5 100644 --- a/src/spikeinterface/postprocessing/spike_locations.py +++ b/src/spikeinterface/postprocessing/spike_locations.py @@ -103,8 +103,8 @@ def _get_pipeline_nodes(self): ) retriever = SpikeRetriever( - recording, sorting, + recording, channel_from_template=True, extremum_channel_inds=extremum_channels_indices, ) From 722c313382b6ac225a2c9119c676bc1bcab6e480 Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 15:17:43 +0100 Subject: [PATCH 228/248] has_exceeding_spikes arg switch --- src/spikeinterface/core/basesorting.py | 2 +- src/spikeinterface/core/frameslicesorting.py | 2 +- src/spikeinterface/core/waveform_tools.py | 2 +- src/spikeinterface/curation/remove_excess_spikes.py | 2 +- .../curation/tests/test_remove_excess_spikes.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/core/basesorting.py b/src/spikeinterface/core/basesorting.py index fd68df9dda..d9a567dedf 100644 --- a/src/spikeinterface/core/basesorting.py +++ b/src/spikeinterface/core/basesorting.py @@ -197,7 +197,7 @@ def register_recording(self, recording, check_spike_frames=True): self.get_num_segments() == recording.get_num_segments() ), "The recording has a different number of segments than the sorting!" if check_spike_frames: - if has_exceeding_spikes(recording, self): + if has_exceeding_spikes(self, recording): warnings.warn( "Some spikes exceed the recording's duration! " "Removing these excess spikes with `spikeinterface.curation.remove_excess_spikes()` " diff --git a/src/spikeinterface/core/frameslicesorting.py b/src/spikeinterface/core/frameslicesorting.py index ffd8af5fd8..f3ec449ab0 100644 --- a/src/spikeinterface/core/frameslicesorting.py +++ b/src/spikeinterface/core/frameslicesorting.py @@ -54,7 +54,7 @@ def __init__(self, parent_sorting, start_frame=None, end_frame=None, check_spike assert ( start_frame <= parent_n_samples ), "`start_frame` should be smaller than the sortings' total number of samples." - if check_spike_frames and has_exceeding_spikes(parent_sorting._recording, parent_sorting): + if check_spike_frames and has_exceeding_spikes(parent_sorting, parent_sorting._recording): raise ValueError( "The sorting object has spikes whose times go beyond the recording duration." "This could indicate a bug in the sorter. " diff --git a/src/spikeinterface/core/waveform_tools.py b/src/spikeinterface/core/waveform_tools.py index befc49d034..4543074872 100644 --- a/src/spikeinterface/core/waveform_tools.py +++ b/src/spikeinterface/core/waveform_tools.py @@ -679,7 +679,7 @@ def split_waveforms_by_units(unit_ids, spikes, all_waveforms, sparsity_mask=None return waveforms_by_units -def has_exceeding_spikes(recording, sorting) -> bool: +def has_exceeding_spikes(sorting, recording) -> bool: """ Check if the sorting objects has spikes exceeding the recording number of samples, for all segments diff --git a/src/spikeinterface/curation/remove_excess_spikes.py b/src/spikeinterface/curation/remove_excess_spikes.py index 0ae7a59fc6..d1d6b7f3cb 100644 --- a/src/spikeinterface/curation/remove_excess_spikes.py +++ b/src/spikeinterface/curation/remove_excess_spikes.py @@ -102,7 +102,7 @@ def remove_excess_spikes(sorting, recording): sorting_without_excess_spikes : Sorting The sorting without any excess spikes. """ - if has_exceeding_spikes(recording=recording, sorting=sorting): + if has_exceeding_spikes(sorting=sorting, recording=recording): return RemoveExcessSpikesSorting(sorting=sorting, recording=recording) else: return sorting diff --git a/src/spikeinterface/curation/tests/test_remove_excess_spikes.py b/src/spikeinterface/curation/tests/test_remove_excess_spikes.py index 69edbaba4c..141cc4c34e 100644 --- a/src/spikeinterface/curation/tests/test_remove_excess_spikes.py +++ b/src/spikeinterface/curation/tests/test_remove_excess_spikes.py @@ -39,10 +39,10 @@ def test_remove_excess_spikes(): labels.append(labels_segment) sorting = NumpySorting.from_times_labels(times, labels, sampling_frequency=sampling_frequency) - assert has_exceeding_spikes(recording, sorting) + assert has_exceeding_spikes(sorting, recording) sorting_corrected = remove_excess_spikes(sorting, recording) - assert not has_exceeding_spikes(recording, sorting_corrected) + assert not has_exceeding_spikes(sorting_corrected, recording) for u in sorting.unit_ids: for segment_index in range(sorting.get_num_segments()): From d0968c4c941e290488848d14c6881c7a2cdf9c8c Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 15:19:24 +0100 Subject: [PATCH 229/248] removed accidental commit --- .../tests/test_train_manual_curation.py | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 src/spikeinterface/curation/tests/test_train_manual_curation.py diff --git a/src/spikeinterface/curation/tests/test_train_manual_curation.py b/src/spikeinterface/curation/tests/test_train_manual_curation.py deleted file mode 100644 index f0f9ff4d75..0000000000 --- a/src/spikeinterface/curation/tests/test_train_manual_curation.py +++ /dev/null @@ -1,120 +0,0 @@ -import pytest -import pandas as pd -import os -import shutil - -from spikeinterface.curation.train_manual_curation import CurationModelTrainer, Objective, train_model - -# Sample data for testing -data = { - 'num_spikes': [1, 2, 3, 4, 5, 6], - 'firing_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'presence_ratio': [0.9, 0.8, 0.7, 0.6, 0.5, 0.4], - 'isi_violations_ratio': [0.01, 0.02, 0.03, 0.04, 0.05, 0.06], - 'amplitude_cutoff': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'amplitude_median': [0.2, 0.3, 0.4, 0.5, 0.6, 0.7], - 'amplitude_cv_median': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'amplitude_cv_range': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'sync_spike_2': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'sync_spike_4': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'sync_spike_8': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'firing_range': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'drift_ptp': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'drift_std': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'drift_mad': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'isolation_distance': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'l_ratio': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'd_prime': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'silhouette': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'nn_hit_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'nn_miss_rate': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'peak_to_valley': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'peak_trough_ratio': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'half_width': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'repolarization_slope': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'recovery_slope': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'num_positive_peaks': [1, 2, 3, 4, 5, 6], - 'num_negative_peaks': [1, 2, 3, 4, 5, 6], - 'velocity_above': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'velocity_below': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'exp_decay': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'spread': [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], - 'is_noise': [0, 1, 0, 1, 0, 1], - 'is_sua': [1, 0, 1, 0, 1, 0], - 'majority_vote': ['good', 'bad', 'good', 'bad', 'good', 'bad'] -} - -df = pd.DataFrame(data) - -# Test initialization -def test_initialization(): - trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') - assert trainer.output_folder == '/tmp' - assert trainer.curator_column == 'num_spikes' - assert trainer.imputation_strategies is not None - assert trainer.scaling_techniques is not None - -# Test load_data_file -def test_load_data_file(): - trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') - df.to_csv('/tmp/test.csv', index=False) - trainer.load_data_file('/tmp/test.csv') - assert trainer.testing_metrics is not None - assert 0 in trainer.testing_metrics - -# Test process_test_data_for_classification -def test_process_test_data_for_classification(): - trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') - trainer.testing_metrics = {0: df} - trainer.process_test_data_for_classification() - assert trainer.noise_test is not None - assert trainer.sua_mua_test is not None - -# Test apply_scaling_imputation -def test_apply_scaling_imputation(): - trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') - X_train = df.drop(columns=['is_noise', 'is_sua', 'majority_vote']) - X_val = df.drop(columns=['is_noise', 'is_sua', 'majority_vote']) - y_train = df['is_noise'] - y_val = df['is_noise'] - result = trainer.apply_scaling_imputation('median', trainer.scaling_techniques[0][1], X_train, X_val, y_train, y_val) - assert result is not None - -# Test get_classifier_search_space -def test_get_classifier_search_space(): - from sklearn.linear_model import LogisticRegression - trainer = CurationModelTrainer(column_name='num_spikes', output_folder='/tmp') - model, param_space = trainer.get_classifier_search_space(LogisticRegression) - assert model is not None - assert param_space is not None - -# Test Objective Enum -def test_objective_enum(): - assert Objective.Noise == Objective(1) - assert Objective.SUA == Objective(2) - assert str(Objective.Noise) == "Objective.Noise" - assert str(Objective.SUA) == "Objective.SUA" - -# Test train_model function -def test_train_model(monkeypatch): - output_folder = '/tmp/output' - os.makedirs(output_folder, exist_ok=True) - df.to_csv('/tmp/metrics.csv', index=False) - - def mock_load_and_preprocess_full(self, path): - self.testing_metrics = {0: df} - self.process_test_data_for_classification() - - monkeypatch.setattr(CurationModelTrainer, 'load_and_preprocess_full', mock_load_and_preprocess_full) - - trainer = train_model('/tmp/metrics.csv', output_folder, 'is_noise') - assert trainer is not None - assert trainer.testing_metrics is not None - assert 0 in trainer.testing_metrics - -# Clean up temporary files -@pytest.fixture(scope="module", autouse=True) -def cleanup(request): - def remove_tmp(): - shutil.rmtree('/tmp', ignore_errors=True) - request.addfinalizer(remove_tmp) From f687c2c2fe9b70a970cfd39d6dd7b134c15e065f Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 15:20:32 +0100 Subject: [PATCH 230/248] docs --- src/spikeinterface/core/waveform_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/core/waveform_tools.py b/src/spikeinterface/core/waveform_tools.py index 4543074872..98380e955f 100644 --- a/src/spikeinterface/core/waveform_tools.py +++ b/src/spikeinterface/core/waveform_tools.py @@ -685,10 +685,10 @@ def has_exceeding_spikes(sorting, recording) -> bool: Parameters ---------- - recording : BaseRecording - The recording object sorting : BaseSorting The sorting object + recording : BaseRecording + The recording object Returns ------- From b8c8fa83ba8695545b420d135c92f5167d7d2de1 Mon Sep 17 00:00:00 2001 From: jakeswann1 Date: Thu, 27 Jun 2024 15:54:59 +0100 Subject: [PATCH 231/248] Missed one --- .../postprocessing/tests/common_extension_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/postprocessing/tests/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py index bb2f5aaafd..52dbaf23d4 100644 --- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py +++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py @@ -73,7 +73,7 @@ class instance is used for each. In this case, we have to set self.__class__.recording, self.__class__.sorting = get_dataset() self.__class__.sparsity = estimate_sparsity( - self.__class__.recording, self.__class__.sorting, method="radius", radius_um=20 + self.__class__.sorting, self.__class__.recording, method="radius", radius_um=20 ) self.__class__.cache_folder = create_cache_folder From 3eee955a8da3989dda6cbd84b25c0eabc2222527 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Thu, 27 Jun 2024 09:01:15 -0600 Subject: [PATCH 232/248] make test skipif --- .../core/tests/test_core_tools.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/spikeinterface/core/tests/test_core_tools.py b/src/spikeinterface/core/tests/test_core_tools.py index 724517577c..7153991543 100644 --- a/src/spikeinterface/core/tests/test_core_tools.py +++ b/src/spikeinterface/core/tests/test_core_tools.py @@ -31,25 +31,25 @@ def test_add_suffix(): assert str(file_path_with_suffix) == expected_path +@pytest.mark.skipif(platform.system() == "Windows", reason="Runs on posix only") def test_path_utils_functions(): - if platform.system() != "Windows": - # posix path - d = { - "kwargs": { - "path": "/yep/sub/path1", - "recording": { - "module": "mock_module", - "class": "mock_class", - "version": "1.2", - "annotations": {}, - "kwargs": {"path": "/yep/sub/path2"}, - }, - } + # posix path + d = { + "kwargs": { + "path": "/yep/sub/path1", + "recording": { + "module": "mock_module", + "class": "mock_class", + "version": "1.2", + "annotations": {}, + "kwargs": {"path": "/yep/sub/path2"}, + }, } + } - d2 = recursive_path_modifier(d, lambda p: p.replace("/yep", "/yop")) - assert d2["kwargs"]["path"].startswith("/yop") - assert d2["kwargs"]["recording"]["kwargs"]["path"].startswith("/yop") + d2 = recursive_path_modifier(d, lambda p: p.replace("/yep", "/yop")) + assert d2["kwargs"]["path"].startswith("/yop") + assert d2["kwargs"]["recording"]["kwargs"]["path"].startswith("/yop") @pytest.mark.skipif(platform.system() != "Windows", reason="Runs only on Windows") From c24c9669dcd8e53246c376c6d33eebbf39cbab83 Mon Sep 17 00:00:00 2001 From: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> Date: Thu, 27 Jun 2024 18:32:22 +0100 Subject: [PATCH 233/248] Add *sg_execution_times.rst to gitignore. (#3097) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d981c8de4e..6c9fa6869f 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,7 @@ examples/tutorials/*.svg doc/_build/* doc/tutorials/* doc/sources/* +*sg_execution_times.rst examples/getting_started/tmp_* examples/getting_started/phy From d5ec1806bf41c27317f60e7c96cf71972400774b Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:58:30 -0400 Subject: [PATCH 234/248] get rid of waveform term --- src/spikeinterface/widgets/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/base.py b/src/spikeinterface/widgets/base.py index b94167d2b7..9566989d31 100644 --- a/src/spikeinterface/widgets/base.py +++ b/src/spikeinterface/widgets/base.py @@ -139,7 +139,7 @@ def check_extensions(sorting_analyzer, extensions): if not sorting_analyzer.has_extension(extension): raise_error = True error_msg += ( - f"The {extension} waveform extension is required for this widget. " + f"The {extension} sorting analyzer extension is required for this widget. " f"Run the `sorting_analyzer.compute('{extension}', ...)` to compute it.\n" ) if raise_error: From 815f6053fb438ae7f5eb04462ac31aad17200aac Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Fri, 28 Jun 2024 15:27:28 -0600 Subject: [PATCH 235/248] accept paths in jsonification --- src/spikeinterface/core/core_tools.py | 3 ++ .../core/tests/test_jsonification.py | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 066ab58d8c..d4701343af 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -98,6 +98,9 @@ def default(self, obj): if isinstance(obj, BaseExtractor): return obj.to_dict() + if isinstance(obj, Path): + return str(obj) + # The base-class handles the assertion return super().default(obj) diff --git a/src/spikeinterface/core/tests/test_jsonification.py b/src/spikeinterface/core/tests/test_jsonification.py index 4417ea342f..316dac3abc 100644 --- a/src/spikeinterface/core/tests/test_jsonification.py +++ b/src/spikeinterface/core/tests/test_jsonification.py @@ -7,11 +7,7 @@ from spikeinterface.core.core_tools import SIJsonEncoder from spikeinterface.core.generate import generate_recording, generate_sorting - -@pytest.fixture(scope="module") -def numpy_generated_recording(): - recording = generate_recording() - return recording +from pathlib import Path @pytest.fixture(scope="module") @@ -124,8 +120,25 @@ def test_numpy_dtype_alises_encoding(): json.dumps(np.float32, cls=SIJsonEncoder) -def test_recording_encoding(numpy_generated_recording): - recording = numpy_generated_recording +def test_path_encoding(tmp_path): + + temporary_path = tmp_path / "a_path_for_this_test" + + json.dumps(temporary_path, cls=SIJsonEncoder) + + +def test_path_as_annotation(tmp_path): + temporary_path = tmp_path / "a_path_for_this_test" + + recording = generate_recording() + recording.annotate(path=temporary_path) + + json.dumps(recording, cls=SIJsonEncoder) + + +def test_recording_encoding(): + recording = generate_recording() + json.dumps(recording, cls=SIJsonEncoder) @@ -200,4 +213,4 @@ def test_encoding_numpy_scalars_within_nested_extractors_dict(nested_extractor_d if __name__ == "__main__": nested_extractor = nested_extractor() - test_encoding_numpy_scalars_within_nested_extractors(nested_extractor_) + test_encoding_numpy_scalars_within_nested_extractors(nested_extractor) From 92b969e345199b9795937104844c58e200864556 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 28 Jun 2024 17:56:56 -0400 Subject: [PATCH 236/248] use curation argument in gui --- src/spikeinterface/widgets/sorting_summary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/widgets/sorting_summary.py b/src/spikeinterface/widgets/sorting_summary.py index 24b4ca8022..6f60e9ab9a 100644 --- a/src/spikeinterface/widgets/sorting_summary.py +++ b/src/spikeinterface/widgets/sorting_summary.py @@ -188,6 +188,6 @@ def plot_spikeinterface_gui(self, data_plot, **backend_kwargs): import spikeinterface_gui app = spikeinterface_gui.mkQApp() - win = spikeinterface_gui.MainWindow(sorting_analyzer) + win = spikeinterface_gui.MainWindow(sorting_analyzer, curation=data_plot["curation"]) win.show() app.exec_() From 4539550f72883b3ed2339c8a73a52c6d811647f9 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Sat, 29 Jun 2024 11:57:37 +0200 Subject: [PATCH 237/248] Tools for Generation of Hybrid recordings (#2436) Hybrid recording framework --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alessio Buccino Co-authored-by: Heberto Mayorquin Co-authored-by: Charlie Windolf --- .../benchmark_with_hybrid_recordings.rst | 2552 +++++++++++++++++ .../benchmark_with_hybrid_recordings_20_0.png | Bin 0 -> 183377 bytes .../benchmark_with_hybrid_recordings_28_1.png | Bin 0 -> 393421 bytes .../benchmark_with_hybrid_recordings_37_1.png | Bin 0 -> 81662 bytes .../benchmark_with_hybrid_recordings_9_0.png | Bin 0 -> 212689 bytes doc/how_to/index.rst | 1 + examples/how_to/README.md | 12 +- ..._neuropixels.py => analyze_neuropixels.py} | 2 +- .../benchmark_with_hybrid_recordings.py | 293 ++ pyproject.toml | 3 + src/spikeinterface/core/core_tools.py | 4 + src/spikeinterface/core/generate.py | 3 +- src/spikeinterface/core/node_pipeline.py | 1 - src/spikeinterface/core/sortinganalyzer.py | 4 +- src/spikeinterface/core/template.py | 10 +- src/spikeinterface/core/template_tools.py | 3 +- src/spikeinterface/generation/__init__.py | 8 + src/spikeinterface/generation/drift_tools.py | 85 +- .../generation/drifting_generator.py | 102 +- src/spikeinterface/generation/hybrid_tools.py | 568 ++++ src/spikeinterface/generation/noise_tools.py | 15 +- .../generation/tests/test_drift_tools.py | 24 +- .../generation/tests/test_hybrid_tools.py | 83 + .../generation/tests/test_mock.py | 3 - src/spikeinterface/postprocessing/__init__.py | 1 - .../postprocessing/localization_tools.py | 623 ++++ .../postprocessing/unit_locations.py | 608 +--- src/spikeinterface/preprocessing/__init__.py | 2 +- src/spikeinterface/preprocessing/motion.py | 53 +- .../preprocessing/tests/test_motion.py | 12 +- .../sorters/internal/spyking_circus2.py | 43 +- .../benchmark/benchmark_peak_localization.py | 2 +- .../sortingcomponents/motion_utils.py | 14 +- .../sortingcomponents/peak_detection.py | 2 +- .../sortingcomponents/peak_localization.py | 7 +- src/spikeinterface/sortingcomponents/tools.py | 1 + src/spikeinterface/widgets/unit_waveforms.py | 4 +- 37 files changed, 4399 insertions(+), 749 deletions(-) create mode 100644 doc/how_to/benchmark_with_hybrid_recordings.rst create mode 100644 doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_20_0.png create mode 100644 doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_28_1.png create mode 100644 doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_37_1.png create mode 100644 doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_9_0.png rename examples/how_to/{analyse_neuropixels.py => analyze_neuropixels.py} (99%) create mode 100644 examples/how_to/benchmark_with_hybrid_recordings.py create mode 100644 src/spikeinterface/generation/hybrid_tools.py create mode 100644 src/spikeinterface/generation/tests/test_hybrid_tools.py delete mode 100644 src/spikeinterface/generation/tests/test_mock.py create mode 100644 src/spikeinterface/postprocessing/localization_tools.py diff --git a/doc/how_to/benchmark_with_hybrid_recordings.rst b/doc/how_to/benchmark_with_hybrid_recordings.rst new file mode 100644 index 0000000000..9e8c6c7d65 --- /dev/null +++ b/doc/how_to/benchmark_with_hybrid_recordings.rst @@ -0,0 +1,2552 @@ +Benchmark spike sorting with hybrid recordings +============================================== + +This example shows how to use the SpikeInterface hybrid recordings +framework to benchmark spike sorting results. + +Hybrid recordings are built from existing recordings by injecting units +with known spiking activity. The template (aka average waveforms) of the +injected units can be from previous spike sorted data. In this example, +we will be using an open database of templates that we have constructed +from the International Brain Laboratory - Brain Wide Map (available on +`DANDI `__). + +Importantly, recordings from long-shank probes, such as Neuropixels, +usually experience drifts. Such drifts have to be taken into account in +order to smoothly inject spikes into the recording. + +.. code:: ipython3 + + import spikeinterface as si + import spikeinterface.extractors as se + import spikeinterface.preprocessing as spre + import spikeinterface.comparison as sc + import spikeinterface.generation as sgen + import spikeinterface.widgets as sw + + from spikeinterface.sortingcomponents.motion_estimation import estimate_motion + + import numpy as np + import matplotlib.pyplot as plt + from pathlib import Path + +.. code:: ipython3 + + %matplotlib inline + +.. code:: ipython3 + + si.set_global_job_kwargs(n_jobs=16) + +For this notebook, we will use a drifting recording similar to the one +acquired by Nick Steinmetz and available +`here `__, where an +triangular motion was imposed to the recording by moving the probe up +and down with a micro-manipulator. + +.. code:: ipython3 + + workdir = Path("/ssd980/working/hybrid/steinmetz_imposed_motion") + workdir.mkdir(exist_ok=True) + +.. code:: ipython3 + + recording_np1_imposed = se.read_spikeglx("/hdd1/data/spikeglx/nick-steinmetz/dataset1/p1_g0_t0/") + recording_preproc = spre.highpass_filter(recording_np1_imposed) + recording_preproc = spre.common_reference(recording_preproc) + +To visualize the drift, we can estimate the motion and plot it: + +.. code:: ipython3 + + # to correct for drift, we need a float dtype + recording_preproc = spre.astype(recording_preproc, "float") + _, motion_info = spre.correct_motion( + recording_preproc, preset="nonrigid_fast_and_accurate", n_jobs=4, progress_bar=True, output_motion_info=True + ) + + + +.. parsed-literal:: + + detect and localize: 0%| | 0/1958 [00:00 {minimum_depth}") + len(templates_selected_info) + + + + +.. parsed-literal:: + + 31 + + + +We can now retrieve the selected templates as a ``Templates`` object: + +.. code:: ipython3 + + templates_selected = sgen.query_templates_from_database(templates_selected_info, verbose=True) + print(templates_selected) + + +.. parsed-literal:: + + Fetching templates from 2 datasets + Templates: 31 units - 240 samples - 384 channels + sampling_frequency=30.00 kHz - ms_before=3.00 ms - ms_after=5.00 ms + Probe - IMEC - Neuropixels 1.0 - 18194814141 - 384ch - 1shanks + + +While we selected templates from a target aread and at certain depths, +we can see that the template amplitudes are quite large. This will make +spike sorting easy… we can further manipulate the ``Templates`` by +rescaling, relocating, or further selections with the +``sgen.scale_template_to_range``, ``sgen.relocate_templates``, and +``sgen.select_templates`` functions. + +In our case, let’s rescale the amplitudes between 50 and 150 +:math:`\mu`\ V and relocate them towards the bottom half of the probe, +where the activity looks interesting! + +.. code:: ipython3 + + min_amplitude = 50 + max_amplitude = 150 + templates_scaled = sgen.scale_template_to_range( + templates=templates_selected, + min_amplitude=min_amplitude, + max_amplitude=max_amplitude + ) + + min_displacement = 1000 + max_displacement = 3000 + templates_relocated = sgen.relocate_templates( + templates=templates_scaled, + min_displacement=min_displacement, + max_displacement=max_displacement + ) + +Let’s plot the selected templates: + +.. code:: ipython3 + + sparsity_plot = si.compute_sparsity(templates_relocated) + fig = plt.figure(figsize=(10, 10)) + w = sw.plot_unit_templates(templates_relocated, sparsity=sparsity_plot, ncols=4, figure=fig) + w.figure.subplots_adjust(wspace=0.5, hspace=0.7) + + + +.. image:: benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_20_0.png + + +Constructing hybrid recordings +------------------------------ + +We can construct now hybrid recordings with the selected templates. + +We will do this in two ways to show how important it is to account for +drifts when injecting hybrid spikes. + +- For the first recording we will not pass the estimated motion + (``recording_hybrid_ignore_drift``). +- For the second recording, we will pass and account for the estimated + motion (``recording_hybrid_with_drift``). + +.. code:: ipython3 + + recording_hybrid_ignore_drift, sorting_hybrid = sgen.generate_hybrid_recording( + recording=recording_preproc, templates=templates_relocated, seed=2308 + ) + recording_hybrid_ignore_drift + + + + +.. raw:: html + +
InjectTemplatesRecording: 384 channels - 30.0kHz - 1 segments - 58,715,724 samples - 1,957.19s (32.62 minutes) - float64 dtype - 167.99 GiB
Channel IDs
    ['imec0.ap#AP0' 'imec0.ap#AP1' 'imec0.ap#AP2' 'imec0.ap#AP3' + 'imec0.ap#AP4' 'imec0.ap#AP5' 'imec0.ap#AP6' 'imec0.ap#AP7' + 'imec0.ap#AP8' 'imec0.ap#AP9' 'imec0.ap#AP10' 'imec0.ap#AP11' + 'imec0.ap#AP12' 'imec0.ap#AP13' 'imec0.ap#AP14' 'imec0.ap#AP15' + 'imec0.ap#AP16' 'imec0.ap#AP17' 'imec0.ap#AP18' 'imec0.ap#AP19' + 'imec0.ap#AP20' 'imec0.ap#AP21' 'imec0.ap#AP22' 'imec0.ap#AP23' + 'imec0.ap#AP24' 'imec0.ap#AP25' 'imec0.ap#AP26' 'imec0.ap#AP27' + 'imec0.ap#AP28' 'imec0.ap#AP29' 'imec0.ap#AP30' 'imec0.ap#AP31' + 'imec0.ap#AP32' 'imec0.ap#AP33' 'imec0.ap#AP34' 'imec0.ap#AP35' + 'imec0.ap#AP36' 'imec0.ap#AP37' 'imec0.ap#AP38' 'imec0.ap#AP39' + 'imec0.ap#AP40' 'imec0.ap#AP41' 'imec0.ap#AP42' 'imec0.ap#AP43' + 'imec0.ap#AP44' 'imec0.ap#AP45' 'imec0.ap#AP46' 'imec0.ap#AP47' + 'imec0.ap#AP48' 'imec0.ap#AP49' 'imec0.ap#AP50' 'imec0.ap#AP51' + 'imec0.ap#AP52' 'imec0.ap#AP53' 'imec0.ap#AP54' 'imec0.ap#AP55' + 'imec0.ap#AP56' 'imec0.ap#AP57' 'imec0.ap#AP58' 'imec0.ap#AP59' + 'imec0.ap#AP60' 'imec0.ap#AP61' 'imec0.ap#AP62' 'imec0.ap#AP63' + 'imec0.ap#AP64' 'imec0.ap#AP65' 'imec0.ap#AP66' 'imec0.ap#AP67' + 'imec0.ap#AP68' 'imec0.ap#AP69' 'imec0.ap#AP70' 'imec0.ap#AP71' + 'imec0.ap#AP72' 'imec0.ap#AP73' 'imec0.ap#AP74' 'imec0.ap#AP75' + 'imec0.ap#AP76' 'imec0.ap#AP77' 'imec0.ap#AP78' 'imec0.ap#AP79' + 'imec0.ap#AP80' 'imec0.ap#AP81' 'imec0.ap#AP82' 'imec0.ap#AP83' + 'imec0.ap#AP84' 'imec0.ap#AP85' 'imec0.ap#AP86' 'imec0.ap#AP87' + 'imec0.ap#AP88' 'imec0.ap#AP89' 'imec0.ap#AP90' 'imec0.ap#AP91' + 'imec0.ap#AP92' 'imec0.ap#AP93' 'imec0.ap#AP94' 'imec0.ap#AP95' + 'imec0.ap#AP96' 'imec0.ap#AP97' 'imec0.ap#AP98' 'imec0.ap#AP99' + 'imec0.ap#AP100' 'imec0.ap#AP101' 'imec0.ap#AP102' 'imec0.ap#AP103' + 'imec0.ap#AP104' 'imec0.ap#AP105' 'imec0.ap#AP106' 'imec0.ap#AP107' + 'imec0.ap#AP108' 'imec0.ap#AP109' 'imec0.ap#AP110' 'imec0.ap#AP111' + 'imec0.ap#AP112' 'imec0.ap#AP113' 'imec0.ap#AP114' 'imec0.ap#AP115' + 'imec0.ap#AP116' 'imec0.ap#AP117' 'imec0.ap#AP118' 'imec0.ap#AP119' + 'imec0.ap#AP120' 'imec0.ap#AP121' 'imec0.ap#AP122' 'imec0.ap#AP123' + 'imec0.ap#AP124' 'imec0.ap#AP125' 'imec0.ap#AP126' 'imec0.ap#AP127' + 'imec0.ap#AP128' 'imec0.ap#AP129' 'imec0.ap#AP130' 'imec0.ap#AP131' + 'imec0.ap#AP132' 'imec0.ap#AP133' 'imec0.ap#AP134' 'imec0.ap#AP135' + 'imec0.ap#AP136' 'imec0.ap#AP137' 'imec0.ap#AP138' 'imec0.ap#AP139' + 'imec0.ap#AP140' 'imec0.ap#AP141' 'imec0.ap#AP142' 'imec0.ap#AP143' + 'imec0.ap#AP144' 'imec0.ap#AP145' 'imec0.ap#AP146' 'imec0.ap#AP147' + 'imec0.ap#AP148' 'imec0.ap#AP149' 'imec0.ap#AP150' 'imec0.ap#AP151' + 'imec0.ap#AP152' 'imec0.ap#AP153' 'imec0.ap#AP154' 'imec0.ap#AP155' + 'imec0.ap#AP156' 'imec0.ap#AP157' 'imec0.ap#AP158' 'imec0.ap#AP159' + 'imec0.ap#AP160' 'imec0.ap#AP161' 'imec0.ap#AP162' 'imec0.ap#AP163' + 'imec0.ap#AP164' 'imec0.ap#AP165' 'imec0.ap#AP166' 'imec0.ap#AP167' + 'imec0.ap#AP168' 'imec0.ap#AP169' 'imec0.ap#AP170' 'imec0.ap#AP171' + 'imec0.ap#AP172' 'imec0.ap#AP173' 'imec0.ap#AP174' 'imec0.ap#AP175' + 'imec0.ap#AP176' 'imec0.ap#AP177' 'imec0.ap#AP178' 'imec0.ap#AP179' + 'imec0.ap#AP180' 'imec0.ap#AP181' 'imec0.ap#AP182' 'imec0.ap#AP183' + 'imec0.ap#AP184' 'imec0.ap#AP185' 'imec0.ap#AP186' 'imec0.ap#AP187' + 'imec0.ap#AP188' 'imec0.ap#AP189' 'imec0.ap#AP190' 'imec0.ap#AP191' + 'imec0.ap#AP192' 'imec0.ap#AP193' 'imec0.ap#AP194' 'imec0.ap#AP195' + 'imec0.ap#AP196' 'imec0.ap#AP197' 'imec0.ap#AP198' 'imec0.ap#AP199' + 'imec0.ap#AP200' 'imec0.ap#AP201' 'imec0.ap#AP202' 'imec0.ap#AP203' + 'imec0.ap#AP204' 'imec0.ap#AP205' 'imec0.ap#AP206' 'imec0.ap#AP207' + 'imec0.ap#AP208' 'imec0.ap#AP209' 'imec0.ap#AP210' 'imec0.ap#AP211' + 'imec0.ap#AP212' 'imec0.ap#AP213' 'imec0.ap#AP214' 'imec0.ap#AP215' + 'imec0.ap#AP216' 'imec0.ap#AP217' 'imec0.ap#AP218' 'imec0.ap#AP219' + 'imec0.ap#AP220' 'imec0.ap#AP221' 'imec0.ap#AP222' 'imec0.ap#AP223' + 'imec0.ap#AP224' 'imec0.ap#AP225' 'imec0.ap#AP226' 'imec0.ap#AP227' + 'imec0.ap#AP228' 'imec0.ap#AP229' 'imec0.ap#AP230' 'imec0.ap#AP231' + 'imec0.ap#AP232' 'imec0.ap#AP233' 'imec0.ap#AP234' 'imec0.ap#AP235' + 'imec0.ap#AP236' 'imec0.ap#AP237' 'imec0.ap#AP238' 'imec0.ap#AP239' + 'imec0.ap#AP240' 'imec0.ap#AP241' 'imec0.ap#AP242' 'imec0.ap#AP243' + 'imec0.ap#AP244' 'imec0.ap#AP245' 'imec0.ap#AP246' 'imec0.ap#AP247' + 'imec0.ap#AP248' 'imec0.ap#AP249' 'imec0.ap#AP250' 'imec0.ap#AP251' + 'imec0.ap#AP252' 'imec0.ap#AP253' 'imec0.ap#AP254' 'imec0.ap#AP255' + 'imec0.ap#AP256' 'imec0.ap#AP257' 'imec0.ap#AP258' 'imec0.ap#AP259' + 'imec0.ap#AP260' 'imec0.ap#AP261' 'imec0.ap#AP262' 'imec0.ap#AP263' + 'imec0.ap#AP264' 'imec0.ap#AP265' 'imec0.ap#AP266' 'imec0.ap#AP267' + 'imec0.ap#AP268' 'imec0.ap#AP269' 'imec0.ap#AP270' 'imec0.ap#AP271' + 'imec0.ap#AP272' 'imec0.ap#AP273' 'imec0.ap#AP274' 'imec0.ap#AP275' + 'imec0.ap#AP276' 'imec0.ap#AP277' 'imec0.ap#AP278' 'imec0.ap#AP279' + 'imec0.ap#AP280' 'imec0.ap#AP281' 'imec0.ap#AP282' 'imec0.ap#AP283' + 'imec0.ap#AP284' 'imec0.ap#AP285' 'imec0.ap#AP286' 'imec0.ap#AP287' + 'imec0.ap#AP288' 'imec0.ap#AP289' 'imec0.ap#AP290' 'imec0.ap#AP291' + 'imec0.ap#AP292' 'imec0.ap#AP293' 'imec0.ap#AP294' 'imec0.ap#AP295' + 'imec0.ap#AP296' 'imec0.ap#AP297' 'imec0.ap#AP298' 'imec0.ap#AP299' + 'imec0.ap#AP300' 'imec0.ap#AP301' 'imec0.ap#AP302' 'imec0.ap#AP303' + 'imec0.ap#AP304' 'imec0.ap#AP305' 'imec0.ap#AP306' 'imec0.ap#AP307' + 'imec0.ap#AP308' 'imec0.ap#AP309' 'imec0.ap#AP310' 'imec0.ap#AP311' + 'imec0.ap#AP312' 'imec0.ap#AP313' 'imec0.ap#AP314' 'imec0.ap#AP315' + 'imec0.ap#AP316' 'imec0.ap#AP317' 'imec0.ap#AP318' 'imec0.ap#AP319' + 'imec0.ap#AP320' 'imec0.ap#AP321' 'imec0.ap#AP322' 'imec0.ap#AP323' + 'imec0.ap#AP324' 'imec0.ap#AP325' 'imec0.ap#AP326' 'imec0.ap#AP327' + 'imec0.ap#AP328' 'imec0.ap#AP329' 'imec0.ap#AP330' 'imec0.ap#AP331' + 'imec0.ap#AP332' 'imec0.ap#AP333' 'imec0.ap#AP334' 'imec0.ap#AP335' + 'imec0.ap#AP336' 'imec0.ap#AP337' 'imec0.ap#AP338' 'imec0.ap#AP339' + 'imec0.ap#AP340' 'imec0.ap#AP341' 'imec0.ap#AP342' 'imec0.ap#AP343' + 'imec0.ap#AP344' 'imec0.ap#AP345' 'imec0.ap#AP346' 'imec0.ap#AP347' + 'imec0.ap#AP348' 'imec0.ap#AP349' 'imec0.ap#AP350' 'imec0.ap#AP351' + 'imec0.ap#AP352' 'imec0.ap#AP353' 'imec0.ap#AP354' 'imec0.ap#AP355' + 'imec0.ap#AP356' 'imec0.ap#AP357' 'imec0.ap#AP358' 'imec0.ap#AP359' + 'imec0.ap#AP360' 'imec0.ap#AP361' 'imec0.ap#AP362' 'imec0.ap#AP363' + 'imec0.ap#AP364' 'imec0.ap#AP365' 'imec0.ap#AP366' 'imec0.ap#AP367' + 'imec0.ap#AP368' 'imec0.ap#AP369' 'imec0.ap#AP370' 'imec0.ap#AP371' + 'imec0.ap#AP372' 'imec0.ap#AP373' 'imec0.ap#AP374' 'imec0.ap#AP375' + 'imec0.ap#AP376' 'imec0.ap#AP377' 'imec0.ap#AP378' 'imec0.ap#AP379' + 'imec0.ap#AP380' 'imec0.ap#AP381' 'imec0.ap#AP382' 'imec0.ap#AP383']
Annotations
  • is_filtered : True
  • probe_0_planar_contour : [[ -11 9989] + [ -11 -11] + [ 24 -186] + [ 59 -11] + [ 59 9989]]
  • probes_info : [{'model_name': 'Neuropixels 1.0', 'manufacturer': 'IMEC', 'probe_type': '0', 'serial_number': '18408406612', 'part_number': 'PRB_1_4_0480_1_C', 'port': '1', 'slot': '2'}]
Channel Properties
    gain_to_uV [2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375]
    offset_to_uV [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    channel_names ['AP0' 'AP1' 'AP2' 'AP3' 'AP4' 'AP5' 'AP6' 'AP7' 'AP8' 'AP9' 'AP10' 'AP11' + 'AP12' 'AP13' 'AP14' 'AP15' 'AP16' 'AP17' 'AP18' 'AP19' 'AP20' 'AP21' + 'AP22' 'AP23' 'AP24' 'AP25' 'AP26' 'AP27' 'AP28' 'AP29' 'AP30' 'AP31' + 'AP32' 'AP33' 'AP34' 'AP35' 'AP36' 'AP37' 'AP38' 'AP39' 'AP40' 'AP41' + 'AP42' 'AP43' 'AP44' 'AP45' 'AP46' 'AP47' 'AP48' 'AP49' 'AP50' 'AP51' + 'AP52' 'AP53' 'AP54' 'AP55' 'AP56' 'AP57' 'AP58' 'AP59' 'AP60' 'AP61' + 'AP62' 'AP63' 'AP64' 'AP65' 'AP66' 'AP67' 'AP68' 'AP69' 'AP70' 'AP71' + 'AP72' 'AP73' 'AP74' 'AP75' 'AP76' 'AP77' 'AP78' 'AP79' 'AP80' 'AP81' + 'AP82' 'AP83' 'AP84' 'AP85' 'AP86' 'AP87' 'AP88' 'AP89' 'AP90' 'AP91' + 'AP92' 'AP93' 'AP94' 'AP95' 'AP96' 'AP97' 'AP98' 'AP99' 'AP100' 'AP101' + 'AP102' 'AP103' 'AP104' 'AP105' 'AP106' 'AP107' 'AP108' 'AP109' 'AP110' + 'AP111' 'AP112' 'AP113' 'AP114' 'AP115' 'AP116' 'AP117' 'AP118' 'AP119' + 'AP120' 'AP121' 'AP122' 'AP123' 'AP124' 'AP125' 'AP126' 'AP127' 'AP128' + 'AP129' 'AP130' 'AP131' 'AP132' 'AP133' 'AP134' 'AP135' 'AP136' 'AP137' + 'AP138' 'AP139' 'AP140' 'AP141' 'AP142' 'AP143' 'AP144' 'AP145' 'AP146' + 'AP147' 'AP148' 'AP149' 'AP150' 'AP151' 'AP152' 'AP153' 'AP154' 'AP155' + 'AP156' 'AP157' 'AP158' 'AP159' 'AP160' 'AP161' 'AP162' 'AP163' 'AP164' + 'AP165' 'AP166' 'AP167' 'AP168' 'AP169' 'AP170' 'AP171' 'AP172' 'AP173' + 'AP174' 'AP175' 'AP176' 'AP177' 'AP178' 'AP179' 'AP180' 'AP181' 'AP182' + 'AP183' 'AP184' 'AP185' 'AP186' 'AP187' 'AP188' 'AP189' 'AP190' 'AP191' + 'AP192' 'AP193' 'AP194' 'AP195' 'AP196' 'AP197' 'AP198' 'AP199' 'AP200' + 'AP201' 'AP202' 'AP203' 'AP204' 'AP205' 'AP206' 'AP207' 'AP208' 'AP209' + 'AP210' 'AP211' 'AP212' 'AP213' 'AP214' 'AP215' 'AP216' 'AP217' 'AP218' + 'AP219' 'AP220' 'AP221' 'AP222' 'AP223' 'AP224' 'AP225' 'AP226' 'AP227' + 'AP228' 'AP229' 'AP230' 'AP231' 'AP232' 'AP233' 'AP234' 'AP235' 'AP236' + 'AP237' 'AP238' 'AP239' 'AP240' 'AP241' 'AP242' 'AP243' 'AP244' 'AP245' + 'AP246' 'AP247' 'AP248' 'AP249' 'AP250' 'AP251' 'AP252' 'AP253' 'AP254' + 'AP255' 'AP256' 'AP257' 'AP258' 'AP259' 'AP260' 'AP261' 'AP262' 'AP263' + 'AP264' 'AP265' 'AP266' 'AP267' 'AP268' 'AP269' 'AP270' 'AP271' 'AP272' + 'AP273' 'AP274' 'AP275' 'AP276' 'AP277' 'AP278' 'AP279' 'AP280' 'AP281' + 'AP282' 'AP283' 'AP284' 'AP285' 'AP286' 'AP287' 'AP288' 'AP289' 'AP290' + 'AP291' 'AP292' 'AP293' 'AP294' 'AP295' 'AP296' 'AP297' 'AP298' 'AP299' + 'AP300' 'AP301' 'AP302' 'AP303' 'AP304' 'AP305' 'AP306' 'AP307' 'AP308' + 'AP309' 'AP310' 'AP311' 'AP312' 'AP313' 'AP314' 'AP315' 'AP316' 'AP317' + 'AP318' 'AP319' 'AP320' 'AP321' 'AP322' 'AP323' 'AP324' 'AP325' 'AP326' + 'AP327' 'AP328' 'AP329' 'AP330' 'AP331' 'AP332' 'AP333' 'AP334' 'AP335' + 'AP336' 'AP337' 'AP338' 'AP339' 'AP340' 'AP341' 'AP342' 'AP343' 'AP344' + 'AP345' 'AP346' 'AP347' 'AP348' 'AP349' 'AP350' 'AP351' 'AP352' 'AP353' + 'AP354' 'AP355' 'AP356' 'AP357' 'AP358' 'AP359' 'AP360' 'AP361' 'AP362' + 'AP363' 'AP364' 'AP365' 'AP366' 'AP367' 'AP368' 'AP369' 'AP370' 'AP371' + 'AP372' 'AP373' 'AP374' 'AP375' 'AP376' 'AP377' 'AP378' 'AP379' 'AP380' + 'AP381' 'AP382' 'AP383']
    contact_vector [(0, 16., 0., 'square', 12., '', 'e0', 0, 'um', 1., 0., 0., 1., 0, 0, 0, 500, 250, 1) + (0, 48., 0., 'square', 12., '', 'e1', 1, 'um', 1., 0., 0., 1., 1, 0, 0, 500, 250, 1) + (0, 0., 20., 'square', 12., '', 'e2', 2, 'um', 1., 0., 0., 1., 2, 0, 0, 500, 250, 1) + (0, 32., 20., 'square', 12., '', 'e3', 3, 'um', 1., 0., 0., 1., 3, 0, 0, 500, 250, 1) + (0, 16., 40., 'square', 12., '', 'e4', 4, 'um', 1., 0., 0., 1., 4, 0, 0, 500, 250, 1) + (0, 48., 40., 'square', 12., '', 'e5', 5, 'um', 1., 0., 0., 1., 5, 0, 0, 500, 250, 1) + (0, 0., 60., 'square', 12., '', 'e6', 6, 'um', 1., 0., 0., 1., 6, 0, 0, 500, 250, 1) + (0, 32., 60., 'square', 12., '', 'e7', 7, 'um', 1., 0., 0., 1., 7, 0, 0, 500, 250, 1) + (0, 16., 80., 'square', 12., '', 'e8', 8, 'um', 1., 0., 0., 1., 8, 0, 0, 500, 250, 1) + (0, 48., 80., 'square', 12., '', 'e9', 9, 'um', 1., 0., 0., 1., 9, 0, 0, 500, 250, 1) + (0, 0., 100., 'square', 12., '', 'e10', 10, 'um', 1., 0., 0., 1., 10, 0, 0, 500, 250, 1) + (0, 32., 100., 'square', 12., '', 'e11', 11, 'um', 1., 0., 0., 1., 11, 0, 0, 500, 250, 1) + (0, 16., 120., 'square', 12., '', 'e12', 12, 'um', 1., 0., 0., 1., 12, 0, 0, 500, 250, 1) + (0, 48., 120., 'square', 12., '', 'e13', 13, 'um', 1., 0., 0., 1., 13, 0, 0, 500, 250, 1) + (0, 0., 140., 'square', 12., '', 'e14', 14, 'um', 1., 0., 0., 1., 14, 0, 0, 500, 250, 1) + (0, 32., 140., 'square', 12., '', 'e15', 15, 'um', 1., 0., 0., 1., 15, 0, 0, 500, 250, 1) + (0, 16., 160., 'square', 12., '', 'e16', 16, 'um', 1., 0., 0., 1., 16, 0, 0, 500, 250, 1) + (0, 48., 160., 'square', 12., '', 'e17', 17, 'um', 1., 0., 0., 1., 17, 0, 0, 500, 250, 1) + (0, 0., 180., 'square', 12., '', 'e18', 18, 'um', 1., 0., 0., 1., 18, 0, 0, 500, 250, 1) + (0, 32., 180., 'square', 12., '', 'e19', 19, 'um', 1., 0., 0., 1., 19, 0, 0, 500, 250, 1) + (0, 16., 200., 'square', 12., '', 'e20', 20, 'um', 1., 0., 0., 1., 20, 0, 0, 500, 250, 1) + (0, 48., 200., 'square', 12., '', 'e21', 21, 'um', 1., 0., 0., 1., 21, 0, 0, 500, 250, 1) + (0, 0., 220., 'square', 12., '', 'e22', 22, 'um', 1., 0., 0., 1., 22, 0, 0, 500, 250, 1) + (0, 32., 220., 'square', 12., '', 'e23', 23, 'um', 1., 0., 0., 1., 23, 0, 0, 500, 250, 1) + (0, 16., 240., 'square', 12., '', 'e24', 24, 'um', 1., 0., 0., 1., 24, 0, 0, 500, 250, 1) + (0, 48., 240., 'square', 12., '', 'e25', 25, 'um', 1., 0., 0., 1., 25, 0, 0, 500, 250, 1) + (0, 0., 260., 'square', 12., '', 'e26', 26, 'um', 1., 0., 0., 1., 26, 0, 0, 500, 250, 1) + (0, 32., 260., 'square', 12., '', 'e27', 27, 'um', 1., 0., 0., 1., 27, 0, 0, 500, 250, 1) + (0, 16., 280., 'square', 12., '', 'e28', 28, 'um', 1., 0., 0., 1., 28, 0, 0, 500, 250, 1) + (0, 48., 280., 'square', 12., '', 'e29', 29, 'um', 1., 0., 0., 1., 29, 0, 0, 500, 250, 1) + (0, 0., 300., 'square', 12., '', 'e30', 30, 'um', 1., 0., 0., 1., 30, 0, 0, 500, 250, 1) + (0, 32., 300., 'square', 12., '', 'e31', 31, 'um', 1., 0., 0., 1., 31, 0, 0, 500, 250, 1) + (0, 16., 320., 'square', 12., '', 'e32', 32, 'um', 1., 0., 0., 1., 32, 0, 0, 500, 250, 1) + (0, 48., 320., 'square', 12., '', 'e33', 33, 'um', 1., 0., 0., 1., 33, 0, 0, 500, 250, 1) + (0, 0., 340., 'square', 12., '', 'e34', 34, 'um', 1., 0., 0., 1., 34, 0, 0, 500, 250, 1) + (0, 32., 340., 'square', 12., '', 'e35', 35, 'um', 1., 0., 0., 1., 35, 0, 0, 500, 250, 1) + (0, 16., 360., 'square', 12., '', 'e36', 36, 'um', 1., 0., 0., 1., 36, 0, 0, 500, 250, 1) + (0, 48., 360., 'square', 12., '', 'e37', 37, 'um', 1., 0., 0., 1., 37, 0, 0, 500, 250, 1) + (0, 0., 380., 'square', 12., '', 'e38', 38, 'um', 1., 0., 0., 1., 38, 0, 0, 500, 250, 1) + (0, 32., 380., 'square', 12., '', 'e39', 39, 'um', 1., 0., 0., 1., 39, 0, 0, 500, 250, 1) + (0, 16., 400., 'square', 12., '', 'e40', 40, 'um', 1., 0., 0., 1., 40, 0, 0, 500, 250, 1) + (0, 48., 400., 'square', 12., '', 'e41', 41, 'um', 1., 0., 0., 1., 41, 0, 0, 500, 250, 1) + (0, 0., 420., 'square', 12., '', 'e42', 42, 'um', 1., 0., 0., 1., 42, 0, 0, 500, 250, 1) + (0, 32., 420., 'square', 12., '', 'e43', 43, 'um', 1., 0., 0., 1., 43, 0, 0, 500, 250, 1) + (0, 16., 440., 'square', 12., '', 'e44', 44, 'um', 1., 0., 0., 1., 44, 0, 0, 500, 250, 1) + (0, 48., 440., 'square', 12., '', 'e45', 45, 'um', 1., 0., 0., 1., 45, 0, 0, 500, 250, 1) + (0, 0., 460., 'square', 12., '', 'e46', 46, 'um', 1., 0., 0., 1., 46, 0, 0, 500, 250, 1) + (0, 32., 460., 'square', 12., '', 'e47', 47, 'um', 1., 0., 0., 1., 47, 0, 0, 500, 250, 1) + (0, 16., 480., 'square', 12., '', 'e48', 48, 'um', 1., 0., 0., 1., 48, 0, 0, 500, 250, 1) + (0, 48., 480., 'square', 12., '', 'e49', 49, 'um', 1., 0., 0., 1., 49, 0, 0, 500, 250, 1) + (0, 0., 500., 'square', 12., '', 'e50', 50, 'um', 1., 0., 0., 1., 50, 0, 0, 500, 250, 1) + (0, 32., 500., 'square', 12., '', 'e51', 51, 'um', 1., 0., 0., 1., 51, 0, 0, 500, 250, 1) + (0, 16., 520., 'square', 12., '', 'e52', 52, 'um', 1., 0., 0., 1., 52, 0, 0, 500, 250, 1) + (0, 48., 520., 'square', 12., '', 'e53', 53, 'um', 1., 0., 0., 1., 53, 0, 0, 500, 250, 1) + (0, 0., 540., 'square', 12., '', 'e54', 54, 'um', 1., 0., 0., 1., 54, 0, 0, 500, 250, 1) + (0, 32., 540., 'square', 12., '', 'e55', 55, 'um', 1., 0., 0., 1., 55, 0, 0, 500, 250, 1) + (0, 16., 560., 'square', 12., '', 'e56', 56, 'um', 1., 0., 0., 1., 56, 0, 0, 500, 250, 1) + (0, 48., 560., 'square', 12., '', 'e57', 57, 'um', 1., 0., 0., 1., 57, 0, 0, 500, 250, 1) + (0, 0., 580., 'square', 12., '', 'e58', 58, 'um', 1., 0., 0., 1., 58, 0, 0, 500, 250, 1) + (0, 32., 580., 'square', 12., '', 'e59', 59, 'um', 1., 0., 0., 1., 59, 0, 0, 500, 250, 1) + (0, 16., 600., 'square', 12., '', 'e60', 60, 'um', 1., 0., 0., 1., 60, 0, 0, 500, 250, 1) + (0, 48., 600., 'square', 12., '', 'e61', 61, 'um', 1., 0., 0., 1., 61, 0, 0, 500, 250, 1) + (0, 0., 620., 'square', 12., '', 'e62', 62, 'um', 1., 0., 0., 1., 62, 0, 0, 500, 250, 1) + (0, 32., 620., 'square', 12., '', 'e63', 63, 'um', 1., 0., 0., 1., 63, 0, 0, 500, 250, 1) + (0, 16., 640., 'square', 12., '', 'e64', 64, 'um', 1., 0., 0., 1., 64, 0, 0, 500, 250, 1) + (0, 48., 640., 'square', 12., '', 'e65', 65, 'um', 1., 0., 0., 1., 65, 0, 0, 500, 250, 1) + (0, 0., 660., 'square', 12., '', 'e66', 66, 'um', 1., 0., 0., 1., 66, 0, 0, 500, 250, 1) + (0, 32., 660., 'square', 12., '', 'e67', 67, 'um', 1., 0., 0., 1., 67, 0, 0, 500, 250, 1) + (0, 16., 680., 'square', 12., '', 'e68', 68, 'um', 1., 0., 0., 1., 68, 0, 0, 500, 250, 1) + (0, 48., 680., 'square', 12., '', 'e69', 69, 'um', 1., 0., 0., 1., 69, 0, 0, 500, 250, 1) + (0, 0., 700., 'square', 12., '', 'e70', 70, 'um', 1., 0., 0., 1., 70, 0, 0, 500, 250, 1) + (0, 32., 700., 'square', 12., '', 'e71', 71, 'um', 1., 0., 0., 1., 71, 0, 0, 500, 250, 1) + (0, 16., 720., 'square', 12., '', 'e72', 72, 'um', 1., 0., 0., 1., 72, 0, 0, 500, 250, 1) + (0, 48., 720., 'square', 12., '', 'e73', 73, 'um', 1., 0., 0., 1., 73, 0, 0, 500, 250, 1) + (0, 0., 740., 'square', 12., '', 'e74', 74, 'um', 1., 0., 0., 1., 74, 0, 0, 500, 250, 1) + (0, 32., 740., 'square', 12., '', 'e75', 75, 'um', 1., 0., 0., 1., 75, 0, 0, 500, 250, 1) + (0, 16., 760., 'square', 12., '', 'e76', 76, 'um', 1., 0., 0., 1., 76, 0, 0, 500, 250, 1) + (0, 48., 760., 'square', 12., '', 'e77', 77, 'um', 1., 0., 0., 1., 77, 0, 0, 500, 250, 1) + (0, 0., 780., 'square', 12., '', 'e78', 78, 'um', 1., 0., 0., 1., 78, 0, 0, 500, 250, 1) + (0, 32., 780., 'square', 12., '', 'e79', 79, 'um', 1., 0., 0., 1., 79, 0, 0, 500, 250, 1) + (0, 16., 800., 'square', 12., '', 'e80', 80, 'um', 1., 0., 0., 1., 80, 0, 0, 500, 250, 1) + (0, 48., 800., 'square', 12., '', 'e81', 81, 'um', 1., 0., 0., 1., 81, 0, 0, 500, 250, 1) + (0, 0., 820., 'square', 12., '', 'e82', 82, 'um', 1., 0., 0., 1., 82, 0, 0, 500, 250, 1) + (0, 32., 820., 'square', 12., '', 'e83', 83, 'um', 1., 0., 0., 1., 83, 0, 0, 500, 250, 1) + (0, 16., 840., 'square', 12., '', 'e84', 84, 'um', 1., 0., 0., 1., 84, 0, 0, 500, 250, 1) + (0, 48., 840., 'square', 12., '', 'e85', 85, 'um', 1., 0., 0., 1., 85, 0, 0, 500, 250, 1) + (0, 0., 860., 'square', 12., '', 'e86', 86, 'um', 1., 0., 0., 1., 86, 0, 0, 500, 250, 1) + (0, 32., 860., 'square', 12., '', 'e87', 87, 'um', 1., 0., 0., 1., 87, 0, 0, 500, 250, 1) + (0, 16., 880., 'square', 12., '', 'e88', 88, 'um', 1., 0., 0., 1., 88, 0, 0, 500, 250, 1) + (0, 48., 880., 'square', 12., '', 'e89', 89, 'um', 1., 0., 0., 1., 89, 0, 0, 500, 250, 1) + (0, 0., 900., 'square', 12., '', 'e90', 90, 'um', 1., 0., 0., 1., 90, 0, 0, 500, 250, 1) + (0, 32., 900., 'square', 12., '', 'e91', 91, 'um', 1., 0., 0., 1., 91, 0, 0, 500, 250, 1) + (0, 16., 920., 'square', 12., '', 'e92', 92, 'um', 1., 0., 0., 1., 92, 0, 0, 500, 250, 1) + (0, 48., 920., 'square', 12., '', 'e93', 93, 'um', 1., 0., 0., 1., 93, 0, 0, 500, 250, 1) + (0, 0., 940., 'square', 12., '', 'e94', 94, 'um', 1., 0., 0., 1., 94, 0, 0, 500, 250, 1) + (0, 32., 940., 'square', 12., '', 'e95', 95, 'um', 1., 0., 0., 1., 95, 0, 0, 500, 250, 1) + (0, 16., 960., 'square', 12., '', 'e96', 96, 'um', 1., 0., 0., 1., 96, 0, 0, 500, 250, 1) + (0, 48., 960., 'square', 12., '', 'e97', 97, 'um', 1., 0., 0., 1., 97, 0, 0, 500, 250, 1) + (0, 0., 980., 'square', 12., '', 'e98', 98, 'um', 1., 0., 0., 1., 98, 0, 0, 500, 250, 1) + (0, 32., 980., 'square', 12., '', 'e99', 99, 'um', 1., 0., 0., 1., 99, 0, 0, 500, 250, 1) + (0, 16., 1000., 'square', 12., '', 'e100', 100, 'um', 1., 0., 0., 1., 100, 0, 0, 500, 250, 1) + (0, 48., 1000., 'square', 12., '', 'e101', 101, 'um', 1., 0., 0., 1., 101, 0, 0, 500, 250, 1) + (0, 0., 1020., 'square', 12., '', 'e102', 102, 'um', 1., 0., 0., 1., 102, 0, 0, 500, 250, 1) + (0, 32., 1020., 'square', 12., '', 'e103', 103, 'um', 1., 0., 0., 1., 103, 0, 0, 500, 250, 1) + (0, 16., 1040., 'square', 12., '', 'e104', 104, 'um', 1., 0., 0., 1., 104, 0, 0, 500, 250, 1) + (0, 48., 1040., 'square', 12., '', 'e105', 105, 'um', 1., 0., 0., 1., 105, 0, 0, 500, 250, 1) + (0, 0., 1060., 'square', 12., '', 'e106', 106, 'um', 1., 0., 0., 1., 106, 0, 0, 500, 250, 1) + (0, 32., 1060., 'square', 12., '', 'e107', 107, 'um', 1., 0., 0., 1., 107, 0, 0, 500, 250, 1) + (0, 16., 1080., 'square', 12., '', 'e108', 108, 'um', 1., 0., 0., 1., 108, 0, 0, 500, 250, 1) + (0, 48., 1080., 'square', 12., '', 'e109', 109, 'um', 1., 0., 0., 1., 109, 0, 0, 500, 250, 1) + (0, 0., 1100., 'square', 12., '', 'e110', 110, 'um', 1., 0., 0., 1., 110, 0, 0, 500, 250, 1) + (0, 32., 1100., 'square', 12., '', 'e111', 111, 'um', 1., 0., 0., 1., 111, 0, 0, 500, 250, 1) + (0, 16., 1120., 'square', 12., '', 'e112', 112, 'um', 1., 0., 0., 1., 112, 0, 0, 500, 250, 1) + (0, 48., 1120., 'square', 12., '', 'e113', 113, 'um', 1., 0., 0., 1., 113, 0, 0, 500, 250, 1) + (0, 0., 1140., 'square', 12., '', 'e114', 114, 'um', 1., 0., 0., 1., 114, 0, 0, 500, 250, 1) + (0, 32., 1140., 'square', 12., '', 'e115', 115, 'um', 1., 0., 0., 1., 115, 0, 0, 500, 250, 1) + (0, 16., 1160., 'square', 12., '', 'e116', 116, 'um', 1., 0., 0., 1., 116, 0, 0, 500, 250, 1) + (0, 48., 1160., 'square', 12., '', 'e117', 117, 'um', 1., 0., 0., 1., 117, 0, 0, 500, 250, 1) + (0, 0., 1180., 'square', 12., '', 'e118', 118, 'um', 1., 0., 0., 1., 118, 0, 0, 500, 250, 1) + (0, 32., 1180., 'square', 12., '', 'e119', 119, 'um', 1., 0., 0., 1., 119, 0, 0, 500, 250, 1) + (0, 16., 1200., 'square', 12., '', 'e120', 120, 'um', 1., 0., 0., 1., 120, 0, 0, 500, 250, 1) + (0, 48., 1200., 'square', 12., '', 'e121', 121, 'um', 1., 0., 0., 1., 121, 0, 0, 500, 250, 1) + (0, 0., 1220., 'square', 12., '', 'e122', 122, 'um', 1., 0., 0., 1., 122, 0, 0, 500, 250, 1) + (0, 32., 1220., 'square', 12., '', 'e123', 123, 'um', 1., 0., 0., 1., 123, 0, 0, 500, 250, 1) + (0, 16., 1240., 'square', 12., '', 'e124', 124, 'um', 1., 0., 0., 1., 124, 0, 0, 500, 250, 1) + (0, 48., 1240., 'square', 12., '', 'e125', 125, 'um', 1., 0., 0., 1., 125, 0, 0, 500, 250, 1) + (0, 0., 1260., 'square', 12., '', 'e126', 126, 'um', 1., 0., 0., 1., 126, 0, 0, 500, 250, 1) + (0, 32., 1260., 'square', 12., '', 'e127', 127, 'um', 1., 0., 0., 1., 127, 0, 0, 500, 250, 1) + (0, 16., 1280., 'square', 12., '', 'e128', 128, 'um', 1., 0., 0., 1., 128, 0, 0, 500, 250, 1) + (0, 48., 1280., 'square', 12., '', 'e129', 129, 'um', 1., 0., 0., 1., 129, 0, 0, 500, 250, 1) + (0, 0., 1300., 'square', 12., '', 'e130', 130, 'um', 1., 0., 0., 1., 130, 0, 0, 500, 250, 1) + (0, 32., 1300., 'square', 12., '', 'e131', 131, 'um', 1., 0., 0., 1., 131, 0, 0, 500, 250, 1) + (0, 16., 1320., 'square', 12., '', 'e132', 132, 'um', 1., 0., 0., 1., 132, 0, 0, 500, 250, 1) + (0, 48., 1320., 'square', 12., '', 'e133', 133, 'um', 1., 0., 0., 1., 133, 0, 0, 500, 250, 1) + (0, 0., 1340., 'square', 12., '', 'e134', 134, 'um', 1., 0., 0., 1., 134, 0, 0, 500, 250, 1) + (0, 32., 1340., 'square', 12., '', 'e135', 135, 'um', 1., 0., 0., 1., 135, 0, 0, 500, 250, 1) + (0, 16., 1360., 'square', 12., '', 'e136', 136, 'um', 1., 0., 0., 1., 136, 0, 0, 500, 250, 1) + (0, 48., 1360., 'square', 12., '', 'e137', 137, 'um', 1., 0., 0., 1., 137, 0, 0, 500, 250, 1) + (0, 0., 1380., 'square', 12., '', 'e138', 138, 'um', 1., 0., 0., 1., 138, 0, 0, 500, 250, 1) + (0, 32., 1380., 'square', 12., '', 'e139', 139, 'um', 1., 0., 0., 1., 139, 0, 0, 500, 250, 1) + (0, 16., 1400., 'square', 12., '', 'e140', 140, 'um', 1., 0., 0., 1., 140, 0, 0, 500, 250, 1) + (0, 48., 1400., 'square', 12., '', 'e141', 141, 'um', 1., 0., 0., 1., 141, 0, 0, 500, 250, 1) + (0, 0., 1420., 'square', 12., '', 'e142', 142, 'um', 1., 0., 0., 1., 142, 0, 0, 500, 250, 1) + (0, 32., 1420., 'square', 12., '', 'e143', 143, 'um', 1., 0., 0., 1., 143, 0, 0, 500, 250, 1) + (0, 16., 1440., 'square', 12., '', 'e144', 144, 'um', 1., 0., 0., 1., 144, 0, 0, 500, 250, 1) + (0, 48., 1440., 'square', 12., '', 'e145', 145, 'um', 1., 0., 0., 1., 145, 0, 0, 500, 250, 1) + (0, 0., 1460., 'square', 12., '', 'e146', 146, 'um', 1., 0., 0., 1., 146, 0, 0, 500, 250, 1) + (0, 32., 1460., 'square', 12., '', 'e147', 147, 'um', 1., 0., 0., 1., 147, 0, 0, 500, 250, 1) + (0, 16., 1480., 'square', 12., '', 'e148', 148, 'um', 1., 0., 0., 1., 148, 0, 0, 500, 250, 1) + (0, 48., 1480., 'square', 12., '', 'e149', 149, 'um', 1., 0., 0., 1., 149, 0, 0, 500, 250, 1) + (0, 0., 1500., 'square', 12., '', 'e150', 150, 'um', 1., 0., 0., 1., 150, 0, 0, 500, 250, 1) + (0, 32., 1500., 'square', 12., '', 'e151', 151, 'um', 1., 0., 0., 1., 151, 0, 0, 500, 250, 1) + (0, 16., 1520., 'square', 12., '', 'e152', 152, 'um', 1., 0., 0., 1., 152, 0, 0, 500, 250, 1) + (0, 48., 1520., 'square', 12., '', 'e153', 153, 'um', 1., 0., 0., 1., 153, 0, 0, 500, 250, 1) + (0, 0., 1540., 'square', 12., '', 'e154', 154, 'um', 1., 0., 0., 1., 154, 0, 0, 500, 250, 1) + (0, 32., 1540., 'square', 12., '', 'e155', 155, 'um', 1., 0., 0., 1., 155, 0, 0, 500, 250, 1) + (0, 16., 1560., 'square', 12., '', 'e156', 156, 'um', 1., 0., 0., 1., 156, 0, 0, 500, 250, 1) + (0, 48., 1560., 'square', 12., '', 'e157', 157, 'um', 1., 0., 0., 1., 157, 0, 0, 500, 250, 1) + (0, 0., 1580., 'square', 12., '', 'e158', 158, 'um', 1., 0., 0., 1., 158, 0, 0, 500, 250, 1) + (0, 32., 1580., 'square', 12., '', 'e159', 159, 'um', 1., 0., 0., 1., 159, 0, 0, 500, 250, 1) + (0, 16., 1600., 'square', 12., '', 'e160', 160, 'um', 1., 0., 0., 1., 160, 0, 0, 500, 250, 1) + (0, 48., 1600., 'square', 12., '', 'e161', 161, 'um', 1., 0., 0., 1., 161, 0, 0, 500, 250, 1) + (0, 0., 1620., 'square', 12., '', 'e162', 162, 'um', 1., 0., 0., 1., 162, 0, 0, 500, 250, 1) + (0, 32., 1620., 'square', 12., '', 'e163', 163, 'um', 1., 0., 0., 1., 163, 0, 0, 500, 250, 1) + (0, 16., 1640., 'square', 12., '', 'e164', 164, 'um', 1., 0., 0., 1., 164, 0, 0, 500, 250, 1) + (0, 48., 1640., 'square', 12., '', 'e165', 165, 'um', 1., 0., 0., 1., 165, 0, 0, 500, 250, 1) + (0, 0., 1660., 'square', 12., '', 'e166', 166, 'um', 1., 0., 0., 1., 166, 0, 0, 500, 250, 1) + (0, 32., 1660., 'square', 12., '', 'e167', 167, 'um', 1., 0., 0., 1., 167, 0, 0, 500, 250, 1) + (0, 16., 1680., 'square', 12., '', 'e168', 168, 'um', 1., 0., 0., 1., 168, 0, 0, 500, 250, 1) + (0, 48., 1680., 'square', 12., '', 'e169', 169, 'um', 1., 0., 0., 1., 169, 0, 0, 500, 250, 1) + (0, 0., 1700., 'square', 12., '', 'e170', 170, 'um', 1., 0., 0., 1., 170, 0, 0, 500, 250, 1) + (0, 32., 1700., 'square', 12., '', 'e171', 171, 'um', 1., 0., 0., 1., 171, 0, 0, 500, 250, 1) + (0, 16., 1720., 'square', 12., '', 'e172', 172, 'um', 1., 0., 0., 1., 172, 0, 0, 500, 250, 1) + (0, 48., 1720., 'square', 12., '', 'e173', 173, 'um', 1., 0., 0., 1., 173, 0, 0, 500, 250, 1) + (0, 0., 1740., 'square', 12., '', 'e174', 174, 'um', 1., 0., 0., 1., 174, 0, 0, 500, 250, 1) + (0, 32., 1740., 'square', 12., '', 'e175', 175, 'um', 1., 0., 0., 1., 175, 0, 0, 500, 250, 1) + (0, 16., 1760., 'square', 12., '', 'e176', 176, 'um', 1., 0., 0., 1., 176, 0, 0, 500, 250, 1) + (0, 48., 1760., 'square', 12., '', 'e177', 177, 'um', 1., 0., 0., 1., 177, 0, 0, 500, 250, 1) + (0, 0., 1780., 'square', 12., '', 'e178', 178, 'um', 1., 0., 0., 1., 178, 0, 0, 500, 250, 1) + (0, 32., 1780., 'square', 12., '', 'e179', 179, 'um', 1., 0., 0., 1., 179, 0, 0, 500, 250, 1) + (0, 16., 1800., 'square', 12., '', 'e180', 180, 'um', 1., 0., 0., 1., 180, 0, 0, 500, 250, 1) + (0, 48., 1800., 'square', 12., '', 'e181', 181, 'um', 1., 0., 0., 1., 181, 0, 0, 500, 250, 1) + (0, 0., 1820., 'square', 12., '', 'e182', 182, 'um', 1., 0., 0., 1., 182, 0, 0, 500, 250, 1) + (0, 32., 1820., 'square', 12., '', 'e183', 183, 'um', 1., 0., 0., 1., 183, 0, 0, 500, 250, 1) + (0, 16., 1840., 'square', 12., '', 'e184', 184, 'um', 1., 0., 0., 1., 184, 0, 0, 500, 250, 1) + (0, 48., 1840., 'square', 12., '', 'e185', 185, 'um', 1., 0., 0., 1., 185, 0, 0, 500, 250, 1) + (0, 0., 1860., 'square', 12., '', 'e186', 186, 'um', 1., 0., 0., 1., 186, 0, 0, 500, 250, 1) + (0, 32., 1860., 'square', 12., '', 'e187', 187, 'um', 1., 0., 0., 1., 187, 0, 0, 500, 250, 1) + (0, 16., 1880., 'square', 12., '', 'e188', 188, 'um', 1., 0., 0., 1., 188, 0, 0, 500, 250, 1) + (0, 48., 1880., 'square', 12., '', 'e189', 189, 'um', 1., 0., 0., 1., 189, 0, 0, 500, 250, 1) + (0, 0., 1900., 'square', 12., '', 'e190', 190, 'um', 1., 0., 0., 1., 190, 0, 0, 500, 250, 1) + (0, 32., 1900., 'square', 12., '', 'e191', 191, 'um', 1., 0., 0., 1., 191, 0, 0, 500, 250, 1) + (0, 16., 1920., 'square', 12., '', 'e192', 192, 'um', 1., 0., 0., 1., 192, 0, 0, 500, 250, 1) + (0, 48., 1920., 'square', 12., '', 'e193', 193, 'um', 1., 0., 0., 1., 193, 0, 0, 500, 250, 1) + (0, 0., 1940., 'square', 12., '', 'e194', 194, 'um', 1., 0., 0., 1., 194, 0, 0, 500, 250, 1) + (0, 32., 1940., 'square', 12., '', 'e195', 195, 'um', 1., 0., 0., 1., 195, 0, 0, 500, 250, 1) + (0, 16., 1960., 'square', 12., '', 'e196', 196, 'um', 1., 0., 0., 1., 196, 0, 0, 500, 250, 1) + (0, 48., 1960., 'square', 12., '', 'e197', 197, 'um', 1., 0., 0., 1., 197, 0, 0, 500, 250, 1) + (0, 0., 1980., 'square', 12., '', 'e198', 198, 'um', 1., 0., 0., 1., 198, 0, 0, 500, 250, 1) + (0, 32., 1980., 'square', 12., '', 'e199', 199, 'um', 1., 0., 0., 1., 199, 0, 0, 500, 250, 1) + (0, 16., 2000., 'square', 12., '', 'e200', 200, 'um', 1., 0., 0., 1., 200, 0, 0, 500, 250, 1) + (0, 48., 2000., 'square', 12., '', 'e201', 201, 'um', 1., 0., 0., 1., 201, 0, 0, 500, 250, 1) + (0, 0., 2020., 'square', 12., '', 'e202', 202, 'um', 1., 0., 0., 1., 202, 0, 0, 500, 250, 1) + (0, 32., 2020., 'square', 12., '', 'e203', 203, 'um', 1., 0., 0., 1., 203, 0, 0, 500, 250, 1) + (0, 16., 2040., 'square', 12., '', 'e204', 204, 'um', 1., 0., 0., 1., 204, 0, 0, 500, 250, 1) + (0, 48., 2040., 'square', 12., '', 'e205', 205, 'um', 1., 0., 0., 1., 205, 0, 0, 500, 250, 1) + (0, 0., 2060., 'square', 12., '', 'e206', 206, 'um', 1., 0., 0., 1., 206, 0, 0, 500, 250, 1) + (0, 32., 2060., 'square', 12., '', 'e207', 207, 'um', 1., 0., 0., 1., 207, 0, 0, 500, 250, 1) + (0, 16., 2080., 'square', 12., '', 'e208', 208, 'um', 1., 0., 0., 1., 208, 0, 0, 500, 250, 1) + (0, 48., 2080., 'square', 12., '', 'e209', 209, 'um', 1., 0., 0., 1., 209, 0, 0, 500, 250, 1) + (0, 0., 2100., 'square', 12., '', 'e210', 210, 'um', 1., 0., 0., 1., 210, 0, 0, 500, 250, 1) + (0, 32., 2100., 'square', 12., '', 'e211', 211, 'um', 1., 0., 0., 1., 211, 0, 0, 500, 250, 1) + (0, 16., 2120., 'square', 12., '', 'e212', 212, 'um', 1., 0., 0., 1., 212, 0, 0, 500, 250, 1) + (0, 48., 2120., 'square', 12., '', 'e213', 213, 'um', 1., 0., 0., 1., 213, 0, 0, 500, 250, 1) + (0, 0., 2140., 'square', 12., '', 'e214', 214, 'um', 1., 0., 0., 1., 214, 0, 0, 500, 250, 1) + (0, 32., 2140., 'square', 12., '', 'e215', 215, 'um', 1., 0., 0., 1., 215, 0, 0, 500, 250, 1) + (0, 16., 2160., 'square', 12., '', 'e216', 216, 'um', 1., 0., 0., 1., 216, 0, 0, 500, 250, 1) + (0, 48., 2160., 'square', 12., '', 'e217', 217, 'um', 1., 0., 0., 1., 217, 0, 0, 500, 250, 1) + (0, 0., 2180., 'square', 12., '', 'e218', 218, 'um', 1., 0., 0., 1., 218, 0, 0, 500, 250, 1) + (0, 32., 2180., 'square', 12., '', 'e219', 219, 'um', 1., 0., 0., 1., 219, 0, 0, 500, 250, 1) + (0, 16., 2200., 'square', 12., '', 'e220', 220, 'um', 1., 0., 0., 1., 220, 0, 0, 500, 250, 1) + (0, 48., 2200., 'square', 12., '', 'e221', 221, 'um', 1., 0., 0., 1., 221, 0, 0, 500, 250, 1) + (0, 0., 2220., 'square', 12., '', 'e222', 222, 'um', 1., 0., 0., 1., 222, 0, 0, 500, 250, 1) + (0, 32., 2220., 'square', 12., '', 'e223', 223, 'um', 1., 0., 0., 1., 223, 0, 0, 500, 250, 1) + (0, 16., 2240., 'square', 12., '', 'e224', 224, 'um', 1., 0., 0., 1., 224, 0, 0, 500, 250, 1) + (0, 48., 2240., 'square', 12., '', 'e225', 225, 'um', 1., 0., 0., 1., 225, 0, 0, 500, 250, 1) + (0, 0., 2260., 'square', 12., '', 'e226', 226, 'um', 1., 0., 0., 1., 226, 0, 0, 500, 250, 1) + (0, 32., 2260., 'square', 12., '', 'e227', 227, 'um', 1., 0., 0., 1., 227, 0, 0, 500, 250, 1) + (0, 16., 2280., 'square', 12., '', 'e228', 228, 'um', 1., 0., 0., 1., 228, 0, 0, 500, 250, 1) + (0, 48., 2280., 'square', 12., '', 'e229', 229, 'um', 1., 0., 0., 1., 229, 0, 0, 500, 250, 1) + (0, 0., 2300., 'square', 12., '', 'e230', 230, 'um', 1., 0., 0., 1., 230, 0, 0, 500, 250, 1) + (0, 32., 2300., 'square', 12., '', 'e231', 231, 'um', 1., 0., 0., 1., 231, 0, 0, 500, 250, 1) + (0, 16., 2320., 'square', 12., '', 'e232', 232, 'um', 1., 0., 0., 1., 232, 0, 0, 500, 250, 1) + (0, 48., 2320., 'square', 12., '', 'e233', 233, 'um', 1., 0., 0., 1., 233, 0, 0, 500, 250, 1) + (0, 0., 2340., 'square', 12., '', 'e234', 234, 'um', 1., 0., 0., 1., 234, 0, 0, 500, 250, 1) + (0, 32., 2340., 'square', 12., '', 'e235', 235, 'um', 1., 0., 0., 1., 235, 0, 0, 500, 250, 1) + (0, 16., 2360., 'square', 12., '', 'e236', 236, 'um', 1., 0., 0., 1., 236, 0, 0, 500, 250, 1) + (0, 48., 2360., 'square', 12., '', 'e237', 237, 'um', 1., 0., 0., 1., 237, 0, 0, 500, 250, 1) + (0, 0., 2380., 'square', 12., '', 'e238', 238, 'um', 1., 0., 0., 1., 238, 0, 0, 500, 250, 1) + (0, 32., 2380., 'square', 12., '', 'e239', 239, 'um', 1., 0., 0., 1., 239, 0, 0, 500, 250, 1) + (0, 16., 2400., 'square', 12., '', 'e240', 240, 'um', 1., 0., 0., 1., 240, 0, 0, 500, 250, 1) + (0, 48., 2400., 'square', 12., '', 'e241', 241, 'um', 1., 0., 0., 1., 241, 0, 0, 500, 250, 1) + (0, 0., 2420., 'square', 12., '', 'e242', 242, 'um', 1., 0., 0., 1., 242, 0, 0, 500, 250, 1) + (0, 32., 2420., 'square', 12., '', 'e243', 243, 'um', 1., 0., 0., 1., 243, 0, 0, 500, 250, 1) + (0, 16., 2440., 'square', 12., '', 'e244', 244, 'um', 1., 0., 0., 1., 244, 0, 0, 500, 250, 1) + (0, 48., 2440., 'square', 12., '', 'e245', 245, 'um', 1., 0., 0., 1., 245, 0, 0, 500, 250, 1) + (0, 0., 2460., 'square', 12., '', 'e246', 246, 'um', 1., 0., 0., 1., 246, 0, 0, 500, 250, 1) + (0, 32., 2460., 'square', 12., '', 'e247', 247, 'um', 1., 0., 0., 1., 247, 0, 0, 500, 250, 1) + (0, 16., 2480., 'square', 12., '', 'e248', 248, 'um', 1., 0., 0., 1., 248, 0, 0, 500, 250, 1) + (0, 48., 2480., 'square', 12., '', 'e249', 249, 'um', 1., 0., 0., 1., 249, 0, 0, 500, 250, 1) + (0, 0., 2500., 'square', 12., '', 'e250', 250, 'um', 1., 0., 0., 1., 250, 0, 0, 500, 250, 1) + (0, 32., 2500., 'square', 12., '', 'e251', 251, 'um', 1., 0., 0., 1., 251, 0, 0, 500, 250, 1) + (0, 16., 2520., 'square', 12., '', 'e252', 252, 'um', 1., 0., 0., 1., 252, 0, 0, 500, 250, 1) + (0, 48., 2520., 'square', 12., '', 'e253', 253, 'um', 1., 0., 0., 1., 253, 0, 0, 500, 250, 1) + (0, 0., 2540., 'square', 12., '', 'e254', 254, 'um', 1., 0., 0., 1., 254, 0, 0, 500, 250, 1) + (0, 32., 2540., 'square', 12., '', 'e255', 255, 'um', 1., 0., 0., 1., 255, 0, 0, 500, 250, 1) + (0, 16., 2560., 'square', 12., '', 'e256', 256, 'um', 1., 0., 0., 1., 256, 0, 0, 500, 250, 1) + (0, 48., 2560., 'square', 12., '', 'e257', 257, 'um', 1., 0., 0., 1., 257, 0, 0, 500, 250, 1) + (0, 0., 2580., 'square', 12., '', 'e258', 258, 'um', 1., 0., 0., 1., 258, 0, 0, 500, 250, 1) + (0, 32., 2580., 'square', 12., '', 'e259', 259, 'um', 1., 0., 0., 1., 259, 0, 0, 500, 250, 1) + (0, 16., 2600., 'square', 12., '', 'e260', 260, 'um', 1., 0., 0., 1., 260, 0, 0, 500, 250, 1) + (0, 48., 2600., 'square', 12., '', 'e261', 261, 'um', 1., 0., 0., 1., 261, 0, 0, 500, 250, 1) + (0, 0., 2620., 'square', 12., '', 'e262', 262, 'um', 1., 0., 0., 1., 262, 0, 0, 500, 250, 1) + (0, 32., 2620., 'square', 12., '', 'e263', 263, 'um', 1., 0., 0., 1., 263, 0, 0, 500, 250, 1) + (0, 16., 2640., 'square', 12., '', 'e264', 264, 'um', 1., 0., 0., 1., 264, 0, 0, 500, 250, 1) + (0, 48., 2640., 'square', 12., '', 'e265', 265, 'um', 1., 0., 0., 1., 265, 0, 0, 500, 250, 1) + (0, 0., 2660., 'square', 12., '', 'e266', 266, 'um', 1., 0., 0., 1., 266, 0, 0, 500, 250, 1) + (0, 32., 2660., 'square', 12., '', 'e267', 267, 'um', 1., 0., 0., 1., 267, 0, 0, 500, 250, 1) + (0, 16., 2680., 'square', 12., '', 'e268', 268, 'um', 1., 0., 0., 1., 268, 0, 0, 500, 250, 1) + (0, 48., 2680., 'square', 12., '', 'e269', 269, 'um', 1., 0., 0., 1., 269, 0, 0, 500, 250, 1) + (0, 0., 2700., 'square', 12., '', 'e270', 270, 'um', 1., 0., 0., 1., 270, 0, 0, 500, 250, 1) + (0, 32., 2700., 'square', 12., '', 'e271', 271, 'um', 1., 0., 0., 1., 271, 0, 0, 500, 250, 1) + (0, 16., 2720., 'square', 12., '', 'e272', 272, 'um', 1., 0., 0., 1., 272, 0, 0, 500, 250, 1) + (0, 48., 2720., 'square', 12., '', 'e273', 273, 'um', 1., 0., 0., 1., 273, 0, 0, 500, 250, 1) + (0, 0., 2740., 'square', 12., '', 'e274', 274, 'um', 1., 0., 0., 1., 274, 0, 0, 500, 250, 1) + (0, 32., 2740., 'square', 12., '', 'e275', 275, 'um', 1., 0., 0., 1., 275, 0, 0, 500, 250, 1) + (0, 16., 2760., 'square', 12., '', 'e276', 276, 'um', 1., 0., 0., 1., 276, 0, 0, 500, 250, 1) + (0, 48., 2760., 'square', 12., '', 'e277', 277, 'um', 1., 0., 0., 1., 277, 0, 0, 500, 250, 1) + (0, 0., 2780., 'square', 12., '', 'e278', 278, 'um', 1., 0., 0., 1., 278, 0, 0, 500, 250, 1) + (0, 32., 2780., 'square', 12., '', 'e279', 279, 'um', 1., 0., 0., 1., 279, 0, 0, 500, 250, 1) + (0, 16., 2800., 'square', 12., '', 'e280', 280, 'um', 1., 0., 0., 1., 280, 0, 0, 500, 250, 1) + (0, 48., 2800., 'square', 12., '', 'e281', 281, 'um', 1., 0., 0., 1., 281, 0, 0, 500, 250, 1) + (0, 0., 2820., 'square', 12., '', 'e282', 282, 'um', 1., 0., 0., 1., 282, 0, 0, 500, 250, 1) + (0, 32., 2820., 'square', 12., '', 'e283', 283, 'um', 1., 0., 0., 1., 283, 0, 0, 500, 250, 1) + (0, 16., 2840., 'square', 12., '', 'e284', 284, 'um', 1., 0., 0., 1., 284, 0, 0, 500, 250, 1) + (0, 48., 2840., 'square', 12., '', 'e285', 285, 'um', 1., 0., 0., 1., 285, 0, 0, 500, 250, 1) + (0, 0., 2860., 'square', 12., '', 'e286', 286, 'um', 1., 0., 0., 1., 286, 0, 0, 500, 250, 1) + (0, 32., 2860., 'square', 12., '', 'e287', 287, 'um', 1., 0., 0., 1., 287, 0, 0, 500, 250, 1) + (0, 16., 2880., 'square', 12., '', 'e288', 288, 'um', 1., 0., 0., 1., 288, 0, 0, 500, 250, 1) + (0, 48., 2880., 'square', 12., '', 'e289', 289, 'um', 1., 0., 0., 1., 289, 0, 0, 500, 250, 1) + (0, 0., 2900., 'square', 12., '', 'e290', 290, 'um', 1., 0., 0., 1., 290, 0, 0, 500, 250, 1) + (0, 32., 2900., 'square', 12., '', 'e291', 291, 'um', 1., 0., 0., 1., 291, 0, 0, 500, 250, 1) + (0, 16., 2920., 'square', 12., '', 'e292', 292, 'um', 1., 0., 0., 1., 292, 0, 0, 500, 250, 1) + (0, 48., 2920., 'square', 12., '', 'e293', 293, 'um', 1., 0., 0., 1., 293, 0, 0, 500, 250, 1) + (0, 0., 2940., 'square', 12., '', 'e294', 294, 'um', 1., 0., 0., 1., 294, 0, 0, 500, 250, 1) + (0, 32., 2940., 'square', 12., '', 'e295', 295, 'um', 1., 0., 0., 1., 295, 0, 0, 500, 250, 1) + (0, 16., 2960., 'square', 12., '', 'e296', 296, 'um', 1., 0., 0., 1., 296, 0, 0, 500, 250, 1) + (0, 48., 2960., 'square', 12., '', 'e297', 297, 'um', 1., 0., 0., 1., 297, 0, 0, 500, 250, 1) + (0, 0., 2980., 'square', 12., '', 'e298', 298, 'um', 1., 0., 0., 1., 298, 0, 0, 500, 250, 1) + (0, 32., 2980., 'square', 12., '', 'e299', 299, 'um', 1., 0., 0., 1., 299, 0, 0, 500, 250, 1) + (0, 16., 3000., 'square', 12., '', 'e300', 300, 'um', 1., 0., 0., 1., 300, 0, 0, 500, 250, 1) + (0, 48., 3000., 'square', 12., '', 'e301', 301, 'um', 1., 0., 0., 1., 301, 0, 0, 500, 250, 1) + (0, 0., 3020., 'square', 12., '', 'e302', 302, 'um', 1., 0., 0., 1., 302, 0, 0, 500, 250, 1) + (0, 32., 3020., 'square', 12., '', 'e303', 303, 'um', 1., 0., 0., 1., 303, 0, 0, 500, 250, 1) + (0, 16., 3040., 'square', 12., '', 'e304', 304, 'um', 1., 0., 0., 1., 304, 0, 0, 500, 250, 1) + (0, 48., 3040., 'square', 12., '', 'e305', 305, 'um', 1., 0., 0., 1., 305, 0, 0, 500, 250, 1) + (0, 0., 3060., 'square', 12., '', 'e306', 306, 'um', 1., 0., 0., 1., 306, 0, 0, 500, 250, 1) + (0, 32., 3060., 'square', 12., '', 'e307', 307, 'um', 1., 0., 0., 1., 307, 0, 0, 500, 250, 1) + (0, 16., 3080., 'square', 12., '', 'e308', 308, 'um', 1., 0., 0., 1., 308, 0, 0, 500, 250, 1) + (0, 48., 3080., 'square', 12., '', 'e309', 309, 'um', 1., 0., 0., 1., 309, 0, 0, 500, 250, 1) + (0, 0., 3100., 'square', 12., '', 'e310', 310, 'um', 1., 0., 0., 1., 310, 0, 0, 500, 250, 1) + (0, 32., 3100., 'square', 12., '', 'e311', 311, 'um', 1., 0., 0., 1., 311, 0, 0, 500, 250, 1) + (0, 16., 3120., 'square', 12., '', 'e312', 312, 'um', 1., 0., 0., 1., 312, 0, 0, 500, 250, 1) + (0, 48., 3120., 'square', 12., '', 'e313', 313, 'um', 1., 0., 0., 1., 313, 0, 0, 500, 250, 1) + (0, 0., 3140., 'square', 12., '', 'e314', 314, 'um', 1., 0., 0., 1., 314, 0, 0, 500, 250, 1) + (0, 32., 3140., 'square', 12., '', 'e315', 315, 'um', 1., 0., 0., 1., 315, 0, 0, 500, 250, 1) + (0, 16., 3160., 'square', 12., '', 'e316', 316, 'um', 1., 0., 0., 1., 316, 0, 0, 500, 250, 1) + (0, 48., 3160., 'square', 12., '', 'e317', 317, 'um', 1., 0., 0., 1., 317, 0, 0, 500, 250, 1) + (0, 0., 3180., 'square', 12., '', 'e318', 318, 'um', 1., 0., 0., 1., 318, 0, 0, 500, 250, 1) + (0, 32., 3180., 'square', 12., '', 'e319', 319, 'um', 1., 0., 0., 1., 319, 0, 0, 500, 250, 1) + (0, 16., 3200., 'square', 12., '', 'e320', 320, 'um', 1., 0., 0., 1., 320, 0, 0, 500, 250, 1) + (0, 48., 3200., 'square', 12., '', 'e321', 321, 'um', 1., 0., 0., 1., 321, 0, 0, 500, 250, 1) + (0, 0., 3220., 'square', 12., '', 'e322', 322, 'um', 1., 0., 0., 1., 322, 0, 0, 500, 250, 1) + (0, 32., 3220., 'square', 12., '', 'e323', 323, 'um', 1., 0., 0., 1., 323, 0, 0, 500, 250, 1) + (0, 16., 3240., 'square', 12., '', 'e324', 324, 'um', 1., 0., 0., 1., 324, 0, 0, 500, 250, 1) + (0, 48., 3240., 'square', 12., '', 'e325', 325, 'um', 1., 0., 0., 1., 325, 0, 0, 500, 250, 1) + (0, 0., 3260., 'square', 12., '', 'e326', 326, 'um', 1., 0., 0., 1., 326, 0, 0, 500, 250, 1) + (0, 32., 3260., 'square', 12., '', 'e327', 327, 'um', 1., 0., 0., 1., 327, 0, 0, 500, 250, 1) + (0, 16., 3280., 'square', 12., '', 'e328', 328, 'um', 1., 0., 0., 1., 328, 0, 0, 500, 250, 1) + (0, 48., 3280., 'square', 12., '', 'e329', 329, 'um', 1., 0., 0., 1., 329, 0, 0, 500, 250, 1) + (0, 0., 3300., 'square', 12., '', 'e330', 330, 'um', 1., 0., 0., 1., 330, 0, 0, 500, 250, 1) + (0, 32., 3300., 'square', 12., '', 'e331', 331, 'um', 1., 0., 0., 1., 331, 0, 0, 500, 250, 1) + (0, 16., 3320., 'square', 12., '', 'e332', 332, 'um', 1., 0., 0., 1., 332, 0, 0, 500, 250, 1) + (0, 48., 3320., 'square', 12., '', 'e333', 333, 'um', 1., 0., 0., 1., 333, 0, 0, 500, 250, 1) + (0, 0., 3340., 'square', 12., '', 'e334', 334, 'um', 1., 0., 0., 1., 334, 0, 0, 500, 250, 1) + (0, 32., 3340., 'square', 12., '', 'e335', 335, 'um', 1., 0., 0., 1., 335, 0, 0, 500, 250, 1) + (0, 16., 3360., 'square', 12., '', 'e336', 336, 'um', 1., 0., 0., 1., 336, 0, 0, 500, 250, 1) + (0, 48., 3360., 'square', 12., '', 'e337', 337, 'um', 1., 0., 0., 1., 337, 0, 0, 500, 250, 1) + (0, 0., 3380., 'square', 12., '', 'e338', 338, 'um', 1., 0., 0., 1., 338, 0, 0, 500, 250, 1) + (0, 32., 3380., 'square', 12., '', 'e339', 339, 'um', 1., 0., 0., 1., 339, 0, 0, 500, 250, 1) + (0, 16., 3400., 'square', 12., '', 'e340', 340, 'um', 1., 0., 0., 1., 340, 0, 0, 500, 250, 1) + (0, 48., 3400., 'square', 12., '', 'e341', 341, 'um', 1., 0., 0., 1., 341, 0, 0, 500, 250, 1) + (0, 0., 3420., 'square', 12., '', 'e342', 342, 'um', 1., 0., 0., 1., 342, 0, 0, 500, 250, 1) + (0, 32., 3420., 'square', 12., '', 'e343', 343, 'um', 1., 0., 0., 1., 343, 0, 0, 500, 250, 1) + (0, 16., 3440., 'square', 12., '', 'e344', 344, 'um', 1., 0., 0., 1., 344, 0, 0, 500, 250, 1) + (0, 48., 3440., 'square', 12., '', 'e345', 345, 'um', 1., 0., 0., 1., 345, 0, 0, 500, 250, 1) + (0, 0., 3460., 'square', 12., '', 'e346', 346, 'um', 1., 0., 0., 1., 346, 0, 0, 500, 250, 1) + (0, 32., 3460., 'square', 12., '', 'e347', 347, 'um', 1., 0., 0., 1., 347, 0, 0, 500, 250, 1) + (0, 16., 3480., 'square', 12., '', 'e348', 348, 'um', 1., 0., 0., 1., 348, 0, 0, 500, 250, 1) + (0, 48., 3480., 'square', 12., '', 'e349', 349, 'um', 1., 0., 0., 1., 349, 0, 0, 500, 250, 1) + (0, 0., 3500., 'square', 12., '', 'e350', 350, 'um', 1., 0., 0., 1., 350, 0, 0, 500, 250, 1) + (0, 32., 3500., 'square', 12., '', 'e351', 351, 'um', 1., 0., 0., 1., 351, 0, 0, 500, 250, 1) + (0, 16., 3520., 'square', 12., '', 'e352', 352, 'um', 1., 0., 0., 1., 352, 0, 0, 500, 250, 1) + (0, 48., 3520., 'square', 12., '', 'e353', 353, 'um', 1., 0., 0., 1., 353, 0, 0, 500, 250, 1) + (0, 0., 3540., 'square', 12., '', 'e354', 354, 'um', 1., 0., 0., 1., 354, 0, 0, 500, 250, 1) + (0, 32., 3540., 'square', 12., '', 'e355', 355, 'um', 1., 0., 0., 1., 355, 0, 0, 500, 250, 1) + (0, 16., 3560., 'square', 12., '', 'e356', 356, 'um', 1., 0., 0., 1., 356, 0, 0, 500, 250, 1) + (0, 48., 3560., 'square', 12., '', 'e357', 357, 'um', 1., 0., 0., 1., 357, 0, 0, 500, 250, 1) + (0, 0., 3580., 'square', 12., '', 'e358', 358, 'um', 1., 0., 0., 1., 358, 0, 0, 500, 250, 1) + (0, 32., 3580., 'square', 12., '', 'e359', 359, 'um', 1., 0., 0., 1., 359, 0, 0, 500, 250, 1) + (0, 16., 3600., 'square', 12., '', 'e360', 360, 'um', 1., 0., 0., 1., 360, 0, 0, 500, 250, 1) + (0, 48., 3600., 'square', 12., '', 'e361', 361, 'um', 1., 0., 0., 1., 361, 0, 0, 500, 250, 1) + (0, 0., 3620., 'square', 12., '', 'e362', 362, 'um', 1., 0., 0., 1., 362, 0, 0, 500, 250, 1) + (0, 32., 3620., 'square', 12., '', 'e363', 363, 'um', 1., 0., 0., 1., 363, 0, 0, 500, 250, 1) + (0, 16., 3640., 'square', 12., '', 'e364', 364, 'um', 1., 0., 0., 1., 364, 0, 0, 500, 250, 1) + (0, 48., 3640., 'square', 12., '', 'e365', 365, 'um', 1., 0., 0., 1., 365, 0, 0, 500, 250, 1) + (0, 0., 3660., 'square', 12., '', 'e366', 366, 'um', 1., 0., 0., 1., 366, 0, 0, 500, 250, 1) + (0, 32., 3660., 'square', 12., '', 'e367', 367, 'um', 1., 0., 0., 1., 367, 0, 0, 500, 250, 1) + (0, 16., 3680., 'square', 12., '', 'e368', 368, 'um', 1., 0., 0., 1., 368, 0, 0, 500, 250, 1) + (0, 48., 3680., 'square', 12., '', 'e369', 369, 'um', 1., 0., 0., 1., 369, 0, 0, 500, 250, 1) + (0, 0., 3700., 'square', 12., '', 'e370', 370, 'um', 1., 0., 0., 1., 370, 0, 0, 500, 250, 1) + (0, 32., 3700., 'square', 12., '', 'e371', 371, 'um', 1., 0., 0., 1., 371, 0, 0, 500, 250, 1) + (0, 16., 3720., 'square', 12., '', 'e372', 372, 'um', 1., 0., 0., 1., 372, 0, 0, 500, 250, 1) + (0, 48., 3720., 'square', 12., '', 'e373', 373, 'um', 1., 0., 0., 1., 373, 0, 0, 500, 250, 1) + (0, 0., 3740., 'square', 12., '', 'e374', 374, 'um', 1., 0., 0., 1., 374, 0, 0, 500, 250, 1) + (0, 32., 3740., 'square', 12., '', 'e375', 375, 'um', 1., 0., 0., 1., 375, 0, 0, 500, 250, 1) + (0, 16., 3760., 'square', 12., '', 'e376', 376, 'um', 1., 0., 0., 1., 376, 0, 0, 500, 250, 1) + (0, 48., 3760., 'square', 12., '', 'e377', 377, 'um', 1., 0., 0., 1., 377, 0, 0, 500, 250, 1) + (0, 0., 3780., 'square', 12., '', 'e378', 378, 'um', 1., 0., 0., 1., 378, 0, 0, 500, 250, 1) + (0, 32., 3780., 'square', 12., '', 'e379', 379, 'um', 1., 0., 0., 1., 379, 0, 0, 500, 250, 1) + (0, 16., 3800., 'square', 12., '', 'e380', 380, 'um', 1., 0., 0., 1., 380, 0, 0, 500, 250, 1) + (0, 48., 3800., 'square', 12., '', 'e381', 381, 'um', 1., 0., 0., 1., 381, 0, 0, 500, 250, 1) + (0, 0., 3820., 'square', 12., '', 'e382', 382, 'um', 1., 0., 0., 1., 382, 0, 0, 500, 250, 1) + (0, 32., 3820., 'square', 12., '', 'e383', 383, 'um', 1., 0., 0., 1., 383, 0, 0, 500, 250, 1)]
    location [[ 16. 0.] + [ 48. 0.] + [ 0. 20.] + [ 32. 20.] + [ 16. 40.] + [ 48. 40.] + [ 0. 60.] + [ 32. 60.] + [ 16. 80.] + [ 48. 80.] + [ 0. 100.] + [ 32. 100.] + [ 16. 120.] + [ 48. 120.] + [ 0. 140.] + [ 32. 140.] + [ 16. 160.] + [ 48. 160.] + [ 0. 180.] + [ 32. 180.] + [ 16. 200.] + [ 48. 200.] + [ 0. 220.] + [ 32. 220.] + [ 16. 240.] + [ 48. 240.] + [ 0. 260.] + [ 32. 260.] + [ 16. 280.] + [ 48. 280.] + [ 0. 300.] + [ 32. 300.] + [ 16. 320.] + [ 48. 320.] + [ 0. 340.] + [ 32. 340.] + [ 16. 360.] + [ 48. 360.] + [ 0. 380.] + [ 32. 380.] + [ 16. 400.] + [ 48. 400.] + [ 0. 420.] + [ 32. 420.] + [ 16. 440.] + [ 48. 440.] + [ 0. 460.] + [ 32. 460.] + [ 16. 480.] + [ 48. 480.] + [ 0. 500.] + [ 32. 500.] + [ 16. 520.] + [ 48. 520.] + [ 0. 540.] + [ 32. 540.] + [ 16. 560.] + [ 48. 560.] + [ 0. 580.] + [ 32. 580.] + [ 16. 600.] + [ 48. 600.] + [ 0. 620.] + [ 32. 620.] + [ 16. 640.] + [ 48. 640.] + [ 0. 660.] + [ 32. 660.] + [ 16. 680.] + [ 48. 680.] + [ 0. 700.] + [ 32. 700.] + [ 16. 720.] + [ 48. 720.] + [ 0. 740.] + [ 32. 740.] + [ 16. 760.] + [ 48. 760.] + [ 0. 780.] + [ 32. 780.] + [ 16. 800.] + [ 48. 800.] + [ 0. 820.] + [ 32. 820.] + [ 16. 840.] + [ 48. 840.] + [ 0. 860.] + [ 32. 860.] + [ 16. 880.] + [ 48. 880.] + [ 0. 900.] + [ 32. 900.] + [ 16. 920.] + [ 48. 920.] + [ 0. 940.] + [ 32. 940.] + [ 16. 960.] + [ 48. 960.] + [ 0. 980.] + [ 32. 980.] + [ 16. 1000.] + [ 48. 1000.] + [ 0. 1020.] + [ 32. 1020.] + [ 16. 1040.] + [ 48. 1040.] + [ 0. 1060.] + [ 32. 1060.] + [ 16. 1080.] + [ 48. 1080.] + [ 0. 1100.] + [ 32. 1100.] + [ 16. 1120.] + [ 48. 1120.] + [ 0. 1140.] + [ 32. 1140.] + [ 16. 1160.] + [ 48. 1160.] + [ 0. 1180.] + [ 32. 1180.] + [ 16. 1200.] + [ 48. 1200.] + [ 0. 1220.] + [ 32. 1220.] + [ 16. 1240.] + [ 48. 1240.] + [ 0. 1260.] + [ 32. 1260.] + [ 16. 1280.] + [ 48. 1280.] + [ 0. 1300.] + [ 32. 1300.] + [ 16. 1320.] + [ 48. 1320.] + [ 0. 1340.] + [ 32. 1340.] + [ 16. 1360.] + [ 48. 1360.] + [ 0. 1380.] + [ 32. 1380.] + [ 16. 1400.] + [ 48. 1400.] + [ 0. 1420.] + [ 32. 1420.] + [ 16. 1440.] + [ 48. 1440.] + [ 0. 1460.] + [ 32. 1460.] + [ 16. 1480.] + [ 48. 1480.] + [ 0. 1500.] + [ 32. 1500.] + [ 16. 1520.] + [ 48. 1520.] + [ 0. 1540.] + [ 32. 1540.] + [ 16. 1560.] + [ 48. 1560.] + [ 0. 1580.] + [ 32. 1580.] + [ 16. 1600.] + [ 48. 1600.] + [ 0. 1620.] + [ 32. 1620.] + [ 16. 1640.] + [ 48. 1640.] + [ 0. 1660.] + [ 32. 1660.] + [ 16. 1680.] + [ 48. 1680.] + [ 0. 1700.] + [ 32. 1700.] + [ 16. 1720.] + [ 48. 1720.] + [ 0. 1740.] + [ 32. 1740.] + [ 16. 1760.] + [ 48. 1760.] + [ 0. 1780.] + [ 32. 1780.] + [ 16. 1800.] + [ 48. 1800.] + [ 0. 1820.] + [ 32. 1820.] + [ 16. 1840.] + [ 48. 1840.] + [ 0. 1860.] + [ 32. 1860.] + [ 16. 1880.] + [ 48. 1880.] + [ 0. 1900.] + [ 32. 1900.] + [ 16. 1920.] + [ 48. 1920.] + [ 0. 1940.] + [ 32. 1940.] + [ 16. 1960.] + [ 48. 1960.] + [ 0. 1980.] + [ 32. 1980.] + [ 16. 2000.] + [ 48. 2000.] + [ 0. 2020.] + [ 32. 2020.] + [ 16. 2040.] + [ 48. 2040.] + [ 0. 2060.] + [ 32. 2060.] + [ 16. 2080.] + [ 48. 2080.] + [ 0. 2100.] + [ 32. 2100.] + [ 16. 2120.] + [ 48. 2120.] + [ 0. 2140.] + [ 32. 2140.] + [ 16. 2160.] + [ 48. 2160.] + [ 0. 2180.] + [ 32. 2180.] + [ 16. 2200.] + [ 48. 2200.] + [ 0. 2220.] + [ 32. 2220.] + [ 16. 2240.] + [ 48. 2240.] + [ 0. 2260.] + [ 32. 2260.] + [ 16. 2280.] + [ 48. 2280.] + [ 0. 2300.] + [ 32. 2300.] + [ 16. 2320.] + [ 48. 2320.] + [ 0. 2340.] + [ 32. 2340.] + [ 16. 2360.] + [ 48. 2360.] + [ 0. 2380.] + [ 32. 2380.] + [ 16. 2400.] + [ 48. 2400.] + [ 0. 2420.] + [ 32. 2420.] + [ 16. 2440.] + [ 48. 2440.] + [ 0. 2460.] + [ 32. 2460.] + [ 16. 2480.] + [ 48. 2480.] + [ 0. 2500.] + [ 32. 2500.] + [ 16. 2520.] + [ 48. 2520.] + [ 0. 2540.] + [ 32. 2540.] + [ 16. 2560.] + [ 48. 2560.] + [ 0. 2580.] + [ 32. 2580.] + [ 16. 2600.] + [ 48. 2600.] + [ 0. 2620.] + [ 32. 2620.] + [ 16. 2640.] + [ 48. 2640.] + [ 0. 2660.] + [ 32. 2660.] + [ 16. 2680.] + [ 48. 2680.] + [ 0. 2700.] + [ 32. 2700.] + [ 16. 2720.] + [ 48. 2720.] + [ 0. 2740.] + [ 32. 2740.] + [ 16. 2760.] + [ 48. 2760.] + [ 0. 2780.] + [ 32. 2780.] + [ 16. 2800.] + [ 48. 2800.] + [ 0. 2820.] + [ 32. 2820.] + [ 16. 2840.] + [ 48. 2840.] + [ 0. 2860.] + [ 32. 2860.] + [ 16. 2880.] + [ 48. 2880.] + [ 0. 2900.] + [ 32. 2900.] + [ 16. 2920.] + [ 48. 2920.] + [ 0. 2940.] + [ 32. 2940.] + [ 16. 2960.] + [ 48. 2960.] + [ 0. 2980.] + [ 32. 2980.] + [ 16. 3000.] + [ 48. 3000.] + [ 0. 3020.] + [ 32. 3020.] + [ 16. 3040.] + [ 48. 3040.] + [ 0. 3060.] + [ 32. 3060.] + [ 16. 3080.] + [ 48. 3080.] + [ 0. 3100.] + [ 32. 3100.] + [ 16. 3120.] + [ 48. 3120.] + [ 0. 3140.] + [ 32. 3140.] + [ 16. 3160.] + [ 48. 3160.] + [ 0. 3180.] + [ 32. 3180.] + [ 16. 3200.] + [ 48. 3200.] + [ 0. 3220.] + [ 32. 3220.] + [ 16. 3240.] + [ 48. 3240.] + [ 0. 3260.] + [ 32. 3260.] + [ 16. 3280.] + [ 48. 3280.] + [ 0. 3300.] + [ 32. 3300.] + [ 16. 3320.] + [ 48. 3320.] + [ 0. 3340.] + [ 32. 3340.] + [ 16. 3360.] + [ 48. 3360.] + [ 0. 3380.] + [ 32. 3380.] + [ 16. 3400.] + [ 48. 3400.] + [ 0. 3420.] + [ 32. 3420.] + [ 16. 3440.] + [ 48. 3440.] + [ 0. 3460.] + [ 32. 3460.] + [ 16. 3480.] + [ 48. 3480.] + [ 0. 3500.] + [ 32. 3500.] + [ 16. 3520.] + [ 48. 3520.] + [ 0. 3540.] + [ 32. 3540.] + [ 16. 3560.] + [ 48. 3560.] + [ 0. 3580.] + [ 32. 3580.] + [ 16. 3600.] + [ 48. 3600.] + [ 0. 3620.] + [ 32. 3620.] + [ 16. 3640.] + [ 48. 3640.] + [ 0. 3660.] + [ 32. 3660.] + [ 16. 3680.] + [ 48. 3680.] + [ 0. 3700.] + [ 32. 3700.] + [ 16. 3720.] + [ 48. 3720.] + [ 0. 3740.] + [ 32. 3740.] + [ 16. 3760.] + [ 48. 3760.] + [ 0. 3780.] + [ 32. 3780.] + [ 16. 3800.] + [ 48. 3800.] + [ 0. 3820.] + [ 32. 3820.]]
    group [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    inter_sample_shift [0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385]
+ + + +Note that the ``generate_hybrid_recording`` is warning us that we might +want to account for drift! + +.. code:: ipython3 + + # by passing the `sorting_hybrid` object, we make sure that injected spikes are the same + # this will take a bit more time because it's interpolating the templates to account for drifts + recording_hybrid_with_drift, sorting_hybrid = sgen.generate_hybrid_recording( + recording=recording_preproc, + templates=templates_relocated, + motion=motion_info["motion"], + sorting=sorting_hybrid, + seed=2308, + ) + recording_hybrid_with_drift + + + + +.. raw:: html + +
InjectDriftingTemplatesRecording: 384 channels - 30.0kHz - 1 segments - 58,715,724 samples - 1,957.19s (32.62 minutes) - float64 dtype - 167.99 GiB
Channel IDs
    ['imec0.ap#AP0' 'imec0.ap#AP1' 'imec0.ap#AP2' 'imec0.ap#AP3' + 'imec0.ap#AP4' 'imec0.ap#AP5' 'imec0.ap#AP6' 'imec0.ap#AP7' + 'imec0.ap#AP8' 'imec0.ap#AP9' 'imec0.ap#AP10' 'imec0.ap#AP11' + 'imec0.ap#AP12' 'imec0.ap#AP13' 'imec0.ap#AP14' 'imec0.ap#AP15' + 'imec0.ap#AP16' 'imec0.ap#AP17' 'imec0.ap#AP18' 'imec0.ap#AP19' + 'imec0.ap#AP20' 'imec0.ap#AP21' 'imec0.ap#AP22' 'imec0.ap#AP23' + 'imec0.ap#AP24' 'imec0.ap#AP25' 'imec0.ap#AP26' 'imec0.ap#AP27' + 'imec0.ap#AP28' 'imec0.ap#AP29' 'imec0.ap#AP30' 'imec0.ap#AP31' + 'imec0.ap#AP32' 'imec0.ap#AP33' 'imec0.ap#AP34' 'imec0.ap#AP35' + 'imec0.ap#AP36' 'imec0.ap#AP37' 'imec0.ap#AP38' 'imec0.ap#AP39' + 'imec0.ap#AP40' 'imec0.ap#AP41' 'imec0.ap#AP42' 'imec0.ap#AP43' + 'imec0.ap#AP44' 'imec0.ap#AP45' 'imec0.ap#AP46' 'imec0.ap#AP47' + 'imec0.ap#AP48' 'imec0.ap#AP49' 'imec0.ap#AP50' 'imec0.ap#AP51' + 'imec0.ap#AP52' 'imec0.ap#AP53' 'imec0.ap#AP54' 'imec0.ap#AP55' + 'imec0.ap#AP56' 'imec0.ap#AP57' 'imec0.ap#AP58' 'imec0.ap#AP59' + 'imec0.ap#AP60' 'imec0.ap#AP61' 'imec0.ap#AP62' 'imec0.ap#AP63' + 'imec0.ap#AP64' 'imec0.ap#AP65' 'imec0.ap#AP66' 'imec0.ap#AP67' + 'imec0.ap#AP68' 'imec0.ap#AP69' 'imec0.ap#AP70' 'imec0.ap#AP71' + 'imec0.ap#AP72' 'imec0.ap#AP73' 'imec0.ap#AP74' 'imec0.ap#AP75' + 'imec0.ap#AP76' 'imec0.ap#AP77' 'imec0.ap#AP78' 'imec0.ap#AP79' + 'imec0.ap#AP80' 'imec0.ap#AP81' 'imec0.ap#AP82' 'imec0.ap#AP83' + 'imec0.ap#AP84' 'imec0.ap#AP85' 'imec0.ap#AP86' 'imec0.ap#AP87' + 'imec0.ap#AP88' 'imec0.ap#AP89' 'imec0.ap#AP90' 'imec0.ap#AP91' + 'imec0.ap#AP92' 'imec0.ap#AP93' 'imec0.ap#AP94' 'imec0.ap#AP95' + 'imec0.ap#AP96' 'imec0.ap#AP97' 'imec0.ap#AP98' 'imec0.ap#AP99' + 'imec0.ap#AP100' 'imec0.ap#AP101' 'imec0.ap#AP102' 'imec0.ap#AP103' + 'imec0.ap#AP104' 'imec0.ap#AP105' 'imec0.ap#AP106' 'imec0.ap#AP107' + 'imec0.ap#AP108' 'imec0.ap#AP109' 'imec0.ap#AP110' 'imec0.ap#AP111' + 'imec0.ap#AP112' 'imec0.ap#AP113' 'imec0.ap#AP114' 'imec0.ap#AP115' + 'imec0.ap#AP116' 'imec0.ap#AP117' 'imec0.ap#AP118' 'imec0.ap#AP119' + 'imec0.ap#AP120' 'imec0.ap#AP121' 'imec0.ap#AP122' 'imec0.ap#AP123' + 'imec0.ap#AP124' 'imec0.ap#AP125' 'imec0.ap#AP126' 'imec0.ap#AP127' + 'imec0.ap#AP128' 'imec0.ap#AP129' 'imec0.ap#AP130' 'imec0.ap#AP131' + 'imec0.ap#AP132' 'imec0.ap#AP133' 'imec0.ap#AP134' 'imec0.ap#AP135' + 'imec0.ap#AP136' 'imec0.ap#AP137' 'imec0.ap#AP138' 'imec0.ap#AP139' + 'imec0.ap#AP140' 'imec0.ap#AP141' 'imec0.ap#AP142' 'imec0.ap#AP143' + 'imec0.ap#AP144' 'imec0.ap#AP145' 'imec0.ap#AP146' 'imec0.ap#AP147' + 'imec0.ap#AP148' 'imec0.ap#AP149' 'imec0.ap#AP150' 'imec0.ap#AP151' + 'imec0.ap#AP152' 'imec0.ap#AP153' 'imec0.ap#AP154' 'imec0.ap#AP155' + 'imec0.ap#AP156' 'imec0.ap#AP157' 'imec0.ap#AP158' 'imec0.ap#AP159' + 'imec0.ap#AP160' 'imec0.ap#AP161' 'imec0.ap#AP162' 'imec0.ap#AP163' + 'imec0.ap#AP164' 'imec0.ap#AP165' 'imec0.ap#AP166' 'imec0.ap#AP167' + 'imec0.ap#AP168' 'imec0.ap#AP169' 'imec0.ap#AP170' 'imec0.ap#AP171' + 'imec0.ap#AP172' 'imec0.ap#AP173' 'imec0.ap#AP174' 'imec0.ap#AP175' + 'imec0.ap#AP176' 'imec0.ap#AP177' 'imec0.ap#AP178' 'imec0.ap#AP179' + 'imec0.ap#AP180' 'imec0.ap#AP181' 'imec0.ap#AP182' 'imec0.ap#AP183' + 'imec0.ap#AP184' 'imec0.ap#AP185' 'imec0.ap#AP186' 'imec0.ap#AP187' + 'imec0.ap#AP188' 'imec0.ap#AP189' 'imec0.ap#AP190' 'imec0.ap#AP191' + 'imec0.ap#AP192' 'imec0.ap#AP193' 'imec0.ap#AP194' 'imec0.ap#AP195' + 'imec0.ap#AP196' 'imec0.ap#AP197' 'imec0.ap#AP198' 'imec0.ap#AP199' + 'imec0.ap#AP200' 'imec0.ap#AP201' 'imec0.ap#AP202' 'imec0.ap#AP203' + 'imec0.ap#AP204' 'imec0.ap#AP205' 'imec0.ap#AP206' 'imec0.ap#AP207' + 'imec0.ap#AP208' 'imec0.ap#AP209' 'imec0.ap#AP210' 'imec0.ap#AP211' + 'imec0.ap#AP212' 'imec0.ap#AP213' 'imec0.ap#AP214' 'imec0.ap#AP215' + 'imec0.ap#AP216' 'imec0.ap#AP217' 'imec0.ap#AP218' 'imec0.ap#AP219' + 'imec0.ap#AP220' 'imec0.ap#AP221' 'imec0.ap#AP222' 'imec0.ap#AP223' + 'imec0.ap#AP224' 'imec0.ap#AP225' 'imec0.ap#AP226' 'imec0.ap#AP227' + 'imec0.ap#AP228' 'imec0.ap#AP229' 'imec0.ap#AP230' 'imec0.ap#AP231' + 'imec0.ap#AP232' 'imec0.ap#AP233' 'imec0.ap#AP234' 'imec0.ap#AP235' + 'imec0.ap#AP236' 'imec0.ap#AP237' 'imec0.ap#AP238' 'imec0.ap#AP239' + 'imec0.ap#AP240' 'imec0.ap#AP241' 'imec0.ap#AP242' 'imec0.ap#AP243' + 'imec0.ap#AP244' 'imec0.ap#AP245' 'imec0.ap#AP246' 'imec0.ap#AP247' + 'imec0.ap#AP248' 'imec0.ap#AP249' 'imec0.ap#AP250' 'imec0.ap#AP251' + 'imec0.ap#AP252' 'imec0.ap#AP253' 'imec0.ap#AP254' 'imec0.ap#AP255' + 'imec0.ap#AP256' 'imec0.ap#AP257' 'imec0.ap#AP258' 'imec0.ap#AP259' + 'imec0.ap#AP260' 'imec0.ap#AP261' 'imec0.ap#AP262' 'imec0.ap#AP263' + 'imec0.ap#AP264' 'imec0.ap#AP265' 'imec0.ap#AP266' 'imec0.ap#AP267' + 'imec0.ap#AP268' 'imec0.ap#AP269' 'imec0.ap#AP270' 'imec0.ap#AP271' + 'imec0.ap#AP272' 'imec0.ap#AP273' 'imec0.ap#AP274' 'imec0.ap#AP275' + 'imec0.ap#AP276' 'imec0.ap#AP277' 'imec0.ap#AP278' 'imec0.ap#AP279' + 'imec0.ap#AP280' 'imec0.ap#AP281' 'imec0.ap#AP282' 'imec0.ap#AP283' + 'imec0.ap#AP284' 'imec0.ap#AP285' 'imec0.ap#AP286' 'imec0.ap#AP287' + 'imec0.ap#AP288' 'imec0.ap#AP289' 'imec0.ap#AP290' 'imec0.ap#AP291' + 'imec0.ap#AP292' 'imec0.ap#AP293' 'imec0.ap#AP294' 'imec0.ap#AP295' + 'imec0.ap#AP296' 'imec0.ap#AP297' 'imec0.ap#AP298' 'imec0.ap#AP299' + 'imec0.ap#AP300' 'imec0.ap#AP301' 'imec0.ap#AP302' 'imec0.ap#AP303' + 'imec0.ap#AP304' 'imec0.ap#AP305' 'imec0.ap#AP306' 'imec0.ap#AP307' + 'imec0.ap#AP308' 'imec0.ap#AP309' 'imec0.ap#AP310' 'imec0.ap#AP311' + 'imec0.ap#AP312' 'imec0.ap#AP313' 'imec0.ap#AP314' 'imec0.ap#AP315' + 'imec0.ap#AP316' 'imec0.ap#AP317' 'imec0.ap#AP318' 'imec0.ap#AP319' + 'imec0.ap#AP320' 'imec0.ap#AP321' 'imec0.ap#AP322' 'imec0.ap#AP323' + 'imec0.ap#AP324' 'imec0.ap#AP325' 'imec0.ap#AP326' 'imec0.ap#AP327' + 'imec0.ap#AP328' 'imec0.ap#AP329' 'imec0.ap#AP330' 'imec0.ap#AP331' + 'imec0.ap#AP332' 'imec0.ap#AP333' 'imec0.ap#AP334' 'imec0.ap#AP335' + 'imec0.ap#AP336' 'imec0.ap#AP337' 'imec0.ap#AP338' 'imec0.ap#AP339' + 'imec0.ap#AP340' 'imec0.ap#AP341' 'imec0.ap#AP342' 'imec0.ap#AP343' + 'imec0.ap#AP344' 'imec0.ap#AP345' 'imec0.ap#AP346' 'imec0.ap#AP347' + 'imec0.ap#AP348' 'imec0.ap#AP349' 'imec0.ap#AP350' 'imec0.ap#AP351' + 'imec0.ap#AP352' 'imec0.ap#AP353' 'imec0.ap#AP354' 'imec0.ap#AP355' + 'imec0.ap#AP356' 'imec0.ap#AP357' 'imec0.ap#AP358' 'imec0.ap#AP359' + 'imec0.ap#AP360' 'imec0.ap#AP361' 'imec0.ap#AP362' 'imec0.ap#AP363' + 'imec0.ap#AP364' 'imec0.ap#AP365' 'imec0.ap#AP366' 'imec0.ap#AP367' + 'imec0.ap#AP368' 'imec0.ap#AP369' 'imec0.ap#AP370' 'imec0.ap#AP371' + 'imec0.ap#AP372' 'imec0.ap#AP373' 'imec0.ap#AP374' 'imec0.ap#AP375' + 'imec0.ap#AP376' 'imec0.ap#AP377' 'imec0.ap#AP378' 'imec0.ap#AP379' + 'imec0.ap#AP380' 'imec0.ap#AP381' 'imec0.ap#AP382' 'imec0.ap#AP383']
Annotations
  • is_filtered : True
  • probe_0_planar_contour : [[ -11 9989] + [ -11 -11] + [ 24 -186] + [ 59 -11] + [ 59 9989]]
  • probes_info : [{'manufacturer': 'IMEC', 'model_name': 'Neuropixels 1.0', 'serial_number': '18194814141'}]
Channel Properties
    gain_to_uV [2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375 + 2.34375 2.34375 2.34375 2.34375 2.34375 2.34375]
    offset_to_uV [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    channel_names ['AP0' 'AP1' 'AP2' 'AP3' 'AP4' 'AP5' 'AP6' 'AP7' 'AP8' 'AP9' 'AP10' 'AP11' + 'AP12' 'AP13' 'AP14' 'AP15' 'AP16' 'AP17' 'AP18' 'AP19' 'AP20' 'AP21' + 'AP22' 'AP23' 'AP24' 'AP25' 'AP26' 'AP27' 'AP28' 'AP29' 'AP30' 'AP31' + 'AP32' 'AP33' 'AP34' 'AP35' 'AP36' 'AP37' 'AP38' 'AP39' 'AP40' 'AP41' + 'AP42' 'AP43' 'AP44' 'AP45' 'AP46' 'AP47' 'AP48' 'AP49' 'AP50' 'AP51' + 'AP52' 'AP53' 'AP54' 'AP55' 'AP56' 'AP57' 'AP58' 'AP59' 'AP60' 'AP61' + 'AP62' 'AP63' 'AP64' 'AP65' 'AP66' 'AP67' 'AP68' 'AP69' 'AP70' 'AP71' + 'AP72' 'AP73' 'AP74' 'AP75' 'AP76' 'AP77' 'AP78' 'AP79' 'AP80' 'AP81' + 'AP82' 'AP83' 'AP84' 'AP85' 'AP86' 'AP87' 'AP88' 'AP89' 'AP90' 'AP91' + 'AP92' 'AP93' 'AP94' 'AP95' 'AP96' 'AP97' 'AP98' 'AP99' 'AP100' 'AP101' + 'AP102' 'AP103' 'AP104' 'AP105' 'AP106' 'AP107' 'AP108' 'AP109' 'AP110' + 'AP111' 'AP112' 'AP113' 'AP114' 'AP115' 'AP116' 'AP117' 'AP118' 'AP119' + 'AP120' 'AP121' 'AP122' 'AP123' 'AP124' 'AP125' 'AP126' 'AP127' 'AP128' + 'AP129' 'AP130' 'AP131' 'AP132' 'AP133' 'AP134' 'AP135' 'AP136' 'AP137' + 'AP138' 'AP139' 'AP140' 'AP141' 'AP142' 'AP143' 'AP144' 'AP145' 'AP146' + 'AP147' 'AP148' 'AP149' 'AP150' 'AP151' 'AP152' 'AP153' 'AP154' 'AP155' + 'AP156' 'AP157' 'AP158' 'AP159' 'AP160' 'AP161' 'AP162' 'AP163' 'AP164' + 'AP165' 'AP166' 'AP167' 'AP168' 'AP169' 'AP170' 'AP171' 'AP172' 'AP173' + 'AP174' 'AP175' 'AP176' 'AP177' 'AP178' 'AP179' 'AP180' 'AP181' 'AP182' + 'AP183' 'AP184' 'AP185' 'AP186' 'AP187' 'AP188' 'AP189' 'AP190' 'AP191' + 'AP192' 'AP193' 'AP194' 'AP195' 'AP196' 'AP197' 'AP198' 'AP199' 'AP200' + 'AP201' 'AP202' 'AP203' 'AP204' 'AP205' 'AP206' 'AP207' 'AP208' 'AP209' + 'AP210' 'AP211' 'AP212' 'AP213' 'AP214' 'AP215' 'AP216' 'AP217' 'AP218' + 'AP219' 'AP220' 'AP221' 'AP222' 'AP223' 'AP224' 'AP225' 'AP226' 'AP227' + 'AP228' 'AP229' 'AP230' 'AP231' 'AP232' 'AP233' 'AP234' 'AP235' 'AP236' + 'AP237' 'AP238' 'AP239' 'AP240' 'AP241' 'AP242' 'AP243' 'AP244' 'AP245' + 'AP246' 'AP247' 'AP248' 'AP249' 'AP250' 'AP251' 'AP252' 'AP253' 'AP254' + 'AP255' 'AP256' 'AP257' 'AP258' 'AP259' 'AP260' 'AP261' 'AP262' 'AP263' + 'AP264' 'AP265' 'AP266' 'AP267' 'AP268' 'AP269' 'AP270' 'AP271' 'AP272' + 'AP273' 'AP274' 'AP275' 'AP276' 'AP277' 'AP278' 'AP279' 'AP280' 'AP281' + 'AP282' 'AP283' 'AP284' 'AP285' 'AP286' 'AP287' 'AP288' 'AP289' 'AP290' + 'AP291' 'AP292' 'AP293' 'AP294' 'AP295' 'AP296' 'AP297' 'AP298' 'AP299' + 'AP300' 'AP301' 'AP302' 'AP303' 'AP304' 'AP305' 'AP306' 'AP307' 'AP308' + 'AP309' 'AP310' 'AP311' 'AP312' 'AP313' 'AP314' 'AP315' 'AP316' 'AP317' + 'AP318' 'AP319' 'AP320' 'AP321' 'AP322' 'AP323' 'AP324' 'AP325' 'AP326' + 'AP327' 'AP328' 'AP329' 'AP330' 'AP331' 'AP332' 'AP333' 'AP334' 'AP335' + 'AP336' 'AP337' 'AP338' 'AP339' 'AP340' 'AP341' 'AP342' 'AP343' 'AP344' + 'AP345' 'AP346' 'AP347' 'AP348' 'AP349' 'AP350' 'AP351' 'AP352' 'AP353' + 'AP354' 'AP355' 'AP356' 'AP357' 'AP358' 'AP359' 'AP360' 'AP361' 'AP362' + 'AP363' 'AP364' 'AP365' 'AP366' 'AP367' 'AP368' 'AP369' 'AP370' 'AP371' + 'AP372' 'AP373' 'AP374' 'AP375' 'AP376' 'AP377' 'AP378' 'AP379' 'AP380' + 'AP381' 'AP382' 'AP383']
    contact_vector [(0, 16., 0., 'circle', 1., '', '', 0, 'um', 1., 0., 0., 1.) + (0, 48., 0., 'circle', 1., '', '', 1, 'um', 1., 0., 0., 1.) + (0, 0., 20., 'circle', 1., '', '', 2, 'um', 1., 0., 0., 1.) + (0, 32., 20., 'circle', 1., '', '', 3, 'um', 1., 0., 0., 1.) + (0, 16., 40., 'circle', 1., '', '', 4, 'um', 1., 0., 0., 1.) + (0, 48., 40., 'circle', 1., '', '', 5, 'um', 1., 0., 0., 1.) + (0, 0., 60., 'circle', 1., '', '', 6, 'um', 1., 0., 0., 1.) + (0, 32., 60., 'circle', 1., '', '', 7, 'um', 1., 0., 0., 1.) + (0, 16., 80., 'circle', 1., '', '', 8, 'um', 1., 0., 0., 1.) + (0, 48., 80., 'circle', 1., '', '', 9, 'um', 1., 0., 0., 1.) + (0, 0., 100., 'circle', 1., '', '', 10, 'um', 1., 0., 0., 1.) + (0, 32., 100., 'circle', 1., '', '', 11, 'um', 1., 0., 0., 1.) + (0, 16., 120., 'circle', 1., '', '', 12, 'um', 1., 0., 0., 1.) + (0, 48., 120., 'circle', 1., '', '', 13, 'um', 1., 0., 0., 1.) + (0, 0., 140., 'circle', 1., '', '', 14, 'um', 1., 0., 0., 1.) + (0, 32., 140., 'circle', 1., '', '', 15, 'um', 1., 0., 0., 1.) + (0, 16., 160., 'circle', 1., '', '', 16, 'um', 1., 0., 0., 1.) + (0, 48., 160., 'circle', 1., '', '', 17, 'um', 1., 0., 0., 1.) + (0, 0., 180., 'circle', 1., '', '', 18, 'um', 1., 0., 0., 1.) + (0, 32., 180., 'circle', 1., '', '', 19, 'um', 1., 0., 0., 1.) + (0, 16., 200., 'circle', 1., '', '', 20, 'um', 1., 0., 0., 1.) + (0, 48., 200., 'circle', 1., '', '', 21, 'um', 1., 0., 0., 1.) + (0, 0., 220., 'circle', 1., '', '', 22, 'um', 1., 0., 0., 1.) + (0, 32., 220., 'circle', 1., '', '', 23, 'um', 1., 0., 0., 1.) + (0, 16., 240., 'circle', 1., '', '', 24, 'um', 1., 0., 0., 1.) + (0, 48., 240., 'circle', 1., '', '', 25, 'um', 1., 0., 0., 1.) + (0, 0., 260., 'circle', 1., '', '', 26, 'um', 1., 0., 0., 1.) + (0, 32., 260., 'circle', 1., '', '', 27, 'um', 1., 0., 0., 1.) + (0, 16., 280., 'circle', 1., '', '', 28, 'um', 1., 0., 0., 1.) + (0, 48., 280., 'circle', 1., '', '', 29, 'um', 1., 0., 0., 1.) + (0, 0., 300., 'circle', 1., '', '', 30, 'um', 1., 0., 0., 1.) + (0, 32., 300., 'circle', 1., '', '', 31, 'um', 1., 0., 0., 1.) + (0, 16., 320., 'circle', 1., '', '', 32, 'um', 1., 0., 0., 1.) + (0, 48., 320., 'circle', 1., '', '', 33, 'um', 1., 0., 0., 1.) + (0, 0., 340., 'circle', 1., '', '', 34, 'um', 1., 0., 0., 1.) + (0, 32., 340., 'circle', 1., '', '', 35, 'um', 1., 0., 0., 1.) + (0, 16., 360., 'circle', 1., '', '', 36, 'um', 1., 0., 0., 1.) + (0, 48., 360., 'circle', 1., '', '', 37, 'um', 1., 0., 0., 1.) + (0, 0., 380., 'circle', 1., '', '', 38, 'um', 1., 0., 0., 1.) + (0, 32., 380., 'circle', 1., '', '', 39, 'um', 1., 0., 0., 1.) + (0, 16., 400., 'circle', 1., '', '', 40, 'um', 1., 0., 0., 1.) + (0, 48., 400., 'circle', 1., '', '', 41, 'um', 1., 0., 0., 1.) + (0, 0., 420., 'circle', 1., '', '', 42, 'um', 1., 0., 0., 1.) + (0, 32., 420., 'circle', 1., '', '', 43, 'um', 1., 0., 0., 1.) + (0, 16., 440., 'circle', 1., '', '', 44, 'um', 1., 0., 0., 1.) + (0, 48., 440., 'circle', 1., '', '', 45, 'um', 1., 0., 0., 1.) + (0, 0., 460., 'circle', 1., '', '', 46, 'um', 1., 0., 0., 1.) + (0, 32., 460., 'circle', 1., '', '', 47, 'um', 1., 0., 0., 1.) + (0, 16., 480., 'circle', 1., '', '', 48, 'um', 1., 0., 0., 1.) + (0, 48., 480., 'circle', 1., '', '', 49, 'um', 1., 0., 0., 1.) + (0, 0., 500., 'circle', 1., '', '', 50, 'um', 1., 0., 0., 1.) + (0, 32., 500., 'circle', 1., '', '', 51, 'um', 1., 0., 0., 1.) + (0, 16., 520., 'circle', 1., '', '', 52, 'um', 1., 0., 0., 1.) + (0, 48., 520., 'circle', 1., '', '', 53, 'um', 1., 0., 0., 1.) + (0, 0., 540., 'circle', 1., '', '', 54, 'um', 1., 0., 0., 1.) + (0, 32., 540., 'circle', 1., '', '', 55, 'um', 1., 0., 0., 1.) + (0, 16., 560., 'circle', 1., '', '', 56, 'um', 1., 0., 0., 1.) + (0, 48., 560., 'circle', 1., '', '', 57, 'um', 1., 0., 0., 1.) + (0, 0., 580., 'circle', 1., '', '', 58, 'um', 1., 0., 0., 1.) + (0, 32., 580., 'circle', 1., '', '', 59, 'um', 1., 0., 0., 1.) + (0, 16., 600., 'circle', 1., '', '', 60, 'um', 1., 0., 0., 1.) + (0, 48., 600., 'circle', 1., '', '', 61, 'um', 1., 0., 0., 1.) + (0, 0., 620., 'circle', 1., '', '', 62, 'um', 1., 0., 0., 1.) + (0, 32., 620., 'circle', 1., '', '', 63, 'um', 1., 0., 0., 1.) + (0, 16., 640., 'circle', 1., '', '', 64, 'um', 1., 0., 0., 1.) + (0, 48., 640., 'circle', 1., '', '', 65, 'um', 1., 0., 0., 1.) + (0, 0., 660., 'circle', 1., '', '', 66, 'um', 1., 0., 0., 1.) + (0, 32., 660., 'circle', 1., '', '', 67, 'um', 1., 0., 0., 1.) + (0, 16., 680., 'circle', 1., '', '', 68, 'um', 1., 0., 0., 1.) + (0, 48., 680., 'circle', 1., '', '', 69, 'um', 1., 0., 0., 1.) + (0, 0., 700., 'circle', 1., '', '', 70, 'um', 1., 0., 0., 1.) + (0, 32., 700., 'circle', 1., '', '', 71, 'um', 1., 0., 0., 1.) + (0, 16., 720., 'circle', 1., '', '', 72, 'um', 1., 0., 0., 1.) + (0, 48., 720., 'circle', 1., '', '', 73, 'um', 1., 0., 0., 1.) + (0, 0., 740., 'circle', 1., '', '', 74, 'um', 1., 0., 0., 1.) + (0, 32., 740., 'circle', 1., '', '', 75, 'um', 1., 0., 0., 1.) + (0, 16., 760., 'circle', 1., '', '', 76, 'um', 1., 0., 0., 1.) + (0, 48., 760., 'circle', 1., '', '', 77, 'um', 1., 0., 0., 1.) + (0, 0., 780., 'circle', 1., '', '', 78, 'um', 1., 0., 0., 1.) + (0, 32., 780., 'circle', 1., '', '', 79, 'um', 1., 0., 0., 1.) + (0, 16., 800., 'circle', 1., '', '', 80, 'um', 1., 0., 0., 1.) + (0, 48., 800., 'circle', 1., '', '', 81, 'um', 1., 0., 0., 1.) + (0, 0., 820., 'circle', 1., '', '', 82, 'um', 1., 0., 0., 1.) + (0, 32., 820., 'circle', 1., '', '', 83, 'um', 1., 0., 0., 1.) + (0, 16., 840., 'circle', 1., '', '', 84, 'um', 1., 0., 0., 1.) + (0, 48., 840., 'circle', 1., '', '', 85, 'um', 1., 0., 0., 1.) + (0, 0., 860., 'circle', 1., '', '', 86, 'um', 1., 0., 0., 1.) + (0, 32., 860., 'circle', 1., '', '', 87, 'um', 1., 0., 0., 1.) + (0, 16., 880., 'circle', 1., '', '', 88, 'um', 1., 0., 0., 1.) + (0, 48., 880., 'circle', 1., '', '', 89, 'um', 1., 0., 0., 1.) + (0, 0., 900., 'circle', 1., '', '', 90, 'um', 1., 0., 0., 1.) + (0, 32., 900., 'circle', 1., '', '', 91, 'um', 1., 0., 0., 1.) + (0, 16., 920., 'circle', 1., '', '', 92, 'um', 1., 0., 0., 1.) + (0, 48., 920., 'circle', 1., '', '', 93, 'um', 1., 0., 0., 1.) + (0, 0., 940., 'circle', 1., '', '', 94, 'um', 1., 0., 0., 1.) + (0, 32., 940., 'circle', 1., '', '', 95, 'um', 1., 0., 0., 1.) + (0, 16., 960., 'circle', 1., '', '', 96, 'um', 1., 0., 0., 1.) + (0, 48., 960., 'circle', 1., '', '', 97, 'um', 1., 0., 0., 1.) + (0, 0., 980., 'circle', 1., '', '', 98, 'um', 1., 0., 0., 1.) + (0, 32., 980., 'circle', 1., '', '', 99, 'um', 1., 0., 0., 1.) + (0, 16., 1000., 'circle', 1., '', '', 100, 'um', 1., 0., 0., 1.) + (0, 48., 1000., 'circle', 1., '', '', 101, 'um', 1., 0., 0., 1.) + (0, 0., 1020., 'circle', 1., '', '', 102, 'um', 1., 0., 0., 1.) + (0, 32., 1020., 'circle', 1., '', '', 103, 'um', 1., 0., 0., 1.) + (0, 16., 1040., 'circle', 1., '', '', 104, 'um', 1., 0., 0., 1.) + (0, 48., 1040., 'circle', 1., '', '', 105, 'um', 1., 0., 0., 1.) + (0, 0., 1060., 'circle', 1., '', '', 106, 'um', 1., 0., 0., 1.) + (0, 32., 1060., 'circle', 1., '', '', 107, 'um', 1., 0., 0., 1.) + (0, 16., 1080., 'circle', 1., '', '', 108, 'um', 1., 0., 0., 1.) + (0, 48., 1080., 'circle', 1., '', '', 109, 'um', 1., 0., 0., 1.) + (0, 0., 1100., 'circle', 1., '', '', 110, 'um', 1., 0., 0., 1.) + (0, 32., 1100., 'circle', 1., '', '', 111, 'um', 1., 0., 0., 1.) + (0, 16., 1120., 'circle', 1., '', '', 112, 'um', 1., 0., 0., 1.) + (0, 48., 1120., 'circle', 1., '', '', 113, 'um', 1., 0., 0., 1.) + (0, 0., 1140., 'circle', 1., '', '', 114, 'um', 1., 0., 0., 1.) + (0, 32., 1140., 'circle', 1., '', '', 115, 'um', 1., 0., 0., 1.) + (0, 16., 1160., 'circle', 1., '', '', 116, 'um', 1., 0., 0., 1.) + (0, 48., 1160., 'circle', 1., '', '', 117, 'um', 1., 0., 0., 1.) + (0, 0., 1180., 'circle', 1., '', '', 118, 'um', 1., 0., 0., 1.) + (0, 32., 1180., 'circle', 1., '', '', 119, 'um', 1., 0., 0., 1.) + (0, 16., 1200., 'circle', 1., '', '', 120, 'um', 1., 0., 0., 1.) + (0, 48., 1200., 'circle', 1., '', '', 121, 'um', 1., 0., 0., 1.) + (0, 0., 1220., 'circle', 1., '', '', 122, 'um', 1., 0., 0., 1.) + (0, 32., 1220., 'circle', 1., '', '', 123, 'um', 1., 0., 0., 1.) + (0, 16., 1240., 'circle', 1., '', '', 124, 'um', 1., 0., 0., 1.) + (0, 48., 1240., 'circle', 1., '', '', 125, 'um', 1., 0., 0., 1.) + (0, 0., 1260., 'circle', 1., '', '', 126, 'um', 1., 0., 0., 1.) + (0, 32., 1260., 'circle', 1., '', '', 127, 'um', 1., 0., 0., 1.) + (0, 16., 1280., 'circle', 1., '', '', 128, 'um', 1., 0., 0., 1.) + (0, 48., 1280., 'circle', 1., '', '', 129, 'um', 1., 0., 0., 1.) + (0, 0., 1300., 'circle', 1., '', '', 130, 'um', 1., 0., 0., 1.) + (0, 32., 1300., 'circle', 1., '', '', 131, 'um', 1., 0., 0., 1.) + (0, 16., 1320., 'circle', 1., '', '', 132, 'um', 1., 0., 0., 1.) + (0, 48., 1320., 'circle', 1., '', '', 133, 'um', 1., 0., 0., 1.) + (0, 0., 1340., 'circle', 1., '', '', 134, 'um', 1., 0., 0., 1.) + (0, 32., 1340., 'circle', 1., '', '', 135, 'um', 1., 0., 0., 1.) + (0, 16., 1360., 'circle', 1., '', '', 136, 'um', 1., 0., 0., 1.) + (0, 48., 1360., 'circle', 1., '', '', 137, 'um', 1., 0., 0., 1.) + (0, 0., 1380., 'circle', 1., '', '', 138, 'um', 1., 0., 0., 1.) + (0, 32., 1380., 'circle', 1., '', '', 139, 'um', 1., 0., 0., 1.) + (0, 16., 1400., 'circle', 1., '', '', 140, 'um', 1., 0., 0., 1.) + (0, 48., 1400., 'circle', 1., '', '', 141, 'um', 1., 0., 0., 1.) + (0, 0., 1420., 'circle', 1., '', '', 142, 'um', 1., 0., 0., 1.) + (0, 32., 1420., 'circle', 1., '', '', 143, 'um', 1., 0., 0., 1.) + (0, 16., 1440., 'circle', 1., '', '', 144, 'um', 1., 0., 0., 1.) + (0, 48., 1440., 'circle', 1., '', '', 145, 'um', 1., 0., 0., 1.) + (0, 0., 1460., 'circle', 1., '', '', 146, 'um', 1., 0., 0., 1.) + (0, 32., 1460., 'circle', 1., '', '', 147, 'um', 1., 0., 0., 1.) + (0, 16., 1480., 'circle', 1., '', '', 148, 'um', 1., 0., 0., 1.) + (0, 48., 1480., 'circle', 1., '', '', 149, 'um', 1., 0., 0., 1.) + (0, 0., 1500., 'circle', 1., '', '', 150, 'um', 1., 0., 0., 1.) + (0, 32., 1500., 'circle', 1., '', '', 151, 'um', 1., 0., 0., 1.) + (0, 16., 1520., 'circle', 1., '', '', 152, 'um', 1., 0., 0., 1.) + (0, 48., 1520., 'circle', 1., '', '', 153, 'um', 1., 0., 0., 1.) + (0, 0., 1540., 'circle', 1., '', '', 154, 'um', 1., 0., 0., 1.) + (0, 32., 1540., 'circle', 1., '', '', 155, 'um', 1., 0., 0., 1.) + (0, 16., 1560., 'circle', 1., '', '', 156, 'um', 1., 0., 0., 1.) + (0, 48., 1560., 'circle', 1., '', '', 157, 'um', 1., 0., 0., 1.) + (0, 0., 1580., 'circle', 1., '', '', 158, 'um', 1., 0., 0., 1.) + (0, 32., 1580., 'circle', 1., '', '', 159, 'um', 1., 0., 0., 1.) + (0, 16., 1600., 'circle', 1., '', '', 160, 'um', 1., 0., 0., 1.) + (0, 48., 1600., 'circle', 1., '', '', 161, 'um', 1., 0., 0., 1.) + (0, 0., 1620., 'circle', 1., '', '', 162, 'um', 1., 0., 0., 1.) + (0, 32., 1620., 'circle', 1., '', '', 163, 'um', 1., 0., 0., 1.) + (0, 16., 1640., 'circle', 1., '', '', 164, 'um', 1., 0., 0., 1.) + (0, 48., 1640., 'circle', 1., '', '', 165, 'um', 1., 0., 0., 1.) + (0, 0., 1660., 'circle', 1., '', '', 166, 'um', 1., 0., 0., 1.) + (0, 32., 1660., 'circle', 1., '', '', 167, 'um', 1., 0., 0., 1.) + (0, 16., 1680., 'circle', 1., '', '', 168, 'um', 1., 0., 0., 1.) + (0, 48., 1680., 'circle', 1., '', '', 169, 'um', 1., 0., 0., 1.) + (0, 0., 1700., 'circle', 1., '', '', 170, 'um', 1., 0., 0., 1.) + (0, 32., 1700., 'circle', 1., '', '', 171, 'um', 1., 0., 0., 1.) + (0, 16., 1720., 'circle', 1., '', '', 172, 'um', 1., 0., 0., 1.) + (0, 48., 1720., 'circle', 1., '', '', 173, 'um', 1., 0., 0., 1.) + (0, 0., 1740., 'circle', 1., '', '', 174, 'um', 1., 0., 0., 1.) + (0, 32., 1740., 'circle', 1., '', '', 175, 'um', 1., 0., 0., 1.) + (0, 16., 1760., 'circle', 1., '', '', 176, 'um', 1., 0., 0., 1.) + (0, 48., 1760., 'circle', 1., '', '', 177, 'um', 1., 0., 0., 1.) + (0, 0., 1780., 'circle', 1., '', '', 178, 'um', 1., 0., 0., 1.) + (0, 32., 1780., 'circle', 1., '', '', 179, 'um', 1., 0., 0., 1.) + (0, 16., 1800., 'circle', 1., '', '', 180, 'um', 1., 0., 0., 1.) + (0, 48., 1800., 'circle', 1., '', '', 181, 'um', 1., 0., 0., 1.) + (0, 0., 1820., 'circle', 1., '', '', 182, 'um', 1., 0., 0., 1.) + (0, 32., 1820., 'circle', 1., '', '', 183, 'um', 1., 0., 0., 1.) + (0, 16., 1840., 'circle', 1., '', '', 184, 'um', 1., 0., 0., 1.) + (0, 48., 1840., 'circle', 1., '', '', 185, 'um', 1., 0., 0., 1.) + (0, 0., 1860., 'circle', 1., '', '', 186, 'um', 1., 0., 0., 1.) + (0, 32., 1860., 'circle', 1., '', '', 187, 'um', 1., 0., 0., 1.) + (0, 16., 1880., 'circle', 1., '', '', 188, 'um', 1., 0., 0., 1.) + (0, 48., 1880., 'circle', 1., '', '', 189, 'um', 1., 0., 0., 1.) + (0, 0., 1900., 'circle', 1., '', '', 190, 'um', 1., 0., 0., 1.) + (0, 32., 1900., 'circle', 1., '', '', 191, 'um', 1., 0., 0., 1.) + (0, 16., 1920., 'circle', 1., '', '', 192, 'um', 1., 0., 0., 1.) + (0, 48., 1920., 'circle', 1., '', '', 193, 'um', 1., 0., 0., 1.) + (0, 0., 1940., 'circle', 1., '', '', 194, 'um', 1., 0., 0., 1.) + (0, 32., 1940., 'circle', 1., '', '', 195, 'um', 1., 0., 0., 1.) + (0, 16., 1960., 'circle', 1., '', '', 196, 'um', 1., 0., 0., 1.) + (0, 48., 1960., 'circle', 1., '', '', 197, 'um', 1., 0., 0., 1.) + (0, 0., 1980., 'circle', 1., '', '', 198, 'um', 1., 0., 0., 1.) + (0, 32., 1980., 'circle', 1., '', '', 199, 'um', 1., 0., 0., 1.) + (0, 16., 2000., 'circle', 1., '', '', 200, 'um', 1., 0., 0., 1.) + (0, 48., 2000., 'circle', 1., '', '', 201, 'um', 1., 0., 0., 1.) + (0, 0., 2020., 'circle', 1., '', '', 202, 'um', 1., 0., 0., 1.) + (0, 32., 2020., 'circle', 1., '', '', 203, 'um', 1., 0., 0., 1.) + (0, 16., 2040., 'circle', 1., '', '', 204, 'um', 1., 0., 0., 1.) + (0, 48., 2040., 'circle', 1., '', '', 205, 'um', 1., 0., 0., 1.) + (0, 0., 2060., 'circle', 1., '', '', 206, 'um', 1., 0., 0., 1.) + (0, 32., 2060., 'circle', 1., '', '', 207, 'um', 1., 0., 0., 1.) + (0, 16., 2080., 'circle', 1., '', '', 208, 'um', 1., 0., 0., 1.) + (0, 48., 2080., 'circle', 1., '', '', 209, 'um', 1., 0., 0., 1.) + (0, 0., 2100., 'circle', 1., '', '', 210, 'um', 1., 0., 0., 1.) + (0, 32., 2100., 'circle', 1., '', '', 211, 'um', 1., 0., 0., 1.) + (0, 16., 2120., 'circle', 1., '', '', 212, 'um', 1., 0., 0., 1.) + (0, 48., 2120., 'circle', 1., '', '', 213, 'um', 1., 0., 0., 1.) + (0, 0., 2140., 'circle', 1., '', '', 214, 'um', 1., 0., 0., 1.) + (0, 32., 2140., 'circle', 1., '', '', 215, 'um', 1., 0., 0., 1.) + (0, 16., 2160., 'circle', 1., '', '', 216, 'um', 1., 0., 0., 1.) + (0, 48., 2160., 'circle', 1., '', '', 217, 'um', 1., 0., 0., 1.) + (0, 0., 2180., 'circle', 1., '', '', 218, 'um', 1., 0., 0., 1.) + (0, 32., 2180., 'circle', 1., '', '', 219, 'um', 1., 0., 0., 1.) + (0, 16., 2200., 'circle', 1., '', '', 220, 'um', 1., 0., 0., 1.) + (0, 48., 2200., 'circle', 1., '', '', 221, 'um', 1., 0., 0., 1.) + (0, 0., 2220., 'circle', 1., '', '', 222, 'um', 1., 0., 0., 1.) + (0, 32., 2220., 'circle', 1., '', '', 223, 'um', 1., 0., 0., 1.) + (0, 16., 2240., 'circle', 1., '', '', 224, 'um', 1., 0., 0., 1.) + (0, 48., 2240., 'circle', 1., '', '', 225, 'um', 1., 0., 0., 1.) + (0, 0., 2260., 'circle', 1., '', '', 226, 'um', 1., 0., 0., 1.) + (0, 32., 2260., 'circle', 1., '', '', 227, 'um', 1., 0., 0., 1.) + (0, 16., 2280., 'circle', 1., '', '', 228, 'um', 1., 0., 0., 1.) + (0, 48., 2280., 'circle', 1., '', '', 229, 'um', 1., 0., 0., 1.) + (0, 0., 2300., 'circle', 1., '', '', 230, 'um', 1., 0., 0., 1.) + (0, 32., 2300., 'circle', 1., '', '', 231, 'um', 1., 0., 0., 1.) + (0, 16., 2320., 'circle', 1., '', '', 232, 'um', 1., 0., 0., 1.) + (0, 48., 2320., 'circle', 1., '', '', 233, 'um', 1., 0., 0., 1.) + (0, 0., 2340., 'circle', 1., '', '', 234, 'um', 1., 0., 0., 1.) + (0, 32., 2340., 'circle', 1., '', '', 235, 'um', 1., 0., 0., 1.) + (0, 16., 2360., 'circle', 1., '', '', 236, 'um', 1., 0., 0., 1.) + (0, 48., 2360., 'circle', 1., '', '', 237, 'um', 1., 0., 0., 1.) + (0, 0., 2380., 'circle', 1., '', '', 238, 'um', 1., 0., 0., 1.) + (0, 32., 2380., 'circle', 1., '', '', 239, 'um', 1., 0., 0., 1.) + (0, 16., 2400., 'circle', 1., '', '', 240, 'um', 1., 0., 0., 1.) + (0, 48., 2400., 'circle', 1., '', '', 241, 'um', 1., 0., 0., 1.) + (0, 0., 2420., 'circle', 1., '', '', 242, 'um', 1., 0., 0., 1.) + (0, 32., 2420., 'circle', 1., '', '', 243, 'um', 1., 0., 0., 1.) + (0, 16., 2440., 'circle', 1., '', '', 244, 'um', 1., 0., 0., 1.) + (0, 48., 2440., 'circle', 1., '', '', 245, 'um', 1., 0., 0., 1.) + (0, 0., 2460., 'circle', 1., '', '', 246, 'um', 1., 0., 0., 1.) + (0, 32., 2460., 'circle', 1., '', '', 247, 'um', 1., 0., 0., 1.) + (0, 16., 2480., 'circle', 1., '', '', 248, 'um', 1., 0., 0., 1.) + (0, 48., 2480., 'circle', 1., '', '', 249, 'um', 1., 0., 0., 1.) + (0, 0., 2500., 'circle', 1., '', '', 250, 'um', 1., 0., 0., 1.) + (0, 32., 2500., 'circle', 1., '', '', 251, 'um', 1., 0., 0., 1.) + (0, 16., 2520., 'circle', 1., '', '', 252, 'um', 1., 0., 0., 1.) + (0, 48., 2520., 'circle', 1., '', '', 253, 'um', 1., 0., 0., 1.) + (0, 0., 2540., 'circle', 1., '', '', 254, 'um', 1., 0., 0., 1.) + (0, 32., 2540., 'circle', 1., '', '', 255, 'um', 1., 0., 0., 1.) + (0, 16., 2560., 'circle', 1., '', '', 256, 'um', 1., 0., 0., 1.) + (0, 48., 2560., 'circle', 1., '', '', 257, 'um', 1., 0., 0., 1.) + (0, 0., 2580., 'circle', 1., '', '', 258, 'um', 1., 0., 0., 1.) + (0, 32., 2580., 'circle', 1., '', '', 259, 'um', 1., 0., 0., 1.) + (0, 16., 2600., 'circle', 1., '', '', 260, 'um', 1., 0., 0., 1.) + (0, 48., 2600., 'circle', 1., '', '', 261, 'um', 1., 0., 0., 1.) + (0, 0., 2620., 'circle', 1., '', '', 262, 'um', 1., 0., 0., 1.) + (0, 32., 2620., 'circle', 1., '', '', 263, 'um', 1., 0., 0., 1.) + (0, 16., 2640., 'circle', 1., '', '', 264, 'um', 1., 0., 0., 1.) + (0, 48., 2640., 'circle', 1., '', '', 265, 'um', 1., 0., 0., 1.) + (0, 0., 2660., 'circle', 1., '', '', 266, 'um', 1., 0., 0., 1.) + (0, 32., 2660., 'circle', 1., '', '', 267, 'um', 1., 0., 0., 1.) + (0, 16., 2680., 'circle', 1., '', '', 268, 'um', 1., 0., 0., 1.) + (0, 48., 2680., 'circle', 1., '', '', 269, 'um', 1., 0., 0., 1.) + (0, 0., 2700., 'circle', 1., '', '', 270, 'um', 1., 0., 0., 1.) + (0, 32., 2700., 'circle', 1., '', '', 271, 'um', 1., 0., 0., 1.) + (0, 16., 2720., 'circle', 1., '', '', 272, 'um', 1., 0., 0., 1.) + (0, 48., 2720., 'circle', 1., '', '', 273, 'um', 1., 0., 0., 1.) + (0, 0., 2740., 'circle', 1., '', '', 274, 'um', 1., 0., 0., 1.) + (0, 32., 2740., 'circle', 1., '', '', 275, 'um', 1., 0., 0., 1.) + (0, 16., 2760., 'circle', 1., '', '', 276, 'um', 1., 0., 0., 1.) + (0, 48., 2760., 'circle', 1., '', '', 277, 'um', 1., 0., 0., 1.) + (0, 0., 2780., 'circle', 1., '', '', 278, 'um', 1., 0., 0., 1.) + (0, 32., 2780., 'circle', 1., '', '', 279, 'um', 1., 0., 0., 1.) + (0, 16., 2800., 'circle', 1., '', '', 280, 'um', 1., 0., 0., 1.) + (0, 48., 2800., 'circle', 1., '', '', 281, 'um', 1., 0., 0., 1.) + (0, 0., 2820., 'circle', 1., '', '', 282, 'um', 1., 0., 0., 1.) + (0, 32., 2820., 'circle', 1., '', '', 283, 'um', 1., 0., 0., 1.) + (0, 16., 2840., 'circle', 1., '', '', 284, 'um', 1., 0., 0., 1.) + (0, 48., 2840., 'circle', 1., '', '', 285, 'um', 1., 0., 0., 1.) + (0, 0., 2860., 'circle', 1., '', '', 286, 'um', 1., 0., 0., 1.) + (0, 32., 2860., 'circle', 1., '', '', 287, 'um', 1., 0., 0., 1.) + (0, 16., 2880., 'circle', 1., '', '', 288, 'um', 1., 0., 0., 1.) + (0, 48., 2880., 'circle', 1., '', '', 289, 'um', 1., 0., 0., 1.) + (0, 0., 2900., 'circle', 1., '', '', 290, 'um', 1., 0., 0., 1.) + (0, 32., 2900., 'circle', 1., '', '', 291, 'um', 1., 0., 0., 1.) + (0, 16., 2920., 'circle', 1., '', '', 292, 'um', 1., 0., 0., 1.) + (0, 48., 2920., 'circle', 1., '', '', 293, 'um', 1., 0., 0., 1.) + (0, 0., 2940., 'circle', 1., '', '', 294, 'um', 1., 0., 0., 1.) + (0, 32., 2940., 'circle', 1., '', '', 295, 'um', 1., 0., 0., 1.) + (0, 16., 2960., 'circle', 1., '', '', 296, 'um', 1., 0., 0., 1.) + (0, 48., 2960., 'circle', 1., '', '', 297, 'um', 1., 0., 0., 1.) + (0, 0., 2980., 'circle', 1., '', '', 298, 'um', 1., 0., 0., 1.) + (0, 32., 2980., 'circle', 1., '', '', 299, 'um', 1., 0., 0., 1.) + (0, 16., 3000., 'circle', 1., '', '', 300, 'um', 1., 0., 0., 1.) + (0, 48., 3000., 'circle', 1., '', '', 301, 'um', 1., 0., 0., 1.) + (0, 0., 3020., 'circle', 1., '', '', 302, 'um', 1., 0., 0., 1.) + (0, 32., 3020., 'circle', 1., '', '', 303, 'um', 1., 0., 0., 1.) + (0, 16., 3040., 'circle', 1., '', '', 304, 'um', 1., 0., 0., 1.) + (0, 48., 3040., 'circle', 1., '', '', 305, 'um', 1., 0., 0., 1.) + (0, 0., 3060., 'circle', 1., '', '', 306, 'um', 1., 0., 0., 1.) + (0, 32., 3060., 'circle', 1., '', '', 307, 'um', 1., 0., 0., 1.) + (0, 16., 3080., 'circle', 1., '', '', 308, 'um', 1., 0., 0., 1.) + (0, 48., 3080., 'circle', 1., '', '', 309, 'um', 1., 0., 0., 1.) + (0, 0., 3100., 'circle', 1., '', '', 310, 'um', 1., 0., 0., 1.) + (0, 32., 3100., 'circle', 1., '', '', 311, 'um', 1., 0., 0., 1.) + (0, 16., 3120., 'circle', 1., '', '', 312, 'um', 1., 0., 0., 1.) + (0, 48., 3120., 'circle', 1., '', '', 313, 'um', 1., 0., 0., 1.) + (0, 0., 3140., 'circle', 1., '', '', 314, 'um', 1., 0., 0., 1.) + (0, 32., 3140., 'circle', 1., '', '', 315, 'um', 1., 0., 0., 1.) + (0, 16., 3160., 'circle', 1., '', '', 316, 'um', 1., 0., 0., 1.) + (0, 48., 3160., 'circle', 1., '', '', 317, 'um', 1., 0., 0., 1.) + (0, 0., 3180., 'circle', 1., '', '', 318, 'um', 1., 0., 0., 1.) + (0, 32., 3180., 'circle', 1., '', '', 319, 'um', 1., 0., 0., 1.) + (0, 16., 3200., 'circle', 1., '', '', 320, 'um', 1., 0., 0., 1.) + (0, 48., 3200., 'circle', 1., '', '', 321, 'um', 1., 0., 0., 1.) + (0, 0., 3220., 'circle', 1., '', '', 322, 'um', 1., 0., 0., 1.) + (0, 32., 3220., 'circle', 1., '', '', 323, 'um', 1., 0., 0., 1.) + (0, 16., 3240., 'circle', 1., '', '', 324, 'um', 1., 0., 0., 1.) + (0, 48., 3240., 'circle', 1., '', '', 325, 'um', 1., 0., 0., 1.) + (0, 0., 3260., 'circle', 1., '', '', 326, 'um', 1., 0., 0., 1.) + (0, 32., 3260., 'circle', 1., '', '', 327, 'um', 1., 0., 0., 1.) + (0, 16., 3280., 'circle', 1., '', '', 328, 'um', 1., 0., 0., 1.) + (0, 48., 3280., 'circle', 1., '', '', 329, 'um', 1., 0., 0., 1.) + (0, 0., 3300., 'circle', 1., '', '', 330, 'um', 1., 0., 0., 1.) + (0, 32., 3300., 'circle', 1., '', '', 331, 'um', 1., 0., 0., 1.) + (0, 16., 3320., 'circle', 1., '', '', 332, 'um', 1., 0., 0., 1.) + (0, 48., 3320., 'circle', 1., '', '', 333, 'um', 1., 0., 0., 1.) + (0, 0., 3340., 'circle', 1., '', '', 334, 'um', 1., 0., 0., 1.) + (0, 32., 3340., 'circle', 1., '', '', 335, 'um', 1., 0., 0., 1.) + (0, 16., 3360., 'circle', 1., '', '', 336, 'um', 1., 0., 0., 1.) + (0, 48., 3360., 'circle', 1., '', '', 337, 'um', 1., 0., 0., 1.) + (0, 0., 3380., 'circle', 1., '', '', 338, 'um', 1., 0., 0., 1.) + (0, 32., 3380., 'circle', 1., '', '', 339, 'um', 1., 0., 0., 1.) + (0, 16., 3400., 'circle', 1., '', '', 340, 'um', 1., 0., 0., 1.) + (0, 48., 3400., 'circle', 1., '', '', 341, 'um', 1., 0., 0., 1.) + (0, 0., 3420., 'circle', 1., '', '', 342, 'um', 1., 0., 0., 1.) + (0, 32., 3420., 'circle', 1., '', '', 343, 'um', 1., 0., 0., 1.) + (0, 16., 3440., 'circle', 1., '', '', 344, 'um', 1., 0., 0., 1.) + (0, 48., 3440., 'circle', 1., '', '', 345, 'um', 1., 0., 0., 1.) + (0, 0., 3460., 'circle', 1., '', '', 346, 'um', 1., 0., 0., 1.) + (0, 32., 3460., 'circle', 1., '', '', 347, 'um', 1., 0., 0., 1.) + (0, 16., 3480., 'circle', 1., '', '', 348, 'um', 1., 0., 0., 1.) + (0, 48., 3480., 'circle', 1., '', '', 349, 'um', 1., 0., 0., 1.) + (0, 0., 3500., 'circle', 1., '', '', 350, 'um', 1., 0., 0., 1.) + (0, 32., 3500., 'circle', 1., '', '', 351, 'um', 1., 0., 0., 1.) + (0, 16., 3520., 'circle', 1., '', '', 352, 'um', 1., 0., 0., 1.) + (0, 48., 3520., 'circle', 1., '', '', 353, 'um', 1., 0., 0., 1.) + (0, 0., 3540., 'circle', 1., '', '', 354, 'um', 1., 0., 0., 1.) + (0, 32., 3540., 'circle', 1., '', '', 355, 'um', 1., 0., 0., 1.) + (0, 16., 3560., 'circle', 1., '', '', 356, 'um', 1., 0., 0., 1.) + (0, 48., 3560., 'circle', 1., '', '', 357, 'um', 1., 0., 0., 1.) + (0, 0., 3580., 'circle', 1., '', '', 358, 'um', 1., 0., 0., 1.) + (0, 32., 3580., 'circle', 1., '', '', 359, 'um', 1., 0., 0., 1.) + (0, 16., 3600., 'circle', 1., '', '', 360, 'um', 1., 0., 0., 1.) + (0, 48., 3600., 'circle', 1., '', '', 361, 'um', 1., 0., 0., 1.) + (0, 0., 3620., 'circle', 1., '', '', 362, 'um', 1., 0., 0., 1.) + (0, 32., 3620., 'circle', 1., '', '', 363, 'um', 1., 0., 0., 1.) + (0, 16., 3640., 'circle', 1., '', '', 364, 'um', 1., 0., 0., 1.) + (0, 48., 3640., 'circle', 1., '', '', 365, 'um', 1., 0., 0., 1.) + (0, 0., 3660., 'circle', 1., '', '', 366, 'um', 1., 0., 0., 1.) + (0, 32., 3660., 'circle', 1., '', '', 367, 'um', 1., 0., 0., 1.) + (0, 16., 3680., 'circle', 1., '', '', 368, 'um', 1., 0., 0., 1.) + (0, 48., 3680., 'circle', 1., '', '', 369, 'um', 1., 0., 0., 1.) + (0, 0., 3700., 'circle', 1., '', '', 370, 'um', 1., 0., 0., 1.) + (0, 32., 3700., 'circle', 1., '', '', 371, 'um', 1., 0., 0., 1.) + (0, 16., 3720., 'circle', 1., '', '', 372, 'um', 1., 0., 0., 1.) + (0, 48., 3720., 'circle', 1., '', '', 373, 'um', 1., 0., 0., 1.) + (0, 0., 3740., 'circle', 1., '', '', 374, 'um', 1., 0., 0., 1.) + (0, 32., 3740., 'circle', 1., '', '', 375, 'um', 1., 0., 0., 1.) + (0, 16., 3760., 'circle', 1., '', '', 376, 'um', 1., 0., 0., 1.) + (0, 48., 3760., 'circle', 1., '', '', 377, 'um', 1., 0., 0., 1.) + (0, 0., 3780., 'circle', 1., '', '', 378, 'um', 1., 0., 0., 1.) + (0, 32., 3780., 'circle', 1., '', '', 379, 'um', 1., 0., 0., 1.) + (0, 16., 3800., 'circle', 1., '', '', 380, 'um', 1., 0., 0., 1.) + (0, 48., 3800., 'circle', 1., '', '', 381, 'um', 1., 0., 0., 1.) + (0, 0., 3820., 'circle', 1., '', '', 382, 'um', 1., 0., 0., 1.) + (0, 32., 3820., 'circle', 1., '', '', 383, 'um', 1., 0., 0., 1.)]
    location [[ 16. 0.] + [ 48. 0.] + [ 0. 20.] + [ 32. 20.] + [ 16. 40.] + [ 48. 40.] + [ 0. 60.] + [ 32. 60.] + [ 16. 80.] + [ 48. 80.] + [ 0. 100.] + [ 32. 100.] + [ 16. 120.] + [ 48. 120.] + [ 0. 140.] + [ 32. 140.] + [ 16. 160.] + [ 48. 160.] + [ 0. 180.] + [ 32. 180.] + [ 16. 200.] + [ 48. 200.] + [ 0. 220.] + [ 32. 220.] + [ 16. 240.] + [ 48. 240.] + [ 0. 260.] + [ 32. 260.] + [ 16. 280.] + [ 48. 280.] + [ 0. 300.] + [ 32. 300.] + [ 16. 320.] + [ 48. 320.] + [ 0. 340.] + [ 32. 340.] + [ 16. 360.] + [ 48. 360.] + [ 0. 380.] + [ 32. 380.] + [ 16. 400.] + [ 48. 400.] + [ 0. 420.] + [ 32. 420.] + [ 16. 440.] + [ 48. 440.] + [ 0. 460.] + [ 32. 460.] + [ 16. 480.] + [ 48. 480.] + [ 0. 500.] + [ 32. 500.] + [ 16. 520.] + [ 48. 520.] + [ 0. 540.] + [ 32. 540.] + [ 16. 560.] + [ 48. 560.] + [ 0. 580.] + [ 32. 580.] + [ 16. 600.] + [ 48. 600.] + [ 0. 620.] + [ 32. 620.] + [ 16. 640.] + [ 48. 640.] + [ 0. 660.] + [ 32. 660.] + [ 16. 680.] + [ 48. 680.] + [ 0. 700.] + [ 32. 700.] + [ 16. 720.] + [ 48. 720.] + [ 0. 740.] + [ 32. 740.] + [ 16. 760.] + [ 48. 760.] + [ 0. 780.] + [ 32. 780.] + [ 16. 800.] + [ 48. 800.] + [ 0. 820.] + [ 32. 820.] + [ 16. 840.] + [ 48. 840.] + [ 0. 860.] + [ 32. 860.] + [ 16. 880.] + [ 48. 880.] + [ 0. 900.] + [ 32. 900.] + [ 16. 920.] + [ 48. 920.] + [ 0. 940.] + [ 32. 940.] + [ 16. 960.] + [ 48. 960.] + [ 0. 980.] + [ 32. 980.] + [ 16. 1000.] + [ 48. 1000.] + [ 0. 1020.] + [ 32. 1020.] + [ 16. 1040.] + [ 48. 1040.] + [ 0. 1060.] + [ 32. 1060.] + [ 16. 1080.] + [ 48. 1080.] + [ 0. 1100.] + [ 32. 1100.] + [ 16. 1120.] + [ 48. 1120.] + [ 0. 1140.] + [ 32. 1140.] + [ 16. 1160.] + [ 48. 1160.] + [ 0. 1180.] + [ 32. 1180.] + [ 16. 1200.] + [ 48. 1200.] + [ 0. 1220.] + [ 32. 1220.] + [ 16. 1240.] + [ 48. 1240.] + [ 0. 1260.] + [ 32. 1260.] + [ 16. 1280.] + [ 48. 1280.] + [ 0. 1300.] + [ 32. 1300.] + [ 16. 1320.] + [ 48. 1320.] + [ 0. 1340.] + [ 32. 1340.] + [ 16. 1360.] + [ 48. 1360.] + [ 0. 1380.] + [ 32. 1380.] + [ 16. 1400.] + [ 48. 1400.] + [ 0. 1420.] + [ 32. 1420.] + [ 16. 1440.] + [ 48. 1440.] + [ 0. 1460.] + [ 32. 1460.] + [ 16. 1480.] + [ 48. 1480.] + [ 0. 1500.] + [ 32. 1500.] + [ 16. 1520.] + [ 48. 1520.] + [ 0. 1540.] + [ 32. 1540.] + [ 16. 1560.] + [ 48. 1560.] + [ 0. 1580.] + [ 32. 1580.] + [ 16. 1600.] + [ 48. 1600.] + [ 0. 1620.] + [ 32. 1620.] + [ 16. 1640.] + [ 48. 1640.] + [ 0. 1660.] + [ 32. 1660.] + [ 16. 1680.] + [ 48. 1680.] + [ 0. 1700.] + [ 32. 1700.] + [ 16. 1720.] + [ 48. 1720.] + [ 0. 1740.] + [ 32. 1740.] + [ 16. 1760.] + [ 48. 1760.] + [ 0. 1780.] + [ 32. 1780.] + [ 16. 1800.] + [ 48. 1800.] + [ 0. 1820.] + [ 32. 1820.] + [ 16. 1840.] + [ 48. 1840.] + [ 0. 1860.] + [ 32. 1860.] + [ 16. 1880.] + [ 48. 1880.] + [ 0. 1900.] + [ 32. 1900.] + [ 16. 1920.] + [ 48. 1920.] + [ 0. 1940.] + [ 32. 1940.] + [ 16. 1960.] + [ 48. 1960.] + [ 0. 1980.] + [ 32. 1980.] + [ 16. 2000.] + [ 48. 2000.] + [ 0. 2020.] + [ 32. 2020.] + [ 16. 2040.] + [ 48. 2040.] + [ 0. 2060.] + [ 32. 2060.] + [ 16. 2080.] + [ 48. 2080.] + [ 0. 2100.] + [ 32. 2100.] + [ 16. 2120.] + [ 48. 2120.] + [ 0. 2140.] + [ 32. 2140.] + [ 16. 2160.] + [ 48. 2160.] + [ 0. 2180.] + [ 32. 2180.] + [ 16. 2200.] + [ 48. 2200.] + [ 0. 2220.] + [ 32. 2220.] + [ 16. 2240.] + [ 48. 2240.] + [ 0. 2260.] + [ 32. 2260.] + [ 16. 2280.] + [ 48. 2280.] + [ 0. 2300.] + [ 32. 2300.] + [ 16. 2320.] + [ 48. 2320.] + [ 0. 2340.] + [ 32. 2340.] + [ 16. 2360.] + [ 48. 2360.] + [ 0. 2380.] + [ 32. 2380.] + [ 16. 2400.] + [ 48. 2400.] + [ 0. 2420.] + [ 32. 2420.] + [ 16. 2440.] + [ 48. 2440.] + [ 0. 2460.] + [ 32. 2460.] + [ 16. 2480.] + [ 48. 2480.] + [ 0. 2500.] + [ 32. 2500.] + [ 16. 2520.] + [ 48. 2520.] + [ 0. 2540.] + [ 32. 2540.] + [ 16. 2560.] + [ 48. 2560.] + [ 0. 2580.] + [ 32. 2580.] + [ 16. 2600.] + [ 48. 2600.] + [ 0. 2620.] + [ 32. 2620.] + [ 16. 2640.] + [ 48. 2640.] + [ 0. 2660.] + [ 32. 2660.] + [ 16. 2680.] + [ 48. 2680.] + [ 0. 2700.] + [ 32. 2700.] + [ 16. 2720.] + [ 48. 2720.] + [ 0. 2740.] + [ 32. 2740.] + [ 16. 2760.] + [ 48. 2760.] + [ 0. 2780.] + [ 32. 2780.] + [ 16. 2800.] + [ 48. 2800.] + [ 0. 2820.] + [ 32. 2820.] + [ 16. 2840.] + [ 48. 2840.] + [ 0. 2860.] + [ 32. 2860.] + [ 16. 2880.] + [ 48. 2880.] + [ 0. 2900.] + [ 32. 2900.] + [ 16. 2920.] + [ 48. 2920.] + [ 0. 2940.] + [ 32. 2940.] + [ 16. 2960.] + [ 48. 2960.] + [ 0. 2980.] + [ 32. 2980.] + [ 16. 3000.] + [ 48. 3000.] + [ 0. 3020.] + [ 32. 3020.] + [ 16. 3040.] + [ 48. 3040.] + [ 0. 3060.] + [ 32. 3060.] + [ 16. 3080.] + [ 48. 3080.] + [ 0. 3100.] + [ 32. 3100.] + [ 16. 3120.] + [ 48. 3120.] + [ 0. 3140.] + [ 32. 3140.] + [ 16. 3160.] + [ 48. 3160.] + [ 0. 3180.] + [ 32. 3180.] + [ 16. 3200.] + [ 48. 3200.] + [ 0. 3220.] + [ 32. 3220.] + [ 16. 3240.] + [ 48. 3240.] + [ 0. 3260.] + [ 32. 3260.] + [ 16. 3280.] + [ 48. 3280.] + [ 0. 3300.] + [ 32. 3300.] + [ 16. 3320.] + [ 48. 3320.] + [ 0. 3340.] + [ 32. 3340.] + [ 16. 3360.] + [ 48. 3360.] + [ 0. 3380.] + [ 32. 3380.] + [ 16. 3400.] + [ 48. 3400.] + [ 0. 3420.] + [ 32. 3420.] + [ 16. 3440.] + [ 48. 3440.] + [ 0. 3460.] + [ 32. 3460.] + [ 16. 3480.] + [ 48. 3480.] + [ 0. 3500.] + [ 32. 3500.] + [ 16. 3520.] + [ 48. 3520.] + [ 0. 3540.] + [ 32. 3540.] + [ 16. 3560.] + [ 48. 3560.] + [ 0. 3580.] + [ 32. 3580.] + [ 16. 3600.] + [ 48. 3600.] + [ 0. 3620.] + [ 32. 3620.] + [ 16. 3640.] + [ 48. 3640.] + [ 0. 3660.] + [ 32. 3660.] + [ 16. 3680.] + [ 48. 3680.] + [ 0. 3700.] + [ 32. 3700.] + [ 16. 3720.] + [ 48. 3720.] + [ 0. 3740.] + [ 32. 3740.] + [ 16. 3760.] + [ 48. 3760.] + [ 0. 3780.] + [ 32. 3780.] + [ 16. 3800.] + [ 48. 3800.] + [ 0. 3820.] + [ 32. 3820.]]
    group [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
    inter_sample_shift [0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 + 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 + 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 + 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385]
+ + + +We can use the ``SortingAnalyzer`` to estimate spike locations and plot +them: + +.. code:: ipython3 + + # construct analyzers and compute spike locations + analyzer_hybrid_ignore_drift = si.create_sorting_analyzer(sorting_hybrid, recording_hybrid_ignore_drift) + analyzer_hybrid_ignore_drift.compute(["random_spikes", "templates"]) + analyzer_hybrid_ignore_drift.compute("spike_locations", method="grid_convolution") + + analyzer_hybrid_with_drift = si.create_sorting_analyzer(sorting_hybrid, recording_hybrid_with_drift) + analyzer_hybrid_with_drift.compute(["random_spikes", "templates"]) + analyzer_hybrid_with_drift.compute("spike_locations", method="grid_convolution") + + + +.. parsed-literal:: + + estimate_sparsity: 0%| | 0/1958 [00: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ы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�z5 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
&$i%K-C$5hV2;ZxbRFgql7!ECb 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%0DNI>`43rc-TL7e!hr1W$){+cAjwK4Ox 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⧁Y-*=g0oeMeH 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

mE4@fJUcDk0{1U=4BbIPuK5yTZAscPQ=_ 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 Ea)VtsVU9z{7#J32R(*7b~Azi#Ro)% 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 CcPh#N literal 0 HcmV?d00001 diff --git a/doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_37_1.png b/doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_37_1.png new file mode 100644 index 0000000000000000000000000000000000000000..63a61d7cfeaf9262c754c2819baeb85bfcb3a3f7 GIT binary patch literal 81662 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 Snd=S-uj9uwRdSA+`u{J$F4uzq literal 0 HcmV?d00001 diff --git a/doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_9_0.png b/doc/how_to/benchmark_with_hybrid_recordings_files/benchmark_with_hybrid_recordings_9_0.png new file mode 100644 index 0000000000000000000000000000000000000000..8133cb4cff995d619f07f08bb2ea95d28b1f4591 GIT binary patch literal 212689 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%0AUytuGHj_;R2=j5HAt#(LVxsFa3{@}qU z&_rQrm;V0#Ni6=-rH{b zC~Pe#*#D{*m0Ct!!;m zi;H(cDn#kk&%BR4Gy7_vJ31>AKEN01kbvPw&Jsbv!P1Vus9~rRp@?NUv|;woZy)x} zl%|qb9%&T%P~N+DZ>fHDi4JbE@#;bg%T7fiJL9XwN;~nBCWp;B+?wN$4{RzHtEQ$# zzoH7WJX$#pZAod{!*Y|ZGo=gL@aHIB3R;(JY^v+(KG@|YIVBki<7EIU6f&AmoIb65 z^=j@*$MDNET|Sq&dU|^y(lZR^K^4k&8qg7Eqdk-m8W+a~;@LU(LDCJym!Mzb0s&+a zwg9J{(4IuuYZPiaI?NHz0X6`lZm}?Nd7{B(X3`?I03urn6jfdCv*KhLvTL?DYM=ha zOQDcGbWi8~o^YFMt@r*gJ}zp!_@kw+;RfZ}HP4W@xCbum3|{(A>4apy=<9OTdIg zhv|>IbUk8kF`-e|-hj2jPcyMgiOvtRCi4D$73>c;V8xpf%I$eI_)EAnLjG>mSbZ~;Zv$_d~?!m`ObUiPv zefs$Ea!4S;9`SgLR2*&;!Ig~S`}qU}rpQVFbO23+5BA6hLuk#p_=y?FALSGO=YY@3 z5=~@I!-pt}j5AsNf7|R?`qQKMwQ)?Djug4b0Ze3rBU;qPDMQJ4d|i- zu7AtWMH7?jw{KHE)=2=TqJi}Bj{aoYiHWw(mHXI-;APP0U;nOc`SBwNUK8pFKB7TNq5Q?9A(MX-jVu?#;QWpV<#=LtM$PU%v)39#)Se?lvL`Oqutge$01_c?;-4 z(7wL1vf^{8@z;z8ag$b#IMS?lH(5x9@s{j*2Dly1)Uqmcr;>`wWCfEhJzQkHOsL{- z-yXt|SG;=lpz%@y#$o`H!2%^3$7n3+kq9@6aUONt^W zF2vQLcxPL0D-kRqjHL$MMgl<*I~^EddUm#{v5`9Do9$MmS;3Qi%K{2#&aj|}e*b>8s) z6GRH#um%ENO?5Tb!Gotg|AOnLJ$`%}fH9~=B`PJ5Nnq3akHKjS#_oOFV2Mbfw$;lm zJ{NWPTwl*}@sHzrei+}=CQ-Z_;S6)pca_~(7gO8+N$0(#g~cm}UKKoRz*&ZOm$Chj zHoDI6%BQPr4IYlt!BpFoJxot<%+|iNYT$-Wrwc`x)Wmt{3y=qUM)ErQK`b7=dKF35 z2lDCETyGc?&@$vgx5be>>{lY^ZEeWN@*24W?hE(BFEOR0L=IYV?4hfBQEZE*8#O&A zf{o4H+|JSyeF2z>_w^Z_^pi2cD=j|DXTrA#A3Qiy)L)1Pj}9N2n8-0&JZq$I%+$Qd z9TGD2`n4=YRB$!6-j$PwW@~6@$W05KpDY<1-y1h>_^>N}Y9KgtSM^P6$U)>gOixdb z-xBe>0Ws{2ZL6%NPvMuG1|AL(w}=?UBIqR~ENn6I zBbWR1l>$mTh%3~8dJ$95gh&eyIr-Fl)Tv194PY-Y*u{!@*V4{-wP9A486TV*lf*+jcj z{qeJBcRNcH@(2iV^nwFe)Knxt9vsPqAkpOedNV*j@_iJA9m1i_15*u6O^Rk_W?$x^ zbmK^gLNF#gK_kr!={D$EA3}HTJM_#avO1`KpcgV$vg%o|AH4Xlfq@I+Y}Qs*6f1dq zuBfR|Lwfkt-_N*x`*x+_ef#zi(MmLVdjV8WAzbcAgX z5ES&*_CfFwt|J5>%1qsH8%Yu>u(z}8r}LRxheujC`j!Uf1ovya1r&l_m%?+FCDm_Usl#oMisE3txXepPR#FSn}g`zrN11IcRX?COcf%Ph)e=%ZQ+#r3>hz0 z)O2lkcQ?28-{mo%B+xBD9b)0ZMfO_!JFzg7wQd}UFdRDxWete8sLRzRS3g@9O9`Qi zMki6G215I8A)$*{uA+$#>^o%K!eLN(!=eqChUg=P;2T1PKe>2$Z-W1(E6A=(*NQ#r z;ILmfT2t!j7<6fL3FviJ8t09SLZMNjl9Jxx|NcgeIX65AoB_PHB2E-CW-!cx`Kt~k z=nT&$xc=M)LmNLv=i0M(Z}QWp8^fxI%h(`L5dB~Y>Z9t#i+n&41FW4NY45Mm(9rNz zJBgeV;n~MCnpt*)xek9XUs+x(KpQ12Q;O3uBN3AOSDP8j?-F*ZTwgk+px+G&lzhcO|eQn?=#iP8b07QAtVS!h*-!!a`{% zbNDM{@Tj!Kb8~Z({iEJ@o3y>TZ2Pk)78DZ^#ItW~x8t$mtcBmde;cx^Yzu-M9AW-| zjPNhvjC7B8zlZh=aSR}*#G(Ac>$VYy0mL-~u(`?Z;Os2kxjgDBGH8$H&kWv-D93(i zCpeN6U}AUooZKxUGJr?QG8l^;2N?)7otKw)965<77#Hv9w zOmWm>K}c5g27_39ObqoM`d47N(6w2d4J8pMLz?#NOOqSF2l-}s7ggbeSp!8Q8an;y z8b~R?Ef=;bf(VguaUW5}Vr1OKkd0k0JUWzfp=yiE$PZ;0kOZIlhGu4D1Vd+sgy4)- zK>U&zEwld+95Kp}w0Rn^w?@eQpKnd_7yz0uz)9=|;x6bo2Z&K7$d1nYpmhM0?6UF}x{QH+g~ ztgf(9uSdoxIWOO>5&0!oI|l<=A!mE^iU&AH+T%l7SiD2?;l zYO6SXKCV-6aoDi;oQNSz(oL6Arq@aMIz8&u!% zJ<)u*g@j0ECdX^h1%f3>S66!dMd@g2ZdQh5f~EQL1!DPO)o~edaV3~G2sh$L1N-^a z54<-QLo7icnVCOoV|tT1m9=p+R#Fm2D;yoQL+<(mz%5CPnwVUd zz4kX?#Ss|w0u)j1zO_6V1K_JHN5p^KqnbmHLa^*JP|6t-UYmY^0l023vRQVvcKg)u zaUE(nykiUG0)Ss`4e15X>e&?(D7XGt%W&R};Cz#PpC;g(Iv+wv)fd7%Uxr==&UMl6 ztEH@j_#8iZ@;#!Tz$DD;3t-|C2F-2r3K-8Mb%P3G2EeHK9itDYOF#8No(sBGh+l#L zh;0W~lS$|da!t^Z`b$?AOZUL$gx9-4C2<0+hB8DI3>l3(z^V}s?f1wCjM*<3amYG;A(mQMDJyPH?kw$20x(oIYaxd%h+O$#hiR_HDGK8x2{_l4s z7dieI94#EP_;_~@$yy@S7d+sM6eM~FsIs!MGHlsE@eu%y7{E%jvCoOJ>`=QoSH`J$ z)fxHUVow8~I=!cr`~oQ(m>c<=uiw7CvTYTE#f5@kmhA%IOR|@E17f2?>;69Cf__D+ z^3V*47-2p798L4uu%V%`OkrGLXKAOdu`wrL7bJ#<4OZlZZk#)D;vE=jO?^F;%7?o| zgU6}k*NIny4Tuc#x$X68^}a5p--simcgfF+7-M-6RlJ}7d@$=py^ z{0@0Z9y|!qtrdX_(kPMWq#t~ME(HnH-zdv+b!Ay*Wv-XxDx$TrYeK9pn3*BlL=suh z4I%jY@ypD!RaR7Bzaj4#rB=zu%S&_wXe6!3U;vL770JkO=!PGY;^$Y{YyzEqn>9y) z4I-GQQlFYLzO}69AdyD6w&;`Z&@?UU;-?~|3z&+kdUwgmaRBCJ=Ka(-eR>-S#}Ff@ z@8dl%Y+_sBL&Dp5v-_Hzy?uISW(W-F;3p@bMdCj&aG*@VD@0x=SY3Hk%T6p-Ny7v# zth2K-;YhG|)Ya6=%(AIZ*ygmp#0Q7Q;s<~T75wCa%G z@L`A;1?V<0{+{k%S{5AMqAtFMs6G&OU_^BzUdl~RZ6eMFrf0e^JxKT{1U%r3vxP31 zYQb@}77fD^aJf(kr|{tppktB+#M9jWpcbJy1oUv?25zw!ArN;3P6FPx{L2@52&!;# z{w7D5N=IJEYnumF)ba6=2mclT+bOEghu{t{ODDfX4<0nrVyp{g0sQRsn>TQ4lvB}q zfFW)*+iW`|0^!cUAYQGn#zR^?ay>&E63CIf1jzgu8j3x5i5ZxWRCg$+-B=Q*wLGa$ za&zxNV*?z*31w2!WHwHJ{CE;tD@kR#yG!G#Gt|aVQBsoJGNcD|;ranG6(loWwysCI z(I%I>Qb7J1bT(vl5%>{?7g_)n6d5iOd`9v!sJ~GRB%EWb^0Tb0bl8?qRpBdq0!Xg` zl*CX8A4s_j9jE8`n%dghuPrUX%P{0gI2qM$4$=wb9o8vKBt3v@g+OdgR~Hw4@3-^Y zhgRlhX53IFmq1ZSfFBkP8#}x5!l-SPQ|ys)wRK~V&e1?(1M7fOCBr&Icf-fQGD4+h@J)ncj5q;pqZ_~pNf9V7dBK8k!atC+ zls2fL(pOeiavu$cyN+{(Oq|^ddxTZN+TV(vP6D-~qoYo0EJp}cKt_Chjci+?0H$9mDu^u&(ucUoBbsNK znHRB7%G8GLPX)e%u{MvD7ishn!&yTt2hR>44?G!0L*R`efHGujO&eLL*H_%+qRC$N zF6GijUqs{(q}tCYt7_0WGBWZgW^GCrJH`+x1-xtO=%{rqnc7JvDZpsR^5o@3BM}6S zcDBS*`rNs5u=6)qTS2CE2ZBKd?g#5W$|BIYy7!(&YJ@pNJ$~&tC2U@3OrT9KP0H6| z!#e%E^aHtUtvwGOJeWs_J`nO=B=4@!7tf!kLX5{DU`VL0uBP|>LD14-c{h>9Y_MO67zD0NFqOTmn_ zXg7&&JsUSuu0x(~AUtI#t27b+ti;z>e)tfyUy7G^JyK*SJd|lb8OZ!N41eDbF(5-P zlN;gCa8LIMlo>oh#y3=ASQUr_sfC5-1?kaT;ne}+h+h67{-XG=K59N>uA;e~VD2d{ zrcRUvV{s2TM0xO#)ipJlo(}77a$mI^N;k)0nhZ8xS{qj7RP%fO9tAnAq*42VnHnk? zVXSWFYeNl z)j);rYedc0+uQ4R;w<>~(%-yNND5B)NhdE9N>9Ded_VMTiw}=OPiSL^Yz@=+S*5BLVPiziE=3R~)=YEq1rQ#QtC01Gjb3b3w}-jofP4TTda zKP4q4lblvH4UKn$&W!{^+lxAO09Rt$bAP-b$~sr0>wOe*C7FwZ!V3E*lt{{RRMfI? zakn*_LIDTFJG?t+uVM)&ss*Ij2rIGn@81K^b8zM_y(qYiT*n*xP92;kXsT#>tO+{) zI_V?}LC^ciEF1Wuj{UMAG!_^DZfk3PNFk|(6G8!%3ycgZkot)ejqX_q@n+{=TR|kg zk1a;AdU(w{we!JP7c9paW3SvV_0=s7&CgDwmG6H9T^wb>x6;fM%ZXkE_25=jFN4zg zpR;M)Pflu)NctGZ zKi__*Jj z1)Z+=C+hb0B7lGj&DYB0PvuIRzFQAh280Uq^1!8Ug2YIgs+ZS=vV|vLHX#Mr2Y7`4 zO<)?-ds4nH6oP3D9N3`&ON zhTu01M^}P(!FDGSaMD~}*h(Fd-*NnnUhjGk#_xBh)vWZq45&&fEY-j z=jTg}7ERUFS6n|9$;+#LG*gczp;>+?-T;me35617BDb&`>a<&FzQGIK>BCq@AR3}= z!%f3@$&(i^_@N3ej+K&}1+RMY+qY~XDpP^%d{k@Kz)s!?AIDFx1Z69@^605kU){$> zU}4)zJUDx!rMY=OQZOiYmF5_imWG&(9YaR=;=Rxrx_3J7LR{e}YpopcGj=GjY8B>@ zMcV%MfJ_tF?Ocup1Ee#+43znzCkX*9Luml|sLQ&2Mzd}*+(D_$HqaiJM?0Y$9pU0) zHU|g=iGm(qzv$`e>KZKGQTgS|moo5GOm^#szZms|N(0{t89l_*glQFAx%Liy zlIAvD5?h001!1^mCuVRPF0UTP2UDmPVx|Gdw*=KC$*GqwU+VHHU(9K(II5!{iqVtv zf#S;#u((Mrmh0%FB9Zb&mxJ?yM(GA3lRxsNO_)vk5X=ltuW1Gf%w&HTn3RmHe3q7$ zHW2<8;}7`F7#v6W=5I<{`aZ47vja3;CLx~gX~vWb-j9rQ{dPCt zDWsSoZb!w%5%`b62BhB^z&PQPBN&OM1{pp7&$A4ig7_;|Hn;nn5yz$+^2U?G(9xK5E zR=U@OpsR=*XP633A~1{@#Cxp=5+TrFUc&F~uS?K`P+^n1kla8K%W9pg4=_sHjq4Fk zhX8_+MFK+rijSX_16m=;+Q;eX{`dBsp`2$5`SAN*F+_b35ggU1m>9Qn3>>Uak02I` zgtT#L<*z4SZ#`@pl-s|im=$!{DC_A{zZ)6L)j0&fMzdL02&-M;J^J+QfhE~iH(B_)$TEr8EqhR zP*QTLQBwrGN&&n8kson!@rNx#Xa}3&ohQHU#5h4>qVNlC4UJ6z>dE5X;&5%NfC9c) ziDTk~j6)-pRB@Y@opr|!v|9Xj9fTPne$xlF%;v9L3xqTY79dG9ho1G`BsZ@OGDhO8 zhk=^;1nvPcp$d&>HVitW7d0FhhL%hM@vO1o7k$yA{abCBWh=S9zv#BDzO01iM|ySN z*Dqg=APtG|DMq$&c$7RAhMfo(CKok8od@^29?(;!rZj@GhP{!bIEtalmp`O9@ll9( zghn3Cdj%!(&W4nYx8v{rBgHZtC?H3^_CpUl8mh9MSp6@P! z92BYZ2p+I_D8kxwbe!Cfl0$?LjL^)TTKIYI?p+LhVKK}xAOki8I|4{p;_B6_!#{Fq zFgF!+#{5AD^50!oq$&zvG!Oi^lqEan=85q+A_727&Hp?3t`?Fjq!^M>JFCRWr%vW( zp@9;erp}2+opIf|b!MLTA*u1)vD?;|ikv<^8Q-&3c*r+nPXCKGleAi8#e^T{5|`Cg z8BE2&IQNZr6k!2tm#vCY{QwStfgu0gt0PcuajJoQF-n{5R1x5~X8{Ri08Zu-Nd%us zypKhO(!DIaBc#vSw{4_<%xCp5M9SvoN<3_@hR(+Jc0pUVMECyfWLEq5W8pKa*c~u= zE3?mM>Y2vFz1QHPFB_Y^kkjh>*AQLp#yYBsX9k#_diIRmlK~w(S>KM#R+Ui%N^+DxQ1E?-|6YFM5Fe&9cXDscrhvpX)TNOI2 z#2deTQ$d&?*a_MW#z0u_zPc*4Bl$p~f!n{J)y6WrybIZ9^D$RF4-p;sVq3`pL?=Gj zN*Mp$dzPQeanIW&b_5nk6b#hllX7Jd_DXv_UJ=x9ePM?V653D>k8-XH2%N*id6%$+ zDa-E15A2c)3g(j;IPYm}a}Fi+9;-km4PZiU2J#`dYyi{ps`o&a9-z)fffH%;gO)_g zeUuFJbiBP?JTF!%a-4`+TE}!a|n!lMiJRJBI^RXEB+z5Rg& zxbAQB8Dlb}i4sj1wAK4tOJSIjv z6CYj}7Lt4nDl(CK;n{$aR4k%JBR~o93h8g)vI8{)1Hy7J~ z=!6t-FQoeItgQ5YxG)Gc4_dE&rrwHS_tU3!5&CvNdR@bzuB?=(0pQe%}GqukMC_7bS4S|WGI5kAf@B2kzhEYWW4SM#s2;K z(eHzNI#HPvTEw}zk8TZz(0|;(Knp+|oLnjG#74>Sy1KfD4)bO-J(}H3!x3(Ln8017 z#85OeohaOt0bC`Zo49q~&xQsr_k;Ov6J}^V*f+@#$FWghWn-iXbGY;fvtw8j7R|z` zwp0qt#X%rhx;;1NMuIpHu>?-gh?LB6V=Ciwl6(@mW(TRB$km3pwrie7#K*y;{nSi3qXQRFb>^Q9uwmb;GQ32-(XnAN^+2VrrA#>Ja2pUnG_q`Lx&Yjz{cRPYw754W|7QV`Ao9AkC1!Z`N5hhQ$ zq=1{-H&ZgqvyYTd!(#n{@!`{F49T$5tqZI30W}DxW6bqvR!b7o$g}6qe_;*F)GA*N z8+gNAr`kT3NCzN8M))i2@@US}AaGE@s*;>0QasIwJpXf};)Ro!IAFvTAk_Wz$rF?A zcefBL7Xuo=I5i3tim@MfAp8ZuJ1`-@P>ez_wOLwPl7t}&0Ia2VOMfBHr(?_z%S(nT z<1W0XZvA)L#aThv*Heg>PXcEgF&KcLR|44-V}gVVBbAZzxWOdlVk`zI$~e6@neL{Ajut}_A-VpIP6kbU72*~>L!S0Z6jM4 zf5@#aO(BGex^*kI4~hdq*(7m_Q7CZ{VpzOg#(xA#5_*iGxX^@ zy$l!+L5^nIwrvP!U}Rm%+4;3vbO>@O$jIHn;!k@NUor1|bD5navq(q`8F7T=oG3pb zROAH8U^H$}gL%hD(g)zlO#l5G6u3Yx4Fd{(bjp;X}BAHsTLEfc#TFmgk6#SVg9h8c;&mf7Tw z;l&5F+V-PW6WxfhLH|0^6ZnBh(G8;el8Z2Mu5=$IL+^os1tw|D!~}$J2Iu^AID;8< zdT92~&t|V>4HT|f5Rv8s7P*;s&nSzITTl#h`|kBqKs%@lu$|FAAX}Injh=CEyt$i~ zSLv()SeH(!KSWH-`{SdLspak@N}k@!K-sL(fS!n&kjo7KB9V=~TJFTH&Irc_rUPQ$ z{jmiF1!giNd;+a@2@ZB2pgNf+TBE|uj)=Yh{xwjABjz9))=Tc%wI)P`huaj|+(9fm z@}nC0u~3P|XR>^ji>km2Dxe2lB^QZ&2x0fr^1oQEzh}Pn)925aaTfzQQ&3n#?rp0p z9D}fqnpNTDCLO`kYPbq@?Fd9ntP@1cE&l7^FO$NKu_6F^>~ZL}lTUxw$Y~P4_8t5& zu&XsHl&-Pjts^cb6~DE;%WgPL&SRIqbJI;b;qd#*E>!M$Ta?qD(9T8zjq! zBnBG!w{PDxU+lhi?V7SjCO8EN%cvQ{KOwvmh}7O|=zO$b+I`$~RSyzz4Ok7r)^GQw zhp+etJ>OZRG`Q8xWWe>py*6+Zp%Is%5r7>K1u7u_BA((SNdW$JD%19__ILK(?fhuH z^_;zfOD>5JBGmoyqXtBc;I@&+IXNe-p5yhwc44v(yDUnl)?ii^llBv`j=@YvVpeK= zM}oWY_{p#hGJVJoDFQa&=D8iOB5)lV8aQ?(sT^OX$SETCZIRp%M$S=ykR$!0%ySI5 zfm?$o{bXBP8iY%z6F z(xwEm7vxb~r?aqwk()hB2mK$x9s_#**6UNY)%x1*o|mnxy08+s9f<#ly&)I9lNa8r2XK>O@LSK- z90F#@9XH+XG>BbYCHZ@0f|dg$NTya>&TksZ&03)eCf8MNcCzaRj?GM%w8y z*H+{Ck7r1>3k@x;w3IXY6I>|dHk7BAd>Jx=K;onEQd2L&VODuSgmtMi>$cSnYR~y> zlN)41P$HQy&CZm0S%te5tQ%9EsykcB*f)3y27wf&@$u>|EdrYXt}t5bvF;JE2S-C| zSfe6-PJXVO?)qxxS@c$g(0a$HZYcFMR0BX0xG5kQ5+W+e(F^x+j{`z5O0PGmoX`c9 z!|US5p8$c7sWw3b2ip@@vV}BQ@uG2(Q($JMB<&4B6>A9(nMjHt+%Fz(pueMZ5FQ-Z zE9x_BAgfE4nC@L6D&WC`8)A?Bot-s~6h{XSR^O_k#Z5&;g^q<(RYYVYcaR`5fk=6B zfqx?|MZv?+3P&7%#;e;H9)?SR@O2k~g19azY3Y{$AmB?tVVH@;iU0Z)S0<_H>ZT)` zj)5XnADB*rSJ1Cw2_NkgkJU1}AuyB)yajv$8I{~n=yYOQv{7uRlEs)8w!WYh()+kl z!psM90Q1w4HoHP{NzFt%CWRen+1c4ilmjvYqxU(-XevyI-0eXk4>vdl1!Q>&)t8Ln zdYjfF_Z-ovdn}?zkD$Aw0x7xP2B?MAk1WQPnYoRBRhciv`@_X;Fh3ESkH%09 z^aE`1e!lwm^P$I$v=hS&szYw60(YuOi7YOAlWfr5cNl9`>yyJJsN{|`>~trM=U68U zfn!HR+`rEs*NIX3Do0%`xE0`OoPe=K#BuRvqJp14ed?{1?A*GzV`oH+xHTiN2?>i$r@wlHc` z4855bQG{xtM5Wh8VD4j;f)*S$C0tQgTzHxYshn<-_a8oX%KT|3AEyS*N7+Uugmu3< z_pF;2^8+-JLjGJiRZ7vKutlSGZUl9TMcI(vx_$ed)c=-P>fH?TGL zCzK@CjVeeQy}Z)hf{}rB9ep0?L30mIMocmbxwpmh1(~55vboJ)LN1$O(}DX@;mK`5+IwBn(JY--3>De($$p^A=~j|MDz- zI@eoGGiEu38ZeDUYktkc1(|!}<9XGZqC-Pef;Pc&h3|;&&MLj#l-^0F0l3K8O(Yp&@mCizl8J77n-6=FRuJS z>UEkAbGXW)7ePhz1o`JZEl8q_t{nip)6T6SW2s~KKG8gKm!Y(Ie(+pF^6AK;i1*8R0juFRj2jUMf(Oi*l^CWt}7(cu8!Il;lR*K05 z)w1O=D?bmdDkc_K?A2c#g9wUxgTE)pW2i%N4l|ZBJ$lc}h`c>vYx(8KNeo^C5C{`K z@bhz@cro|J(5%we)! zH;aJL1C*40#K&H_{MwtV4!=WlJtj8RdgaPt$oRnaUGbX+y{>f%_(v&+-N63+3cNgs zVw{BqeJ@B*G&FiT8P|VaYeGk6eHjxpSyfflN4Abe*^SKNDbCB%k6d|#$z<#Pxw%=Ba;*?o@gG-zr&JSq# z{8_1!>F&Lg1AL>cZ;VXYH{>moJkYT=ykLF=3a=>&(j~o*IT*ePl1e;P{Q|O6{ClK5 zh{6H?#8z|%_O30ZpN3OZ(Nv>}4rLxZaV|;v>+s&rxyw$ETUL^8nRFhBv-JmSr`nR< z|84%f?MO(p%H6LUuf6T3dRi^!;i#7N>2O43q0Wk~%u-{QDhb{xh5Z94NO?y~3p0^XDR1m+I9H4q|;T?e!5Zi@+xP}yu zvcI2-N_twFr9u+nPo5!gLK+w8ZNGiv-;u#l5@kOhb|xMlP_Bn$vZGdSoVak}Nc?%I z^k>MVj8wi6Ult-0M~nOf;nI6>*6ju+KK2l&7;wnoM*uSxMvb>Y{|4Mb3{5MH`=0;8 z^5SkHz9@(Y3_w4`J8k<`d~@eFL-jR)K2E=tv%@D%+#Qv{Sh`~jvm z!>fi=&CJqIO4N%T9hG$qqvWM~uZ5{JKtU5wgb^~N3X>a%M8Xu#Y*q_=vGCFZo7(oi z$XLwD+uW@<$Ag|$CV?$A_VkwVHP9zx!S&s*th@)00?J&7R}k@3U2Epa$N4w?4ZUtA z%{ckbYVH_hCS)DowQI}Ot$4^xy-6S51)G{QxXoX^*Twemk< z4%9#U`EF)HP}cEN;g@-IG-wRwZyKknp7&Ri@WWiIgI`9|hYy$uPMtn|@a;>rHy6;6 zW^T>k_!A=B;bNY;Ful{Xr=5{Nr${t8A=ha5Cyon{hpz>pgEbat(4n^Iu6cIjVEa_M z*5F~DGzSv0oHPLGDqkT*U}VOSaRt5&8WrRb91&mCc{J13zQ2=l^{PjYJzMQ|@Kgx@ zLkn;DmdQUadbJ=EUqc#>@PA$DDj?>q+pqBDkR%kE+5A~tdiKJ_i_CoQeC>@E z!9rHMUyBdTFD8oM--x?%I1MHKV35R0dFtnZaPTKipB~Kk1vGf)fWXok+oV(Qs3<@< zMPeFgs5fdaE;JWOPF{XH?gD!%e3uLVWv@kxQl2(8eEe95NAJ%+Opt3)LMPnS3LnM0 zWxhz5*Ps>Rj#%l~EA74Fp$B#`3H+Pme+I*!Dex@hi`4l!4ze|Y!JKNqKk6fhXgr>v ztf^^$BlF!6_tBA>K-nZJXke*S)9ARe!Kn`HZpa+5@w@-H{HqU;_eR8UGzMRvxh}M+4HdkMxDlNH_ zd!F73x!U%!=p3X}q~Ps_p}rpi&hHKK^C^j~|CGM>sIKrM5?`XOvLb0@pB>{4-d11x z{GdKFHq<a&X4PYv+3Pu4Cl8rC5 zMD?1NutA6`=<3m@410VR#glc;hHt@N#P|i}Gx;y$0kJi8SQW!6$F~hze`(ffjbQya zlMUd;EzEs7cO=g<8!CpF z;k!g&-BwoXJ9WkkEiFHHtN$K0EM2t;cNbRK)_=iJ=L!WvjX*YWxJEJS2@Eov+y8&ipdNeR|(qGyqXVi>s>rUzL!%r#jwY{=3kJsD*okT~O&| zElv?fiZ4ifkJx1SWErQJ%C)qttan2{YuJ@%H1nN)gts>ZO17Np15` zU43De{gf$v&R%17b~6s{ZwZqe#R%*rGw~fbvh#LJCNTxsIB&*0=Fp6jwJ2#sC`VkFG?4cYwu>=*un83x z779elenQSq1l<~%^ucdT%JT;eez0-?x4S?rp1Q`QIOVNjN zMXOL;bXOWCTjOkI7OygW#*BTjv4b^Ual|=u#A5DR{(d_7*~oN2y7X>(&z+ZL@a1{w zS=~sJp-^;Uu2zVvFI8@A3>fcdW2xktAKI8b%ncQl!U8LfqKiaUKuY551FsKVn05NT zNlCA5SC#i$e$HBNv$Ob}=@3OUqWbv_Eju(f%!`|OE$h}2@-35+p(D;+b%|B8N-iJb zPz8z-dTYLa`hZ39Ik>jcf#*~H|9A)kFvXwNr#)@#640?QSo@eeO(@C7w>6vsigY*4 zq5KnqD35^J$H!iNQW~n)I*!vEVO~W>u{WXx6MP=uC*dDqYU*CE#w7?YGGD&@KRdZx z4?XB0mT*}3{r7k|W~CXh<%CzEC^Xx0S^MOA6P2rU=|~bB5Zbnisk68Vt5=^rSl_|t zbq52f+v%h)p|lBILAgx=A+|{S(j6DBUs^Ht>7`!2#)p>P-tiSV7Q^XXfk?s-62g<% z|8&lV0UR5PHOdG9*!OZ5k(rL+P%eVTr{$MkTK5`bx^D1Ki^=x(a++rlxY1yh%h)$$ z7kEnDO@={(vric`h{Qk?(t-rw;^IQvmh%x-T2S|OD!yEsH;>~}9Na^v6(7PoTpTnU zGXL)@A#EGrK4RFh;d~IA(y@Bm zkMF9q${fly^bx4d2gRVsqyFQ>EtHqHVAPUF^7eXMvF`u-%vkrb3XqdkQqq!as}FnG z;D=-oz^5Wgsq2j&J_wO54kUE74pF5bApBkZxnJq#QndgjZ{xv zkk=3_5k}X*87^i?gkWNrk#b9)T-15KQJINq!To6zO<5G>gI3DbH0^&_>3wOo z)q)!BhxN@)7yIrV^sS|F>xi4NY3Ex^_dPt9b!&=No9`?`4xDTn5S|3m3P%yraKgiW zRrd4F@xjq6ZMp1F4J4Ac&HlV96p#;)VWIQWopDO`XZF)ZMY&4^9s?P=@^NUKWzmJ7 zGA7+~?^6%0MLHp3r`T=7EIPh4+g<+5RGu%UeSG#_iJy{e_Am8iX=(xBU(P@%207be zgd#Q-<+<+M-1bD3+URa4vd7!OLj|vK@!r*8P^ER2=a098(n&wj;6Nz@HbT zx~Uao3{3$yIfp-z8=LBM{v7yNz5XY9|MQ@u8=de9vl>R zYRlf9Z8d@JSplBRUehq&7wVwOi+0s6bzH#oaLCk34kcJj;m}dK+6Ho$J@klLV$6TY z5cDW;(s|1-NY7KkMIv#>V0+?8I7Otng>dxe+L4fHjdydDreiDocL*~E%{I2SYuSFF z-l8{jsPG^V(!-Dj{4=_>801(`xqMz5b^Li9tDD_rb^8`e}i zxFyO$@f4UFqpky>1UClK#DiPnIS3%7v=PQ8JrL#>GknpIBBfQt4ChM2+E#QsgOO4C z8Ss#mtM~a$7kZc~Gv7W`$$F&RVG|z>CRq>y4k!6F|NUCd0;pnybCa-@+N(tW7)Wlw7SzU8H8=Ct~HakL3NH0y^A%~`DK-Jm9%g7 z+VfFGcjj5zosvCSE)i_krDROx*Xdd%o&GHCh~X1GltF}>apzk;bd_Xod&R}8XZZ@` zDjE_)_}om6O#M7|*5(hsxxo^naLK$;rN3v6jD6Y5vY`bPHK!=UDCMOro-+hk)x>%d z7gxLZyNl%pE5Ej|*>kaERs!8OlC5m)i%;vP&PVSTiZAs>mFIq$7_~7ACdO^h(aEkX z(=dQx}n|FHVeTdt$vF)!;T3Tdfu~W_;DYO;;jOT7pAILud4K2R;^*b zPxiDL1Tj+CXw{YQyE{%ebgZ4;Lc;xZd**cu5;2N=0y2>T%>mG!@?(4F4|IX@ADavr zV*AZ5X0($SSn(PuWxfKc5x{ih^l8V?jsF5CP%INNA|zuRe$e;iXdW|QGEbQ&g_t|)yu2t||upsqI zS#+2u{#A@s3l#9=$t~Je#5Yo^@F*pv37@zC_Q@azS%9QSd?w-q!~`-fAzA~1R4X4& zOmv`C%Pa-Y1tiGA zI=qnVGd{1t$39zX&mT(cxRS~U;n0xe%`W~~>(;L)NR+UQjfv9KUT**foZWZAM*|N% zf_Dt|ZOk(P5b%@>3tU0*~v2sJeU@MdC%}Pu9PtspC0E zFV3wHsSyVinWEqWa6f@N(bmz?Ff`l`B;qC0F%bVnK)C6SPEKMb1wMlwrKoZ{w?>4A zzolepQ&jYEv3sc46vV9`?PXU;4KO2?=um~M1-mSZufQqX(l~r; zV8|e3$N(85BwnFu#-V>96Z-ip*YJ*n5ub3dY*#<)dW_QKUd-mJSrRTz(;?(TzqD{t z5WmRwK8ABXQ%3w&^K&(~{_`E|#FPSpTdPt?5y(4$s(rc=lKE}aKkR;jArnKl{#a@l zvcpRM$73b{H+$6>PN64(WT%g&>mJ|I6E`YebTR#RaPQuMF5`*KC^R53FjWO1q3jo8 zen#hne=JT#U0%-=N8xS+DpurihsSQfVPg9OZ-M}MnCS&uQj(aY=y?0n`n6bZM&H`k ze^*E0oiBK~Z|*VET!fN{HqGAq!Z2HM*Q=m1N-(;QLdySU4k~-YTr7m})(;1B3X5n$ z5~)A+IHI8W$e{)q?KV07^ZR$Y2}m3c+I{NqHh_qczhubp;f|p(wIOBn9s;mMgJSSt z%vGakSnsir2z+S@1dAaWE54Dfh5Hpbhx(Oz58~;68?lJH^akelAJ}_!Yx@ZK$KhdN z;tr7lJGARwyhgK~?KLo9p#Pyt7Q|5gdf2A7OmOsU31-lpBSOZ!6zbv|U_RJ3$?J7w zV%7`NItN<}A@Syf2M6+P3grA4UEUFVKVe>A=55h_*BmO@Jfv;7kK{>Nl#&)RKBi{V zD)JV7+}S- z5!A3`XQ2xCX^E>9ahI-%*soW;(D+5AH)=R&qQUDiF+Nbco7{{l$-k^^4ksd(_in*cA=O?dbtmb01)ns@o%N z{8G>g&Qizdijcwyv|_a*z7ZZl|Grz~Ez5MXk~&>b&5Q&C(1qwPIBwOX8j(@!X<()x ztxC6}%n_RGzM3_On@JAhNFJK+*X7{Hjl*nB=rk8|R^!1<%yDFJkigKSGNR~vexQYO zUq<)P(bG^<(DE1hkiy&N;FM82^!!z|Z!L8bWCkIJmJQ$8{o2NL_j4Pm0UP|nRj!rQ zF5r~lltAXY2iE36DRdT_jCewfFToVi{tv}ZSgO2t?$dov#}?9`v>15yX`_h5GzsXE z?j;+cG!!Um7F|1@Fzt2+46=|E@E|!qLf|iyfw)=_;y|3K4^C3y!EMpxzJI4A)}yzU zgHV`j48H)npa0qTvemq~x+>$+A-b z1qx!id8j~}f75U8EVUZ{kBb*)q{n+LEW;H2EE$8TKjH3$E@2_dEanL}Aap zA3A)vVv{kYVMWC@lo>cJq-I2&>Zz=vQp4BBC#J{5^zokQ8rql@*Om$U6-KVR3Txbc zqu7IS7p3wJ9fL(Yr;7P=FBup zigjd|*!mEV9>N*I2r|SamH~m>BkGxmATahIBPCYUs>Wn>3P76=V<0Ri-u}Xq1DU-1 zP$iBBr>Kk6OHv217Cw%e2RS_yjyzTDx8*JC*eNO&qcCVYFx$~|(m2L0$m9-OM*yCv z|7mD}ChtEhP1mGsg3(|;3gT6)OCu7C#e$g=qsymRSZx1wn)Eb?S_lmfzeik_t#V(B zL6Ab@$|O^8mvEKXeeDrpVJVP#VwnhQiXY4vc{7)qjU^;EtJg?gyHD30FYZ3xzq8Da z1BUV6@g*a`z3nVoAa~|7y)us9)TQ!*4*`PYjD=v=EOske%Xy7XH;>49DE`lO_!fsP+Y)~sShQao@& zvu+pZWtX?BpVh0`|C9?FGI0aro3DI|2jqpG$e`b}z&&71?1f$}NAzbuzw+99>SGdG z@PMtao`}typigyAGc{T{!g>UjPM6v$|A^C`#ic#HM9tigN)riL4$>`1W=DDqj6i5Y z#N$8i?mD$}e1=em`x4&35!PY;5HO@zaEh2vCToLYFrl`S++%Bdx5j>^(a+g80k^n8F;UDbF-;h|nKma(;e)bm@*)yByJM;_w8EDF`njBhRD{ zb;<6=@bK{9CrgMKRKn8N5>>)zvOxk!tDj=fP3wUsa_7`#X9ya!W~ZN=5w@y!l}%Ma z%XnI2PUJ~&xk#~lz98`xwrJv7Fy&xhKZ2{=Z$jWW>RZW=jIbmatzrUvmesm>mJ83IK}0)b=sA5vIsn!eW4E8l}r4_F)~1YAfu7}G*nfj}#mrb9u) z_Yel|D148UCsP`p!wFC_I-3t`Y&_F7f??Syl5L2sv0jkD)#+QmO;iP_3Q*Yy@ng!% z1hCVH2RBYJkK*shqChg@pfe|jY5m-IO(WcIee)!K350-BgEkMB%?H*yDhY1FTzWvL z$3Zj@d|HTlZj24}FAnWOA;5Orh<{N7#u}BJ+TeFH{O=a(J>qJniLm1&FN*!jAZS85 z$d8XAy5p1P9Owqg&^dP@ZTrym&Is?X4ZN|&`=mBv1n3B%pIcLgb$CgIdG6giq9wI2 zdUOndiq8X^xb(@esBDCQM{xeO-*^1K|3o?xqZF{IM|NIjVn@KGLR`o%!51|` z>lh!3;bv#Q;S&f&d{iJ6ucV79t;~0Vy76*6Gl}KQfI?wrf*|1*iOj^WesAc0tVi~d zWNB=CV_AQ$j{M;VOc+y7z(9@rcHffg z3ez!2GGtt4*(wvy^b3OC-h<}{eO{!@DV-+agAFW8KL#ii{7=#hagvip_`5CMuEMhau z5QN3j7O$og72aPkaiEcrbU6FcR8*ZJKHd9`U36q7~0zU|dT9q&`; zlVk~dWaQnHe$X07Joshwyw;zZB zPy}97j*v0=HPcn&Y8nUjIff#a=^&if#8MOvolhGTntJM7nq~Q`USb}MrFGIe_t`^M z^qc15Qn*^BXYMQqy*jnir7;FDN$Iu^g@OS6OqX@j$jDz<6M4W zBrZ8HYE%zG`2!z0bt{IIb|eNy=kP|`1qc(S38+pO_j>m1*+BL0OCvfwAJC5H0(?v) ze?`(A_KmH$x8 z7n0HX?~RKq7E85l(xe_}mUz`*!L1SU_{kG3?-por#0^4fXKWltX(Ggq69TVl=c>Ja zd=;_)ifN$vFMzc_twhmxjnw+{=a2PR8TV{a&k>1()e-M_y3$%@kSp{Az&@XzA5lJdo#2ukn=w)i!!5#Uy>z?vgKwr+ z8+wCLqSWMrbc-}uwqh@7!U&2Ob1)5ZS1~m-Eaa*K9mP671fD7=k$y{cw9wRPye3ZH z-;-WDZi7Ej6N#0~hYY3gu!27>w9>U*UK4vnr8)ZNgcmD66SggS8*FTGa&)YPKndI{ zGsSEP{x&S80}dA&bjVklc>P9AY|f+%d95(`8Npsf_BWPix1q2LttfMHDnA?vn23;E z;P0Q~rhd|O&~Oug3kxV1_KGmTInnvxMDj*FR<**=i@YB2t-`kGXGySylT@klz}%?H z=O;S5*PoqnGUU%6R0(2E8+_V3Iu@pj4$o~at@R%j^c;TXY5y+Qe7Ifl^U$j*hroGC zp+A58NQKH}l{cs_f9te;nrG1ar(D>lJF3>NZfUPm)Za9;3%4ls?_Z|Y{>xUtHEH!g zT6=V=aiI)*@QZLK*RXGPOP<}M$!)5P=dz`;OPq(4Zug=-dLDMfIxWTWejw+pr;p7hBp;f+*f-5i#Q?&3z%Uy}PYeD3mnJ%-XK@EdToTYZvvh zyLT0<5NYeqjlwDn=8vgqf}viLx#?})nMYCnL*LuN(22+t!&DG`Ma#{9+m|$5+cD_I znNbyID{?D3ypG&cZ*#9xkGDOv)@nQ)(aU~X*m+IckU1~=tK6P_A!Yrgs~2=mU(1^L zVAe(L@cmZ4I|EEiedM(U)+LTP(rHZHx3;bK3Qs?11CjWa=}N6`~s2@cC8stsVa7 zZfHMiowxk;H0y^{V$F}w1}ZXOX=#*Dl>F~`ay9m`#YD2Bmj5mV{khbf-2OG_efu?o zfrfEqqId^8- zrkj~T`uF4&!}ax7|1QT(PH-z%DoU0NdUWDpgMV7OWO&uD$f`WFl=~+QW<2KRpsp?% zAqDfb6{JOs`qM*_gJKJbU{yJUtd-%N+jr@<; zT02TK4rq9K{m1BG`uYV+4O1B$MRxM)H6)dKOzDx7lnx6r4?4Ej4<4?oYe~fEqrd6L zM{`a2`BM&q$rpNhia9{B+d(WQdLKBRtX;A=+gT^};Gsj?-G<$Qw()6rFgV?1D7a>= z!d)Wjb{J#%o1S}3os}cIie1oOWxPf{Kib@BI3s{(H`nU^y}?W$E#qHDI&5W(uaWP@ z2C8kv@64Ho!dVfbOh!Sv%lp@qnK0z*rd4#wb zwii@S)QH~77ElpduY0=YEXj6fR=n+1WS57xqOHx6ivghwD3_M6guzL3?O$P*_3sEo zTiOn}k)B)be8pe9v)BSgj>@rDpEBH{j~KF{dVb4kYcILbEw6H}d{}Uoo@Hfa9~}+N z%=S?EQJdYY`>M{0W!?$c%38iKRzb3J!rQ3;K3n8Vyk_z-0xEt@F7q0hkTlx&;nBK98#f+` z7!&VstM#B)w8f-*@GpF+RA=okc1e2&o^ZWgiYQ?Hm)+Q?NSNtgp{;z%V=}rGFO+CMGmhk$3dG-1Ec}~){PM(!T#is@J zwQ^+-Qr3=AFAEAb_OiF?|9Ur}ZQmfACs zQMkYWC0SDOY7d-D=Xc6lk`&NYBf7r#!^_LYW7~l92O`r~jve1PPjR_qoi-Ka7T1CX z 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 z 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

4n* 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�JwQuBXV$xqBXApJeaT_#_s%O0XxD zBJ4A(tLHk2mBDwG03O9h!yLSMZ1-*l*I5V032H<(E2iR4*-^q_6NJ|GY!0}&m0q~^ zLI5lI_*6?MX2O4aG327%dxqkXLe+QP!ag(Yk<%{uXWt_)#P?uQ!xp;WI|ff@fX|e$ zN)qHi)037aJXjS$1k}p`BtS}RTeRXzcYm~aq^Y(=B11K5*~ zgm%gSWmrX*k5zKeNo))87D5@p0&;f|cD6*KHEU+cr%&S%d_jtl(WZiEkSZD>8%>R{ z)NCQrIN@7yOKxG<6$bR^FH{-9t=*9!E_XkE;zSFmc@!`kLgn}^Q?i&Oknhl;rkKzP zLFqbhU|^%Y)Y+|&lU=qBkU!!B{;2|=HBr~Mne*Js)}rTwHXCz>-q|_h_-1ksaLYFE z?GQ5MXKjJ7KJ+(Qw{3bwdkjR$c?~ z3yK6@CcY1_ZRD=7(z|X|v$h|n6)qXKIjvYOUNIY}oi}1s>PoK{@rcA}0L$dED-NmKm zJklc5ruWrf`SP01!;MJ zdT$(Z(sW9@;_s0M&el*7@ygq=AP_H4{cAVB;!YitKseisnkM3q>K4a6K z|9nF;oAuM%*8kwJiK68$$=x{3Y_xVrdgGF1s37>Ex4eUG_@c&GC0ydZEiw7}+|g!( zg-KR}*mM5%pXI32dXIV9AtzOGA2Tc9ye8TFbl&_mP ztl`ODGUN@rT*XTqOJ1D9mo^lR`@L$=PCw;)zsmpoBnD800;GgiIka@?`t_kL8fm4v z*=%T))d{u~F_z3k3JWVas4e*+nd+Db8Zumm4-wjV)5n_oeoFZ$p|cO);43>j*<}?( zNlrJ?4)$BY)#xU}D)#>T!0SV2h0Z&(3{qDl39_1GF9ITyY^G~-iw|mdk_*oJSlwB( z!rHyqv{D_IPr>KoZeXzua2%Ip}5HL7BxtuL5K- z4%G|rxA+jNzJ2S)Zvbgz7>+n)va0Z>9T zhPsiZ8ujS{`5K5fz>mqSpHOiG=mE3=Ug6hskLOpcv$nKEX)Q9^h1;)p>C%M`emp=4 zI)OnOPUih}?OHpOFXF6Yi}9(b4Q(U<*4)dK6pa+9A85Jxwk%~_-yC&l zi%?JePA+`>BlD<%Q3FkxT zjh9`J4kW~_=LZ4!kbg-I48qob{|IvcN&xmnxy^Ki1`3XG#8&i%{MW2HPJ(TqtZ`wf zZy)0}HP;ywSuhk*)0iL5d;%a@=0}K?l18&rCsp`mrpu`A}#S=I7X`s<6h`jbHETmikVq ztlS!}j2?DW_!fyJAPs3Ng9RxjC=miZf|EV}vxE44c)nD{7bI={*aD-UJFa%fhgt6V z%Wu;+Q6-ze^oCZ<7})QB^CeE!8il35{~x<`!1kYetDIEz2bG)^yfhocKCC|l*q4W@ ztUptKL~u+vHxd!bt$C|%iW-a&8bWk}v#~k#cuRuCc%-VrK5}a*WrAW<2_ldFR&j zG;ec#>NbW#j4}*7pG<&skSPz{{r=aF;ANA4Kkc&h<9POu4L;B)>38nrBXEpDr|p7+ z#}1COk|KD4zxjwkvNjHdgnU{nK@k0oh+0e+y!y{42K$kWOg|o|z?-01J&}iDF zr_o&sL+8gBDYDt>jRRwbhZRA)M2RCS8yPTvj#@h>*M}@kBPz2`T={%B-IM{gS9}>k zSjY*r5^0duz>y=5fxUNJN6^l|!Gre^OQU%+FE1~Xs3CWZ-Ad4_NC0gQB<0`b2F&jA zwsFwxuq==5+qY-KrbiJ6@`(olUqfLe(uv689u)IlPyjihC8c>~Pd6Q>29Sr)khih1FQki2!flY^#Sw)PMvsduYiBAIIQlE3)9$B~f(Q#>NDq3lm{%D!lIDUuKB zD#vQ?HvW;!$+mm4p6sQ*4!mWyxw`4tx{}o+jL*(Wy9xVd<7#Q9b=f+-LB7aV!=!ic z`WT)XS3Z=jT1h8{ z0nTFCF%e)BBVq@jmg=TeNz>&4)a;U4MaHQcZmZ~f6IO4^FnqY_7>dxAigU9r{2PJj z@)Ls=>M}Cznx}fQqq;#G*YJNUvOIcbxfdjD&qi{bV;}P@wCIL1aPk*BiAaxP$G%Y# z{pH!bC_eszCsibfTKN)1Mcc>Hdp8W$kkA%}!&ayUe)M|`d#OO;w%Y3l2fc^{;TcL< zlY~6^=8hzv0n#96?hB!^DB+-T%rufBZ5PazXDm@{{xJU3$XfOyk&b{CcJxwAQ66R$@-H~t#!a>hME1k&do zdrr%Xp#frPE#pCFG@P8o$n!=QFWLC5&}8VkG|FtwQG>zMs_W4rW% zD}zt0x|bX_AHC13?;Ig=cV6iZ9Qf(@#w(Rn)&tY4eEN{6D%d{o@!Mw~Db{tyF;It; z3-J<2rBn6@*4_Wh7!OfY{BHL zGH;L6vl~J;Wew+sQ_s%2uzIZH8>AJJGkDmxLEnSk+$sL@y(Io*Z3g-ea6>T}@c2R< z)jUbA`4!;cVIE2kDcMn7N&5NjstM~ZOJpK}i@!`d8(jZgKXhRqM|8WT&*xEjC-|4A(A-TV|pIx3Ob~ovcTwigo8Y&=c6%tnT z^ZvC4zaTOnYefV8jJ}Mj$>o(=d|=kg!51z2iU(ZM?FL2WL~I)H;_$$V&T7q@W7=Hf z#U|g1DmN?p%i<=Gu}SRE*|AfUj&umPKgKeD#dn3$$geHab1?SZ@TyR{o2qM@K)byx z`Ef$UrX->!G^%ixsZIQf&I=ag1WJL|0p_;0hhIey!q-Pn&n;#xX--y>+oogq$FYz^ z=bSO~`_N3ybE_nMef;>bBd z1QvV(rOajsPbND@j8#Ls`(ny|O&n1q=Xlh?>n+vPT-kpyG-Y$@-639N3!$cOnUb57 zlA7v)YWeiWD_0*fF2W5$HXCJLWPX1BrHfj1hVkE8(BzK_#a1yAg`1-TyRI*v0ofIaNyRQ%hoSb>uaNsqR4J(iX&5Q zU!L>N+vAxFnrD@Lf7>!J#vs#5*SPdn+v%=k;}?lH*WNvUt`eKx8#a1l>LUhi1X4ph zKdAj^X9L$SAY-XP^hyuVUKiBTvf4CSw@$ptpi;A^-Q2a-6%_vF;!>ND z*Y|NgJYgXtMluo8xDoR+dFs?6iCZ;vEL?7ufmX`}b+R4cr_>TDw{E$TP;TVhdPBs5 z%#s*qo>TJ5fK)yRxwi_iK^|sXcJosPf9V10u_nPje)$g|^#1_;*!?4U5j1cQ4{Gn|Ontg_bXVWeT|0h$Ud!a! z2hL2n*^@qE6d00DVCq3_$$rTEMrW$wT}QTgDzM->yUW$Fs288)efTcUJc;_|&ZnF7 zl^<$Y4=~vsq&u(n0=8MtHD_+i zRF!H8iZhYU`3HHvd@NV5sRIw4Y#S0ILlf@w2+GH6Hi2aU1|f);Oz8^o`m~w$5z_24 zRaDGI8?roEa1?AZEJuV>Btlfwk~+ErAsH?qXdOJmB>FCR^4V_JMuEcS)Hha2=M)4D zzHHcpF4yx$VVl16%XUv6Aj7XPat|g_R%Uu(|51nsTg(G!zeL?Gp$$kZWYk!~-qaPS zKqD!J1iX>x79s-T&R@wt`@AXN@1UQ0rhMJMF+ATUmZ!NwG_#-Y_6rjyCM!sxkhVzz z2v~js>erN?tQ*VbAxrONs#nQPKRO0o_JG=>wpYV4^IB$r zLq9e=F>T#U$LMz7^8qi*O2@O}GBZ_Cf8MAg4ALbm{Gtg#914(InP`BuH@o&}R+i)4 z1A0i0WcqSkZS@L(xB@nkTR+JSlh=di|Ge;?KQDZ=E?pTlr@&r|7X9(c48^;`e8)3D zq0jK}r{Mb`d^!E%&X+{}{3KU@ev&o!rB(sg5iP2uh4LLlxajpMswsFGqde9FL`%3h z)$1a{*aco9#(QUc;jE8aL4NNh_uc|RY?no$rv-H@)s_Uaz-CkZaRN03P@#noC7X!VnHvp{uP|et$H15F`3XYxpuGXcr$VHNmSMu^kBZ+Za?#vE$_# zq|F;koRN;8#BZt)^Yh7*d5gRB;8&_|{ht9|osdGo4tV{a;Di64twZ36{{^3@)7dby Wk4<|G(>)4a6UR*(8#-p;mj43S2F&mP literal 0 HcmV?d00001 diff --git a/doc/how_to/index.rst b/doc/how_to/index.rst index 54fd404848..66dd9b417c 100644 --- a/doc/how_to/index.rst +++ b/doc/how_to/index.rst @@ -13,3 +13,4 @@ Guides on how to solve specific, short problems in SpikeInterface. Learn how to. combine_recordings process_by_channel_group load_your_data_into_sorting + benchmark_with_hybrid_recordings diff --git a/examples/how_to/README.md b/examples/how_to/README.md index af17859ca7..01f11a7a28 100644 --- a/examples/how_to/README.md +++ b/examples/how_to/README.md @@ -14,17 +14,21 @@ with `nbconvert`. Here are the steps (in this example for the `get_started`): ``` >>> jupytext --to notebook get_started.py +>>> jupytext --set-formats ipynb,py get_started.ipynb ``` 2. Run the notebook +3. Sync the run notebook to the .py file: -3. Convert the notebook to .rst +``` +>>> jupytext --sync get_started.ipynb +``` + +4. Convert the notebook to .rst ``` >>> jupyter nbconvert get_started.ipynb --to rst ->>> jupyter nbconvert analyse_neuropixels.ipynb --to rst ``` - -4. Move the .rst and associated folder (e.g. `get_started.rst` and `get_started_files` folder) to the `doc/how_to`. +5. Move the .rst and associated folder (e.g. `get_started.rst` and `get_started_files` folder) to the `doc/how_to`. diff --git a/examples/how_to/analyse_neuropixels.py b/examples/how_to/analyze_neuropixels.py similarity index 99% rename from examples/how_to/analyse_neuropixels.py rename to examples/how_to/analyze_neuropixels.py index ce5bacdda0..aeee8b15b4 100644 --- a/examples/how_to/analyse_neuropixels.py +++ b/examples/how_to/analyze_neuropixels.py @@ -14,7 +14,7 @@ # name: python3 # --- -# # Analyse Neuropixels datasets +# # Analyze Neuropixels datasets # # This example shows how to perform Neuropixels-specific analysis, including custom pre- and post-processing. diff --git a/examples/how_to/benchmark_with_hybrid_recordings.py b/examples/how_to/benchmark_with_hybrid_recordings.py new file mode 100644 index 0000000000..5507ab7a7f --- /dev/null +++ b/examples/how_to/benchmark_with_hybrid_recordings.py @@ -0,0 +1,293 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# formats: ipynb,py +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.16.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# # Benchmark spike sorting with hybrid recordings +# +# This example shows how to use the SpikeInterface hybrid recordings framework to benchmark spike sorting results. +# +# Hybrid recordings are built from existing recordings by injecting units with known spiking activity. +# The template (aka average waveforms) of the injected units can be from previous spike sorted data. +# In this example, we will be using an open database of templates that we have constructed from the International Brain Laboratory - Brain Wide Map (available on [DANDI](https://dandiarchive.org/dandiset/000409?search=IBL&page=2&sortOption=0&sortDir=-1&showDrafts=true&showEmpty=false&pos=9)). +# +# Importantly, recordings from long-shank probes, such as Neuropixels, usually experience drifts. Such drifts have to be taken into account in order to smoothly inject spikes into the recording. + +# + +import spikeinterface as si +import spikeinterface.extractors as se +import spikeinterface.preprocessing as spre +import spikeinterface.comparison as sc +import spikeinterface.generation as sgen +import spikeinterface.widgets as sw + +from spikeinterface.sortingcomponents.motion_estimation import estimate_motion + +import numpy as np +import matplotlib.pyplot as plt +from pathlib import Path +# - + +# %matplotlib inline + +si.set_global_job_kwargs(n_jobs=16) + +# For this notebook, we will use a drifting recording similar to the one acquired by Nick Steinmetz and available [here](https://doi.org/10.6084/m9.figshare.14024495.v1), where an triangular motion was imposed to the recording by moving the probe up and down with a micro-manipulator. + +workdir = Path("/ssd980/working/hybrid/steinmetz_imposed_motion") +workdir.mkdir(exist_ok=True) + +recording_np1_imposed = se.read_spikeglx("/hdd1/data/spikeglx/nick-steinmetz/dataset1/p1_g0_t0/") +recording_preproc = spre.highpass_filter(recording_np1_imposed) +recording_preproc = spre.common_reference(recording_preproc) + +# To visualize the drift, we can estimate the motion and plot it: + +# to correct for drift, we need a float dtype +recording_preproc = spre.astype(recording_preproc, "float") +_, motion_info = spre.correct_motion( + recording_preproc, preset="nonrigid_fast_and_accurate", n_jobs=4, progress_bar=True, output_motion_info=True +) + +ax = sw.plot_drift_raster_map( + peaks=motion_info["peaks"], + peak_locations=motion_info["peak_locations"], + recording=recording_preproc, + cmap="Greys_r", + scatter_decimate=10, + depth_lim=(-10, 3000) +) + +# ## Retrieve templates from database + +# + +templates_info = sgen.fetch_templates_database_info() + +print(f"Number of templates in database: {len(templates_info)}") +print(f"Template database columns: {templates_info.columns}") +# - + +available_brain_areas = np.unique(templates_info.brain_area) +print(f"Available brain areas: {available_brain_areas}") + +# Let's perform a query: templates from visual brain regions and at the "top" of the probe + +target_area = ["VISa5", "VISa6a", "VISp5", "VISp6a", "VISrl6b"] +minimum_depth = 1500 +templates_selected_info = templates_info.query(f"brain_area in {target_area} and depth_along_probe > {minimum_depth}") +len(templates_selected_info) + +# We can now retrieve the selected templates as a `Templates` object: + +templates_selected = sgen.query_templates_from_database(templates_selected_info, verbose=True) +print(templates_selected) + +# While we selected templates from a target aread and at certain depths, we can see that the template amplitudes are quite large. This will make spike sorting easy... we can further manipulate the `Templates` by rescaling, relocating, or further selections with the `sgen.scale_template_to_range`, `sgen.relocate_templates`, and `sgen.select_templates` functions. +# +# In our case, let's rescale the amplitudes between 50 and 150 $\mu$V and relocate them towards the bottom half of the probe, where the activity looks interesting! + +# + +min_amplitude = 50 +max_amplitude = 150 +templates_scaled = sgen.scale_template_to_range( + templates=templates_selected, + min_amplitude=min_amplitude, + max_amplitude=max_amplitude +) + +min_displacement = 1000 +max_displacement = 3000 +templates_relocated = sgen.relocate_templates( + templates=templates_scaled, + min_displacement=min_displacement, + max_displacement=max_displacement +) +# - + +# Let's plot the selected templates: + +sparsity_plot = si.compute_sparsity(templates_relocated) +fig = plt.figure(figsize=(10, 10)) +w = sw.plot_unit_templates(templates_relocated, sparsity=sparsity_plot, ncols=4, figure=fig) +w.figure.subplots_adjust(wspace=0.5, hspace=0.7) + +# ## Constructing hybrid recordings +# +# We can construct now hybrid recordings with the selected templates. +# +# We will do this in two ways to show how important it is to account for drifts when injecting hybrid spikes. +# +# - For the first recording we will not pass the estimated motion (`recording_hybrid_ignore_drift`). +# - For the second recording, we will pass and account for the estimated motion (`recording_hybrid_with_drift`). + +recording_hybrid_ignore_drift, sorting_hybrid = sgen.generate_hybrid_recording( + recording=recording_preproc, templates=templates_relocated, seed=2308 +) +recording_hybrid_ignore_drift + +# Note that the `generate_hybrid_recording` is warning us that we might want to account for drift! + +# by passing the `sorting_hybrid` object, we make sure that injected spikes are the same +# this will take a bit more time because it's interpolating the templates to account for drifts +recording_hybrid_with_drift, sorting_hybrid = sgen.generate_hybrid_recording( + recording=recording_preproc, + templates=templates_relocated, + motion=motion_info["motion"], + sorting=sorting_hybrid, + seed=2308, +) +recording_hybrid_with_drift + +# We can use the `SortingAnalyzer` to estimate spike locations and plot them: + +# + +# construct analyzers and compute spike locations +analyzer_hybrid_ignore_drift = si.create_sorting_analyzer(sorting_hybrid, recording_hybrid_ignore_drift) +analyzer_hybrid_ignore_drift.compute(["random_spikes", "templates"]) +analyzer_hybrid_ignore_drift.compute("spike_locations", method="grid_convolution") + +analyzer_hybrid_with_drift = si.create_sorting_analyzer(sorting_hybrid, recording_hybrid_with_drift) +analyzer_hybrid_with_drift.compute(["random_spikes", "templates"]) +analyzer_hybrid_with_drift.compute("spike_locations", method="grid_convolution") +# - + +# Let's plot the added hybrid spikes using the drift maps: + +fig, axs = plt.subplots(ncols=2, figsize=(10, 7), sharex=True, sharey=True) +_ = sw.plot_drift_raster_map( + peaks=motion_info["peaks"], + peak_locations=motion_info["peak_locations"], + recording=recording_preproc, + cmap="Greys_r", + scatter_decimate=10, + ax=axs[0], +) +_ = sw.plot_drift_raster_map( + sorting_analyzer=analyzer_hybrid_ignore_drift, + color_amplitude=False, + color="r", + scatter_decimate=10, + ax=axs[0] +) +_ = sw.plot_drift_raster_map( + peaks=motion_info["peaks"], + peak_locations=motion_info["peak_locations"], + recording=recording_preproc, + cmap="Greys_r", + scatter_decimate=10, + ax=axs[1], +) +_ = sw.plot_drift_raster_map( + sorting_analyzer=analyzer_hybrid_with_drift, + color_amplitude=False, + color="b", + scatter_decimate=10, + ax=axs[1] +) +axs[0].set_title("Hybrid spikes\nIgnoring drift") +axs[1].set_title("Hybrid spikes\nAccounting for drift") +axs[0].set_xlim(1000, 1500) +axs[0].set_ylim(500, 2500) + +# We can see that clearly following drift is essential in order to properly blend the hybrid spikes into the recording! + +# ## Ground-truth study +# +# In this section we will use the hybrid recording to benchmark a few spike sorters: +# +# - `Kilosort2.5` +# - `Kilosort3` +# - `Kilosort4` +# - `Spyking-CIRCUS 2` + +# to speed up computations, let's first dump the recording to binary +recording_hybrid_bin = recording_hybrid_with_drift.save( + folder=workdir / "hybrid_bin", + overwrite=True +) + +# + +datasets = { + "hybrid": (recording_hybrid_bin, sorting_hybrid), +} + +cases = { + ("kilosort2.5", "hybrid"): { + "label": "KS2.5", + "dataset": "hybrid", + "run_sorter_params": { + "sorter_name": "kilosort2_5", + }, + }, + ("kilosort3", "hybrid"): { + "label": "KS3", + "dataset": "hybrid", + "run_sorter_params": { + "sorter_name": "kilosort3", + }, + }, + ("kilosort4", "hybrid"): { + "label": "KS4", + "dataset": "hybrid", + "run_sorter_params": {"sorter_name": "kilosort4", "nblocks": 5}, + }, + ("sc2", "hybrid"): { + "label": "spykingcircus2", + "dataset": "hybrid", + "run_sorter_params": { + "sorter_name": "spykingcircus2", + }, + }, +} + +# + +study_folder = workdir / "gt_study" + +gtstudy = sc.GroundTruthStudy(study_folder) + +# - + +# run the spike sorting jobs +gtstudy.run_sorters(verbose=False, keep=True) + +# run the comparisons +gtstudy.run_comparisons(exhaustive_gt=False) + +# ## Plot performances +# +# Given that we know the exactly where we injected the hybrid spikes, we can now compute and plot performance metrics: accuracy, precision, and recall. +# +# In the following plot, the x axis is the unit index, while the y axis is the performance metric. The units are sorted by performance. + +w_perf = sw.plot_study_performances(gtstudy, figsize=(12, 7)) +w_perf.axes[0, 0].legend(loc=4) + +# From the performance plots, we can see that there is no clear "winner", but `Kilosort3` definitely performs worse than the other options. +# +# Although non of the sorters find all units perfectly, `Kilosort2.5`, `Kilosort4`, and `SpyKING CIRCUS 2` all find around 10-12 hybrid units with accuracy greater than 80%. +# `Kilosort4` has a better overall curve, being able to find almost all units with an accuracy above 50%. `Kilosort2.5` performs well when looking at precision (finding all spikes in a hybrid unit), at the cost of lower recall (finding spikes when it shouldn't). +# +# +# In this example, we showed how to: +# +# - Access and fetch templates from the SpikeInterface template database +# - Manipulate templates (scaling/relocating) +# - Construct hybrid recordings accounting for drifts +# - Use the `GroundTruthStudy` to benchmark different sorters +# +# The hybrid framework can be extended to target multiple recordings from different brain regions and species and creating recordings of increasing complexity to challenge the existing sorters! +# +# In addition, hybrid studies can also be used to fine-tune spike sorting parameters on specific datasets. +# +# **Are you ready to try it on your data?** diff --git a/pyproject.toml b/pyproject.toml index 69f4067d13..644d52608e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,9 @@ test = [ # preprocessing "ibllib>=2.36.0", # for IBL + # streaming templates + "s3fs", + # tridesclous "numba", "hdbscan>=0.8.33", # Previous version had a broken wheel diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 066ab58d8c..4a40d8e425 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -75,6 +75,7 @@ class SIJsonEncoder(json.JSONEncoder): def default(self, obj): from spikeinterface.core.base import BaseExtractor + from spikeinterface.sortingcomponents.motion_utils import Motion # Over-write behaviors for datetime object if isinstance(obj, datetime.datetime): @@ -98,6 +99,9 @@ def default(self, obj): if isinstance(obj, BaseExtractor): return obj.to_dict() + if isinstance(obj, Motion): + return obj.to_dict() + # The base-class handles the assertion return super().default(obj) diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index 9924a22403..af6664b886 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -3,7 +3,6 @@ import warnings import numpy as np from typing import Union, Optional, List, Literal -import warnings from math import ceil from .basesorting import SpikeVectorSortingSegment @@ -1858,7 +1857,7 @@ def get_traces( wf = template[start_template:end_template] if self.amplitude_vector is not None: wf = wf * self.amplitude_vector[i] - traces[start_traces:end_traces] += wf + traces[start_traces:end_traces] += wf.astype(traces.dtype, copy=False) return traces.astype(self.dtype, copy=False) diff --git a/src/spikeinterface/core/node_pipeline.py b/src/spikeinterface/core/node_pipeline.py index 0722ede23f..ceff8577d3 100644 --- a/src/spikeinterface/core/node_pipeline.py +++ b/src/spikeinterface/core/node_pipeline.py @@ -516,7 +516,6 @@ def _init_peak_pipeline(recording, nodes): worker_ctx["recording"] = recording worker_ctx["nodes"] = nodes worker_ctx["max_margin"] = max(node.get_trace_margin() for node in nodes) - return worker_ctx diff --git a/src/spikeinterface/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py index d790308b76..fc20029ce6 100644 --- a/src/spikeinterface/core/sortinganalyzer.py +++ b/src/spikeinterface/core/sortinganalyzer.py @@ -970,7 +970,9 @@ def compute_one_extension(self, extension_name, save=True, verbose=False, **kwar extension_class = get_extension_class(extension_name) for child in _get_children_dependencies(extension_name): - self.delete_extension(child) + if self.has_extension(child): + print(f"Deleting {child}") + self.delete_extension(child) if extension_class.need_job_kwargs: params, job_kwargs = split_job_kwargs(kwargs) diff --git a/src/spikeinterface/core/template.py b/src/spikeinterface/core/template.py index 066d79b6b4..b64f0610ea 100644 --- a/src/spikeinterface/core/template.py +++ b/src/spikeinterface/core/template.py @@ -353,9 +353,9 @@ def from_zarr_group(cls, zarr_group: "zarr.Group") -> "Templates": the `add_templates_to_zarr_group` method. """ - templates_array = zarr_group["templates_array"] - channel_ids = zarr_group["channel_ids"] - unit_ids = zarr_group["unit_ids"] + templates_array = zarr_group["templates_array"][:] + channel_ids = zarr_group["channel_ids"][:] + unit_ids = zarr_group["unit_ids"][:] sampling_frequency = zarr_group.attrs["sampling_frequency"] nbefore = zarr_group.attrs["nbefore"] @@ -364,7 +364,7 @@ def from_zarr_group(cls, zarr_group: "zarr.Group") -> "Templates": sparsity_mask = None if "sparsity_mask" in zarr_group: - sparsity_mask = zarr_group["sparsity_mask"] + sparsity_mask = zarr_group["sparsity_mask"][:] probe = None if "probe" in zarr_group: @@ -449,7 +449,7 @@ def __eq__(self, other): return True - def get_channel_locations(self): + def get_channel_locations(self) -> np.ndarray: assert self.probe is not None, "Templates.get_channel_locations() needs a probe to be set" channel_locations = self.probe.contact_positions return channel_locations diff --git a/src/spikeinterface/core/template_tools.py b/src/spikeinterface/core/template_tools.py index 1ba9372322..934b18ed49 100644 --- a/src/spikeinterface/core/template_tools.py +++ b/src/spikeinterface/core/template_tools.py @@ -3,7 +3,6 @@ import warnings from .template import Templates -from .sparsity import _sparsity_doc from .sortinganalyzer import SortingAnalyzer @@ -50,7 +49,7 @@ def _get_nbefore(one_object): raise ValueError("SortingAnalyzer need extension 'templates' to be computed") return ext.nbefore else: - raise ValueError("Input should be Templates or SortingAnalyzer or SortingAnalyzer") + raise ValueError("Input should be Templates or SortingAnalyzer") def get_template_amplitudes( diff --git a/src/spikeinterface/generation/__init__.py b/src/spikeinterface/generation/__init__.py index eae6320e8d..7a2291d932 100644 --- a/src/spikeinterface/generation/__init__.py +++ b/src/spikeinterface/generation/__init__.py @@ -5,6 +5,14 @@ InjectDriftingTemplatesRecording, make_linear_displacement, ) + +from .hybrid_tools import ( + generate_hybrid_recording, + estimate_templates_from_recording, + select_templates, + scale_template_to_range, + relocate_templates, +) from .noise_tools import generate_noise from .drifting_generator import ( make_one_displacement_vector, diff --git a/src/spikeinterface/generation/drift_tools.py b/src/spikeinterface/generation/drift_tools.py index 99e4f4d36e..1f410f4330 100644 --- a/src/spikeinterface/generation/drift_tools.py +++ b/src/spikeinterface/generation/drift_tools.py @@ -1,10 +1,12 @@ from __future__ import annotations + +import math from typing import Optional import numpy as np from numpy.typing import ArrayLike -from spikeinterface.core import Templates, BaseRecording, BaseSorting, BaseRecordingSegment -import math +from probeinterface import Probe +from spikeinterface.core import BaseRecording, BaseRecordingSegment, BaseSorting, Templates def interpolate_templates(templates_array, source_locations, dest_locations, interpolation_method="cubic"): @@ -116,22 +118,80 @@ class DriftingTemplates(Templates): This is the same strategy used by MEArec. """ - def __init__(self, **kwargs): - Templates.__init__(self, **kwargs) + def __init__(self, templates_array_moved=None, displacements=None, **static_kwargs): + Templates.__init__(self, **static_kwargs) assert self.probe is not None, "DriftingTemplates need a Probe in the init" - - self.templates_array_moved = None - self.displacements = None + if templates_array_moved is not None: + if displacements is None: + raise ValueError( + "Please pass both template_array_moved and displacements to DriftingTemplates " + "if you are using precomputed displaced templates." + ) + self.templates_array_moved = templates_array_moved + self.displacements = displacements @classmethod - def from_static(cls, templates): - drifting_teplates = cls( + def from_static_templates(cls, templates: Templates): + """ + Construct a DriftingTemplates object given static templates. + The drifting templates can be then computed using the `precompute_displacements` method. + + Parameters + ---------- + templates : Templates + The static templates. + + Returns + ------- + drifting_templates : DriftingTemplates + The drifting templates object. + + """ + drifting_templates = cls( templates_array=templates.templates_array, sampling_frequency=templates.sampling_frequency, nbefore=templates.nbefore, probe=templates.probe, ) - return drifting_teplates + return drifting_templates + + @classmethod + def from_precomputed_templates( + cls, + templates_array_moved: ArrayLike, + displacements: ArrayLike, + sampling_frequency: float, + nbefore: int, + probe: Probe, + ): + """Construct a DriftingTemplates object given precomputed drifting templates + + Parameters + ---------- + templates_array_moved : np.array + Shape is (num_displacement, num_templates, num_samples, num_channels) + displacements : np.array + Shape is (num_displacement, 2). Last axis is xy, as in make_linear_displacement below. + sampling_frequency : float + nbefore : int + probe : probeinterface.Probe + + Returns + ------- + drifting_templates : DriftingTemplates + The drifting templates object. + """ + # take the central templates as representatives, just to make the super() + # constructor happy. they won't be used as drifting templates. + templates_static = templates_array_moved[templates_array_moved.shape[0] // 2] + return cls( + templates_array=templates_static, + sampling_frequency=sampling_frequency, + nbefore=nbefore, + probe=probe, + templates_array_moved=templates_array_moved, + displacements=displacements, + ) def move_one_template(self, unit_index, displacement, **interpolation_kwargs): """ @@ -442,7 +502,8 @@ def __init__( # TODO: self.upsample_vector = upsample_vector self.upsample_vector = None self.parent_recording = parent_recording_segment - self.num_samples = parent_recording_segment.get_num_frames() if num_samples is None else num_samples + self.num_samples = parent_recording_segment.get_num_samples() if num_samples is None else num_samples + self.num_samples = int(num_samples) self.displacement_indices = displacement_indices self.templates_array_moved = templates_array_moved @@ -507,7 +568,7 @@ def get_traces( wf = template[start_template:end_template] if self.amplitude_vector is not None: wf *= self.amplitude_vector[i] - traces[start_traces:end_traces] += wf + traces[start_traces:end_traces] += wf.astype(self.dtype, copy=False) return traces.astype(self.dtype) diff --git a/src/spikeinterface/generation/drifting_generator.py b/src/spikeinterface/generation/drifting_generator.py index 7f617c3ade..a0e8ece37e 100644 --- a/src/spikeinterface/generation/drifting_generator.py +++ b/src/spikeinterface/generation/drifting_generator.py @@ -25,12 +25,18 @@ # this should be moved in probeinterface but later _toy_probes = { + "Neuropixel-384": dict( + num_columns=4, + num_contact_per_column=[96] * 4, + xpitch=16, + ypitch=40, + y_shift_per_column=[20, 0, 20, 0], + contact_shapes="square", + contact_shape_params={"width": 12}, + ), "Neuropixel-128": dict( num_columns=4, - num_contact_per_column=[ - 32, - ] - * 4, + num_contact_per_column=[32] * 4, xpitch=16, ypitch=40, y_shift_per_column=[20, 0, 20, 0], @@ -66,22 +72,24 @@ def make_one_displacement_vector( Parameters ---------- - drift_mode: "zigzag" | "bumps", default: "zigzag" - The drift mode - duration: float, default: 600 + drift_mode : "zigzag" | "bumps", default: "zigzag" + The drift mode. + duration : float, default: 600 Duration in seconds - displacement_sampling_frequency: float, default: 5 - Sample rate of the vector - t_start_drift: float | None, default: None - Time in s when drift starts - t_end_drift: float | None, default: None - Time in s when drift ends - period_s: float, default: 200. + amplitude_factor : float, default: 1 + The amplitude factor of the drift. + displacement_sampling_frequency : float, default: 5 + Sample rate of the vector. + t_start_drift : float | None, default: None + Time in s when drift starts. + t_end_drift : float | None, default: None + Time in s when drift ends. + period_s : float, default: 200. Period of the zigzag in seconds - bump_interval_s: tuple, default: (30, 90.) - Range interval between random bumps in seconds - seed: None | int - The seed for the random bumps + bump_interval_s : tuple, default: (30, 90.) + Range interval between random bumps in seconds. + seed : None | int + The seed for the random bumps. Returns ------- @@ -170,34 +178,34 @@ def generate_displacement_vector( Parameters ---------- - duration: float + duration : float Duration of the displacement vector in seconds - unit_locations: np.array + unit_locations : np.array The unit location with shape (num_units, 3) - displacement_sampling_frequency: float, default: 5. + displacement_sampling_frequency : float, default: 5. The sampling frequency of the displacement vector - drift_start_um: list of float, default: [0, 20.] - The start boundary of the motion - drift_stop_um: list of float, default: [0, -20.] - The stop boundary of the motion - drift_step_um: float, default: 1 + drift_start_um : list of float, default: [0, 20.] + The start boundary of the motion in the x and y direction. + drift_stop_um : list of float, default: [0, -20.] + The stop boundary of the motion in the x and y direction. + drift_step_um : float, default: 1 Use to create the displacements_steps array. This ensures an odd number of steps - motion_list: list of dict + motion_list : list of dict List of dicts containing individual motion vector parameters. len(motion_list) == displacement_vectors.shape[2] Returns ------- - displacement_vectors: numpy.ndarray + displacement_vectors : numpy.ndarray The drift vector is a numpy array with shape (num_times, 2, num_motions) num_motions is generally 1, but can be > 1 in case of combining several drift vectors - displacement_unit_factor: numpy array | None, default: None + displacement_unit_factor : numpy array | None, default: None A array containing the factor per unit of each drift (num_units, num_motions). This is used to create non-rigid drift with a factor gradient of depending on the unit positions - displacement_sampling_frequency: float + displacement_sampling_frequency : float The sampling frequency of drift vector - displacements_steps: numpy array + displacements_steps : numpy array Position of the motion steps (from start to step) with shape (num_step, 2) """ @@ -295,38 +303,38 @@ def generate_drifting_recording( Parameters ---------- - num_units: int, default: 250 + num_units : int, default: 250 Number of units. - duration: float, default: 600. + duration : float, default: 600. The duration in seconds. - sampling_frequency: float, dfault: 30000. + sampling_frequency : float, dfault: 30000. The sampling frequency. - probe_name: str, default: "Neuropixel-128" + probe_name : str, default: "Neuropixel-128" The probe type if generate_probe_kwargs is None. - generate_probe_kwargs: None or dict + generate_probe_kwargs : None or dict A dict to generate the probe, this supersede probe_name when not None. - generate_unit_locations_kwargs: dict + generate_unit_locations_kwargs : dict Parameters given to generate_unit_locations(). - generate_displacement_vector_kwargs: dict + generate_displacement_vector_kwargs : dict Parameters given to generate_displacement_vector(). - generate_templates_kwargs: dict + generate_templates_kwargs : dict Parameters given to generate_templates() - generate_sorting_kwargs: dict + generate_sorting_kwargs : dict Parameters given to generate_sorting(). - generate_noise_kwargs: dict + generate_noise_kwargs : dict Parameters given to generate_noise(). - extra_outputs: bool, default False + extra_outputs : bool, default False Return optionaly a dict with more variables. - seed: None ot int + seed : None ot int A unique seed for all steps. Returns ------- - static_recording: Recording + static_recording : Recording A generated recording with no motion. - drifting_recording: Recording + drifting_recording : Recording A generated recording with motion. - sorting: Sorting + sorting : Sorting The ground trith soring object. Same for both recordings. extra_infos: @@ -407,7 +415,7 @@ def generate_drifting_recording( is_scaled=True, ) - drifting_templates = DriftingTemplates.from_static(templates) + drifting_templates = DriftingTemplates.from_static_templates(templates) sorting = generate_sorting( num_units=num_units, diff --git a/src/spikeinterface/generation/hybrid_tools.py b/src/spikeinterface/generation/hybrid_tools.py new file mode 100644 index 0000000000..a57e090f5f --- /dev/null +++ b/src/spikeinterface/generation/hybrid_tools.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import warnings +from typing import Literal +import numpy as np + +from spikeinterface.core import BaseRecording, BaseSorting, Templates + +from spikeinterface.core.generate import ( + generate_templates, + generate_unit_locations, + generate_sorting, + InjectTemplatesRecording, + _ensure_seed, +) +from spikeinterface.core.template_tools import get_template_extremum_channel + +from spikeinterface.sortingcomponents.motion_utils import Motion + +from spikeinterface.generation.drift_tools import ( + InjectDriftingTemplatesRecording, + DriftingTemplates, + make_linear_displacement, + interpolate_templates, + move_dense_templates, +) + + +def estimate_templates_from_recording( + recording: BaseRecording, + ms_before: float = 2, + ms_after: float = 2, + sorter_name: str = "spykingcircus2", + run_sorter_kwargs: dict | None = None, + job_kwargs: dict | None = None, +): + """ + Get dense templates from a recording. Internally, SpyKING CIRCUS 2 is used by default + with the only twist that the template matching step is not launched. Instead, a Template + object is returned based on the results of the clustering. Other sorters can be invoked + with the `sorter_name` and `run_sorter_kwargs` parameters. + + Parameters + ---------- + ms_before : float + The time before peaks of templates. + ms_after : float + The time after peaks of templates. + sorter_name : str + The sorter to be used in order to get some fast clustering. + run_sorter_kwargs : dict + The parameters to provide to the run_sorter function of spikeinterface. + job_kwargs : dict + The jobe keyword arguments to be used in the estimation of the templates. + + Returns + ------- + templates: Templates + The estimated templates + """ + from spikeinterface.core.waveform_tools import estimate_templates + from spikeinterface.sorters.runsorter import run_sorter + + if sorter_name == "spykingcircus2": + if "matching" not in run_sorter_kwargs: + run_sorter_kwargs["matching"] = {"method": None} + + run_sorter_kwargs = run_sorter_kwargs or {} + sorting = run_sorter(sorter_name, recording, **run_sorter_kwargs) + + spikes = sorting.to_spike_vector() + unit_ids = sorting.unit_ids + sampling_frequency = recording.get_sampling_frequency() + nbefore = int(ms_before * sampling_frequency / 1000.0) + nafter = int(ms_after * sampling_frequency / 1000.0) + + job_kwargs = job_kwargs or {} + templates_array = estimate_templates(recording, spikes, unit_ids, nbefore, nafter, **job_kwargs) + + sparsity_mask = None + channel_ids = recording.channel_ids + probe = recording.get_probe() + + templates = Templates( + templates_array, sampling_frequency, nbefore, True, sparsity_mask, channel_ids, unit_ids, probe=probe + ) + + return templates + + +def select_templates( + templates: Templates, + min_amplitude: float | None = None, + max_amplitude: float | None = None, + min_depth: float | None = None, + max_depth: float | None = None, + amplitude_function: Literal["ptp", "min", "max"] = "ptp", + depth_direction: Literal["x", "y"] = "y", +): + """ + Select templates from an existing Templates object based on amplitude and depth. + + Parameters + ---------- + templates : Templates + The input templates. + min_amplitude : float | None, default: None + The minimum amplitude of the templates. + max_amplitude : float | None, default: None + The maximum amplitude of the templates. + min_depth : float | None, default: None + The minimum depth of the templates. + max_depth : float | None, default: None + The maximum depth of the templates. + amplitude_function : "ptp" | "min" | "max", default: "ptp" + The function to use to compute the amplitude of the templates. Can be "ptp", "min" or "max". + depth_direction : "x" | "y", default: "y" + The direction in which to move the templates. Can be "x" or "y". + + Returns + ------- + Templates + The selected templates + """ + assert ( + min_amplitude is not None or max_amplitude is not None or min_depth is not None or max_depth is not None + ), "At least one of min_amplitude, max_amplitude, min_depth, max_depth should be provided" + # get template amplitudes and depth + extremum_channel_indices = list(get_template_extremum_channel(templates, outputs="index").values()) + extremum_channel_indices = np.array(extremum_channel_indices, dtype=int) + + mask = np.ones(templates.num_units, dtype=bool) + if min_amplitude is not None or max_amplitude is not None: + # filter amplitudes + if amplitude_function == "ptp": + amp_fun = np.ptp + elif amplitude_function == "min": + amp_fun = np.min + elif amplitude_function == "max": + amp_fun = np.max + amplitudes = np.zeros(templates.num_units) + templates_array = templates.templates_array + for i in range(templates.num_units): + amplitudes[i] = amp_fun(templates_array[i, :, extremum_channel_indices[i]]) + if min_amplitude is not None: + mask &= amplitudes >= min_amplitude + if max_amplitude is not None: + mask &= amplitudes <= max_amplitude + if min_depth is not None or max_depth is not None: + assert templates.probe is not None, "Templates should have a probe to filter based on depth" + depth_dimension = ["x", "y"].index(depth_direction) + channel_depths = templates.get_channel_locations()[:, depth_dimension] + unit_depths = channel_depths[extremum_channel_indices] + if min_depth is not None: + mask &= unit_depths >= min_depth + if max_depth is not None: + mask &= unit_depths <= max_depth + if np.sum(mask) == 0: + warnings.warn("No templates left after filtering") + return None + filtered_unit_ids = templates.unit_ids[mask] + filtered_templates = templates.select_units(filtered_unit_ids) + + return filtered_templates + + +def scale_template_to_range( + templates: Templates, + min_amplitude: float, + max_amplitude: float, + amplitude_function: Literal["ptp", "min", "max"] = "ptp", +): + """ + Scale templates to have a range with the provided minimum and maximum amplitudes. + + Parameters + ---------- + templates : Templates + The input templates. + min_amplitude : float + The minimum amplitude of the output templates after scaling. + max_amplitude : float + The maximum amplitude of the output templates after scaling. + + Returns + ------- + Templates + The scaled templates. + """ + extremum_channel_indices = list(get_template_extremum_channel(templates, outputs="index").values()) + extremum_channel_indices = np.array(extremum_channel_indices, dtype=int) + + # get amplitudes + if amplitude_function == "ptp": + amp_fun = np.ptp + elif amplitude_function == "min": + amp_fun = np.min + elif amplitude_function == "max": + amp_fun = np.max + amplitudes = np.zeros(templates.num_units) + templates_array = templates.templates_array + for i in range(templates.num_units): + amplitudes[i] = amp_fun(templates_array[i, :, extremum_channel_indices[i]]) + + # scale templates to meet min_amplitude and max_amplitude range + min_scale = np.min(amplitudes) / min_amplitude + max_scale = np.max(amplitudes) / max_amplitude + m = (max_scale - min_scale) / (np.max(amplitudes) - np.min(amplitudes)) + scales = m * (amplitudes - np.min(amplitudes)) + min_scale + + scaled_templates_array = templates.templates_array / scales[:, None, None] + + return Templates( + templates_array=scaled_templates_array, + sampling_frequency=templates.sampling_frequency, + nbefore=templates.nbefore, + sparsity_mask=templates.sparsity_mask, + channel_ids=templates.channel_ids, + unit_ids=templates.unit_ids, + probe=templates.probe, + ) + + +def relocate_templates( + templates: Templates, + min_displacement: float, + max_displacement: float, + margin: float = 0.0, + favor_borders: bool = True, + depth_direction: Literal["x", "y"] = "y", + seed: int | None = None, +): + """ + Relocates templates to have a minimum and maximum displacement. + + Parameters + ---------- + templates : Templates + The input templates + min_displacement : float + The minimum displacement of the templates + max_displacement : float + The maximum displacement of the templates + margin : float, default: 0.0 + The margin to keep between the templates and the borders of the probe. + If greater than 0, the templates are allowed to go beyond the borders of the probe. + favor_borders : bool, default: True + If True, the templates are always moved to the borders of the probe if this is + possoble based on the min_displacement and max_displacement constraints. + This avoids a bias in moving templates towards the center of the probe. + depth_direction : "x" | "y", default: "y" + The direction in which to move the templates. Can be "x" or "y" + seed : int or None, default: None + Seed for random initialization. + + + Returns + ------- + Templates + The relocated templates. + """ + seed = _ensure_seed(seed) + + extremum_channel_indices = list(get_template_extremum_channel(templates, outputs="index").values()) + extremum_channel_indices = np.array(extremum_channel_indices, dtype=int) + depth_dimension = ["x", "y"].index(depth_direction) + channel_depths = templates.get_channel_locations()[:, depth_dimension] + unit_depths = channel_depths[extremum_channel_indices] + + assert margin >= 0, "margin should be positive" + top_margin = np.max(channel_depths) + margin + bottom_margin = np.min(channel_depths) - margin + + templates_array_moved = np.zeros_like(templates.templates_array, dtype=templates.templates_array.dtype) + + rng = np.random.default_rng(seed) + displacements = rng.uniform(low=min_displacement, high=max_displacement, size=templates.num_units) + for i in range(templates.num_units): + # by default, displacement is positive + displacement = displacements[i] + unit_depth = unit_depths[i] + if not favor_borders: + displacement *= rng.choice([-1.0, 1.0]) + if unit_depth + displacement > top_margin: + displacement = -displacement + elif unit_depth - displacement < bottom_margin: + displacement = -displacement + else: + # check if depth is closer to top or bottom + if unit_depth > (top_margin - bottom_margin) / 2: + # if over top margin, move down + if unit_depth + displacement > top_margin: + displacement = -displacement + else: + # if within bottom margin, move down + if unit_depth - displacement >= bottom_margin: + displacement = -displacement + displacement_vector = np.zeros(2) + displacement_vector[depth_dimension] = displacement + templates_array_moved[i] = move_dense_templates( + templates.templates_array[i][None], + displacements=displacement_vector[None], + source_probe=templates.probe, + )[0] + + return Templates( + templates_array=templates_array_moved, + sampling_frequency=templates.sampling_frequency, + nbefore=templates.nbefore, + sparsity_mask=templates.sparsity_mask, + channel_ids=templates.channel_ids, + unit_ids=templates.unit_ids, + probe=templates.probe, + ) + + +def generate_hybrid_recording( + recording: BaseRecording, + sorting: BaseSorting | None = None, + templates: Templates | None = None, + motion: Motion | None = None, + are_templates_scaled: bool = True, + unit_locations: np.ndarray | None = None, + drift_step_um: float = 1.0, + upsample_factor: int | None = None, + upsample_vector: np.ndarray | None = None, + amplitude_std: float = 0.05, + generate_sorting_kwargs: dict = dict(num_units=10, firing_rates=15, refractory_period_ms=4.0, seed=2205), + generate_unit_locations_kwargs: dict = dict(margin_um=10.0, minimum_z=5.0, maximum_z=50.0, minimum_distance=20), + generate_templates_kwargs: dict = dict(ms_before=1.0, ms_after=3.0), + seed: int | None = None, +) -> tuple[BaseRecording, BaseSorting]: + """ + Generate an hybrid recording with spike given sorting+templates. + + The function starts from an existing recording and injects hybrid units in it. + The templates can be provided or generated. If the templates are not provided, + they are generated (using the `spikeinterface.core.generate.generate_templates()` function + and with arguments provided in `generate_templates_kwargs`). + The sorting can be provided or generated. If the sorting is not provided, it is generated + (using the `spikeinterface.core.generate.generate_sorting` function and with arguments + provided in `generate_sorting_kwargs`). + The injected spikes can optionally follow a motion pattern provided by a Motion object. + + Parameters + ---------- + recording : BaseRecording + The recording to inject units in. + sorting : Sorting | None, default: None + An external sorting object. If not provide, one is generated. + templates : Templates | None, default: None + The templates of units. + If None they are generated. + motion : Motion | None, default: None + The motion object to use for the drifting templates. + are_templates_scaled : bool, default: True + If True, the templates are assumed to be in uV, otherwise in the same unit as the recording. + In case the recording has scaling, the templates are "unscaled" before injection. + ms_before : float, default: 1.5 + Cut out in ms before spike peak. + ms_after : float, default: 3 + Cut out in ms after spike peak. + unit_locations : np.array, default: None + The locations at which the templates should be injected. If not provided, generated (see + generate_unit_location_kwargs). + drift_step_um : float, default: 1.0 + The step in um to use for the drifting templates. + upsample_factor : None or int, default: None + A upsampling factor used only when templates are not provided. + upsample_vector : np.array or None + Optional the upsample_vector can given. This has the same shape as spike_vector + amplitude_std : float, default: 0.05 + The standard deviation of the modulation to apply to the spikes when injecting them + into the recording. + generate_sorting_kwargs : dict + When sorting is not provide, this dict is used to generated a Sorting. + generate_unit_locations_kwargs : dict + Dict used to generated template when template not provided. + generate_templates_kwargs : dict + Dict used to generated template when template not provided. + seed : int or None + Seed for random initialization. + If None a diffrent Recording is generated at every call. + Note: even with None a generated recording keep internaly a seed to regenerate the same signal after dump/load. + + Returns + ------- + recording: BaseRecording + The generated hybrid recording extractor. + sorting: Sorting + The generated sorting extractor for the injected units. + """ + + # if None so the same seed will be used for all steps + seed = _ensure_seed(seed) + rng = np.random.default_rng(seed) + + sampling_frequency = recording.sampling_frequency + probe = recording.get_probe() + num_segments = recording.get_num_segments() + dtype = recording.dtype + durations = np.array([recording.get_duration(segment_index) for segment_index in range(num_segments)]) + channel_locations = probe.contact_positions + + assert ( + templates is not None or sorting is not None or generate_sorting_kwargs is not None + ), "Provide templates or sorting or generate_sorting_kwargs" + + # check num_units + num_units = None + if templates is not None: + assert isinstance(templates, Templates), "templates should be a Templates object" + num_units = templates.num_units + if sorting is not None: + assert isinstance(sorting, BaseSorting), "sorting should be a Sorting object" + if num_units is not None: + assert num_units == sorting.get_num_units(), "num_units should be the same in templates and sorting" + else: + num_units = sorting.get_num_units() + if num_units is None: + assert "num_units" in generate_sorting_kwargs, "num_units should be provided in generate_sorting_kwargs" + num_units = generate_sorting_kwargs["num_units"] + else: + generate_sorting_kwargs["num_units"] = num_units + + if templates is None: + if unit_locations is None: + unit_locations = generate_unit_locations(num_units, channel_locations, **generate_unit_locations_kwargs) + else: + assert len(unit_locations) == num_units, "unit_locations and num_units should have the same length" + templates_array = generate_templates( + channel_locations, + unit_locations, + sampling_frequency, + upsample_factor=upsample_factor, + seed=seed, + dtype=dtype, + **generate_templates_kwargs, + ) + ms_before = generate_templates_kwargs["ms_before"] + ms_after = generate_templates_kwargs["ms_after"] + nbefore = int(ms_before * sampling_frequency / 1000.0) + nafter = int(ms_after * sampling_frequency / 1000.0) + templates_ = Templates(templates_array, sampling_frequency, nbefore, True, None, None, None, probe) + else: + from spikeinterface.postprocessing.localization_tools import compute_monopolar_triangulation + + assert isinstance(templates, Templates), "templates should be a Templates object" + assert ( + templates.num_channels == recording.get_num_channels() + ), "templates and recording should have the same number of channels" + nbefore = templates.nbefore + nafter = templates.nafter + unit_locations = compute_monopolar_triangulation(templates) + + channel_locations_rel = channel_locations - channel_locations[0] + templates_locations = templates.get_channel_locations() + templates_locations_rel = templates_locations - templates_locations[0] + + if not np.allclose(channel_locations_rel, templates_locations_rel): + warnings.warn("Channel locations are different between recording and templates. Interpolating templates.") + templates_array = np.zeros(templates.templates_array.shape, dtype=dtype) + for i in range(len(templates_array)): + src_template = templates.templates_array[i][np.newaxis, :, :] + templates_array[i] = interpolate_templates(src_template, templates_locations_rel, channel_locations_rel) + else: + templates_array = templates.templates_array + + # manage scaling of templates + templates_ = templates + if recording.has_scaleable_traces(): + if are_templates_scaled: + templates_array = (templates_array - recording.get_channel_offsets()) / recording.get_channel_gains() + # make a copy of the templates and reset templates_array (might have scaled templates) + templates_ = templates.select_units(templates.unit_ids) + templates_.templates_array = templates_array + + if sorting is None: + generate_sorting_kwargs = generate_sorting_kwargs.copy() + generate_sorting_kwargs["durations"] = durations + generate_sorting_kwargs["sampling_frequency"] = sampling_frequency + generate_sorting_kwargs["seed"] = seed + sorting = generate_sorting(**generate_sorting_kwargs) + else: + assert sorting.sampling_frequency == sampling_frequency + + num_spikes = sorting.to_spike_vector().size + sorting.set_property("gt_unit_locations", unit_locations) + + assert (nbefore + nafter) == templates_array.shape[ + 1 + ], "templates and ms_before, ms_after should have the same length" + + if templates_array.ndim == 3: + upsample_vector = None + else: + if upsample_vector is None: + upsample_factor = templates_array.shape[3] + upsample_vector = rng.integers(0, upsample_factor, size=num_spikes) + + if amplitude_std is not None: + amplitude_factor = rng.normal(loc=1, scale=amplitude_std, size=num_spikes) + else: + amplitude_factor = None + + if motion is not None: + assert num_segments == motion.num_segments, "recording and motion should have the same number of segments" + dim = motion.dim + motion_array_concat = np.concatenate(motion.displacement) + if dim == 0: + start = np.array([np.min(motion_array_concat), 0]) + stop = np.array([np.max(motion_array_concat), 0]) + elif dim == 1: + start = np.array([0, np.min(motion_array_concat)]) + stop = np.array([0, np.max(motion_array_concat)]) + elif dim == 2: + raise NotImplementedError("3D motion not implemented yet") + num_step = int((stop - start)[dim] / drift_step_um) + displacements = make_linear_displacement(start, stop, num_step=num_step) + + # use templates_, because templates_array might have been scaled + drifting_templates = DriftingTemplates.from_static_templates(templates_) + drifting_templates.precompute_displacements(displacements) + + # calculate displacement vectors for each segment and unit + # for each unit, we interpolate the motion at its location + displacement_sampling_frequency = 1.0 / np.diff(motion.temporal_bins_s[0])[0] + displacement_vectors = [] + for segment_index in range(motion.num_segments): + temporal_bins_segment = motion.temporal_bins_s[segment_index] + displacement_vector = np.zeros((len(temporal_bins_segment), 2, num_units)) + for unit_index in range(num_units): + motion_for_unit = motion.get_displacement_at_time_and_depth( + times_s=temporal_bins_segment, + locations_um=unit_locations[unit_index], + segment_index=segment_index, + grid=True, + ) + displacement_vector[:, motion.dim, unit_index] = motion_for_unit[motion.dim, :] + displacement_vectors.append(displacement_vector) + # since displacement is estimated by interpolation for each unit, the unit factor is an eye + displacement_unit_factor = np.eye(num_units) + + hybrid_recording = InjectDriftingTemplatesRecording( + sorting=sorting, + parent_recording=recording, + drifting_templates=drifting_templates, + displacement_vectors=displacement_vectors, + displacement_sampling_frequency=displacement_sampling_frequency, + displacement_unit_factor=displacement_unit_factor, + num_samples=(np.array(durations) * sampling_frequency).astype("int64"), + amplitude_factor=amplitude_factor, + ) + + else: + warnings.warn( + "No Motion is provided! Please check that your recording is drift-free, otherwise the hybrid recording " + "will have stationary units over a drifting recording..." + ) + hybrid_recording = InjectTemplatesRecording( + sorting, + templates_array, + nbefore=nbefore, + parent_recording=recording, + upsample_vector=upsample_vector, + ) + + return hybrid_recording, sorting diff --git a/src/spikeinterface/generation/noise_tools.py b/src/spikeinterface/generation/noise_tools.py index 48555b3062..11f30e352f 100644 --- a/src/spikeinterface/generation/noise_tools.py +++ b/src/spikeinterface/generation/noise_tools.py @@ -10,25 +10,24 @@ def generate_noise( Parameters ---------- - probe: Probe + probe : Probe A probe object. - sampling_frequency: float + sampling_frequency : float Sampling frequency - durations: list of float + durations : list of float Durations - dtype: np.dtype + dtype : np.dtype Dtype - noise_levels: float | np.array | tuple + noise_levels : float | np.array | tuple If scalar same noises on all channels. If array then per channels noise level. If tuple, then this represent the range. - - seed: None | int + seed : None | int The seed for random generator. Returns ------- - noise: NoiseGeneratorRecording + noise : NoiseGeneratorRecording A lazy noise generator recording. """ diff --git a/src/spikeinterface/generation/tests/test_drift_tools.py b/src/spikeinterface/generation/tests/test_drift_tools.py index 8a4837100e..5647b33930 100644 --- a/src/spikeinterface/generation/tests/test_drift_tools.py +++ b/src/spikeinterface/generation/tests/test_drift_tools.py @@ -94,11 +94,12 @@ def test_move_dense_templates(): def test_DriftingTemplates(): static_templates = make_some_templates() - drifting_templates = DriftingTemplates.from_static(static_templates) + drifting_templates = DriftingTemplates.from_static_templates(static_templates) displacement = np.array([[5.0, 10.0]]) unit_index = 0 moved_template_array = drifting_templates.move_one_template(unit_index, displacement) + assert not np.array_equal(moved_template_array, static_templates.templates_array[unit_index]) num_move = 5 amplitude_motion_um = 20 @@ -112,6 +113,25 @@ def test_DriftingTemplates(): static_templates.num_channels, ) + # test from precomputed + drifting_templates_from_precomputed = DriftingTemplates.from_precomputed_templates( + templates_array_moved=drifting_templates.templates_array_moved, + displacements=drifting_templates.displacements, + sampling_frequency=drifting_templates.sampling_frequency, + probe=drifting_templates.probe, + nbefore=drifting_templates.nbefore, + ) + assert drifting_templates_from_precomputed.templates_array_moved.shape == ( + num_move, + static_templates.num_units, + static_templates.num_samples, + static_templates.num_channels, + ) + assert np.array_equal( + drifting_templates_from_precomputed.templates_array_moved, drifting_templates.templates_array_moved + ) + assert np.array_equal(drifting_templates_from_precomputed.displacements, drifting_templates.displacements) + def test_InjectDriftingTemplatesRecording(create_cache_folder): cache_folder = create_cache_folder @@ -119,7 +139,7 @@ def test_InjectDriftingTemplatesRecording(create_cache_folder): probe = templates.probe # drifting templates - drifting_templates = DriftingTemplates.from_static(templates) + drifting_templates = DriftingTemplates.from_static_templates(templates) channel_locations = probe.contact_positions num_units = templates.unit_ids.size diff --git a/src/spikeinterface/generation/tests/test_hybrid_tools.py b/src/spikeinterface/generation/tests/test_hybrid_tools.py new file mode 100644 index 0000000000..d31a0ec81d --- /dev/null +++ b/src/spikeinterface/generation/tests/test_hybrid_tools.py @@ -0,0 +1,83 @@ +import numpy as np + +from spikeinterface.core import Templates +from spikeinterface.core.generate import ( + generate_ground_truth_recording, + generate_sorting, + generate_templates, + generate_unit_locations, +) +from spikeinterface.preprocessing.motion import correct_motion, load_motion_info +from spikeinterface.generation.hybrid_tools import ( + estimate_templates_from_recording, + generate_hybrid_recording, +) + + +def test_generate_hybrid_no_motion(): + rec, _ = generate_ground_truth_recording(sampling_frequency=20000, seed=0) + hybrid, _ = generate_hybrid_recording(rec, seed=0) + assert rec.get_num_channels() == hybrid.get_num_channels() + assert rec.get_num_frames() == hybrid.get_num_frames() + assert rec.get_num_segments() == hybrid.get_num_segments() + assert np.array_equal(rec.get_channel_locations(), hybrid.get_channel_locations()) + + +def test_generate_hybrid_with_sorting(): + gt_sorting = generate_sorting(durations=[10], num_units=20, sampling_frequency=20000, seed=0) + rec, _ = generate_ground_truth_recording(durations=[10], sampling_frequency=20000, sorting=gt_sorting, seed=0) + hybrid, sorting_hybrid = generate_hybrid_recording(rec, sorting=gt_sorting) + assert rec.get_num_channels() == hybrid.get_num_channels() + assert rec.get_num_frames() == hybrid.get_num_frames() + assert rec.get_num_segments() == hybrid.get_num_segments() + assert np.array_equal(rec.get_channel_locations(), hybrid.get_channel_locations()) + assert sorting_hybrid.get_num_units() == len(hybrid.templates) + + +def test_generate_hybrid_motion(): + rec, _ = generate_ground_truth_recording(sampling_frequency=20000, durations=[10], seed=0) + _, motion_info = correct_motion(rec, output_motion_info=True) + motion = motion_info["motion"] + hybrid, sorting_hybrid = generate_hybrid_recording(rec, motion=motion, seed=0) + assert rec.get_num_channels() == hybrid.get_num_channels() + assert rec.get_num_frames() == hybrid.get_num_frames() + assert rec.get_num_segments() == hybrid.get_num_segments() + assert np.array_equal(rec.get_channel_locations(), hybrid.get_channel_locations()) + assert sorting_hybrid.get_num_units() == len(hybrid.drifting_templates.unit_ids) + + +def test_generate_hybrid_from_templates(): + num_units = 10 + ms_before = 2 + ms_after = 4 + rec, _ = generate_ground_truth_recording(sampling_frequency=20000, seed=0) + channel_locations = rec.get_channel_locations() + unit_locations = generate_unit_locations(num_units, channel_locations=channel_locations, seed=0) + templates_array = generate_templates( + channel_locations, unit_locations, rec.sampling_frequency, ms_before, ms_after, seed=0 + ) + nbefore = int(ms_before * rec.sampling_frequency / 1000) + templates = Templates(templates_array, rec.sampling_frequency, nbefore, True, None, None, None, rec.get_probe()) + hybrid, sorting_hybrid = generate_hybrid_recording(rec, templates=templates, seed=0) + assert np.array_equal(hybrid.templates, templates.templates_array) + assert rec.get_num_channels() == hybrid.get_num_channels() + assert rec.get_num_frames() == hybrid.get_num_frames() + assert rec.get_num_segments() == hybrid.get_num_segments() + assert np.array_equal(rec.get_channel_locations(), hybrid.get_channel_locations()) + assert sorting_hybrid.get_num_units() == num_units + + +def test_estimate_templates(create_cache_folder): + cache_folder = create_cache_folder + rec, _ = generate_ground_truth_recording(num_units=10, sampling_frequency=20000, seed=0) + templates = estimate_templates_from_recording( + rec, run_sorter_kwargs=dict(folder=cache_folder / "sc", remove_existing_folder=True) + ) + assert len(templates.templates_array) > 0 + + +if __name__ == "__main__": + test_generate_hybrid_no_motion() + test_generate_hybrid_motion() + test_estimate_templates() + test_generate_hybrid_with_sorting() diff --git a/src/spikeinterface/generation/tests/test_mock.py b/src/spikeinterface/generation/tests/test_mock.py deleted file mode 100644 index 37c6bde47e..0000000000 --- a/src/spikeinterface/generation/tests/test_mock.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_mock(): - # TODO: Add test logic here - pass diff --git a/src/spikeinterface/postprocessing/__init__.py b/src/spikeinterface/postprocessing/__init__.py index ae071a55e0..34a0bfab9a 100644 --- a/src/spikeinterface/postprocessing/__init__.py +++ b/src/spikeinterface/postprocessing/__init__.py @@ -40,7 +40,6 @@ from .unit_locations import ( compute_unit_locations, ComputeUnitLocations, - compute_center_of_mass, ) from .amplitude_scalings import compute_amplitude_scalings, ComputeAmplitudeScalings diff --git a/src/spikeinterface/postprocessing/localization_tools.py b/src/spikeinterface/postprocessing/localization_tools.py new file mode 100644 index 0000000000..b7571a6f3e --- /dev/null +++ b/src/spikeinterface/postprocessing/localization_tools.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import warnings + +import numpy as np + +try: + import numba + + HAVE_NUMBA = True +except ImportError: + HAVE_NUMBA = False + + +from spikeinterface.core import compute_sparsity, SortingAnalyzer, Templates +from spikeinterface.core.template_tools import get_template_extremum_channel, _get_nbefore, get_dense_templates_array + + +def compute_monopolar_triangulation( + sorting_analyzer_or_templates: SortingAnalyzer | Templates, + optimizer: str = "least_square", + radius_um: float = 75, + max_distance_um: float = 1000, + return_alpha: bool = False, + enforce_decrease: bool = False, + feature: str = "ptp", +) -> np.ndarray: + """ + Localize unit with monopolar triangulation. + This method is from Julien Boussard, Erdem Varol and Charlie Windolf + https://www.biorxiv.org/content/10.1101/2021.11.05.467503v1 + + There are 2 implementations of the 2 optimizer variants: + * https://github.com/int-brain-lab/spikes_localization_registration/blob/main/localization_pipeline/localizer.py + * https://github.com/cwindolf/spike-psvae/blob/main/spike_psvae/localization.py + + Important note about axis: + * x/y are dimmension on the probe plane (dim0, dim1) + * y is the depth by convention + * z it the orthogonal axis to the probe plan (dim2) + + Code from Erdem, Julien and Charlie do not use the same convention!!! + + + Parameters + ---------- + sorting_analyzer_or_templates : SortingAnalyzer | Templates + A SortingAnalyzer or Templates object + method : "least_square" | "minimize_with_log_penality", default: "least_square" + The optimizer to use + radius_um : float, default: 75 + For channel sparsity + max_distance_um : float, default: 1000 + to make bounddary in x, y, z and also for alpha + return_alpha : bool, default: False + Return or not the alpha value + enforce_decrease : bool, default: False + Enforce spatial decreasingness for PTP vectors + feature : "ptp" | "energy" | "peak_voltage", default: "ptp" + The available features to consider for estimating the position via + monopolar triangulation are peak-to-peak amplitudes ("ptp", default), + energy ("energy", as L2 norm) or voltages at the center of the waveform + ("peak_voltage") + + Returns + ------- + unit_location: np.ndarray + 3d or 4d, x, y, z, alpha + alpha is the amplitude at source estimation + """ + assert optimizer in ("least_square", "minimize_with_log_penality") + + assert feature in ["ptp", "energy", "peak_voltage"], f"{feature} is not a valid feature" + unit_ids = sorting_analyzer_or_templates.unit_ids + + contact_locations = sorting_analyzer_or_templates.get_channel_locations() + + sparsity = compute_sparsity(sorting_analyzer_or_templates, method="radius", radius_um=radius_um) + templates = get_dense_templates_array( + sorting_analyzer_or_templates, return_scaled=get_return_scaled(sorting_analyzer_or_templates) + ) + nbefore = _get_nbefore(sorting_analyzer_or_templates) + + if enforce_decrease: + neighbours_mask = np.zeros((templates.shape[0], templates.shape[2]), dtype=bool) + for i, unit_id in enumerate(unit_ids): + chan_inds = sparsity.unit_id_to_channel_indices[unit_id] + neighbours_mask[i, chan_inds] = True + enforce_decrease_radial_parents = make_radial_order_parents(contact_locations, neighbours_mask) + best_channels = get_template_extremum_channel(sorting_analyzer_or_templates, outputs="index") + + unit_location = np.zeros((unit_ids.size, 4), dtype="float64") + for i, unit_id in enumerate(unit_ids): + chan_inds = sparsity.unit_id_to_channel_indices[unit_id] + local_contact_locations = contact_locations[chan_inds, :] + + # wf is (nsample, nchan) - chann is only nieghboor + wf = templates[i, :, :][:, chan_inds] + if feature == "ptp": + wf_data = wf.ptp(axis=0) + elif feature == "energy": + wf_data = np.linalg.norm(wf, axis=0) + elif feature == "peak_voltage": + wf_data = np.abs(wf[nbefore]) + + # if enforce_decrease: + # enforce_decrease_shells_data( + # wf_data, best_channels[unit_id], enforce_decrease_radial_parents, in_place=True + # ) + + unit_location[i] = solve_monopolar_triangulation(wf_data, local_contact_locations, max_distance_um, optimizer) + + if not return_alpha: + unit_location = unit_location[:, :3] + + return unit_location + + +def compute_center_of_mass( + sorting_analyzer_or_templates: SortingAnalyzer | Templates, + peak_sign: str = "neg", + radius_um: float = 75, + feature: str = "ptp", +) -> np.ndarray: + """ + Computes the center of mass (COM) of a unit based on the template amplitudes. + + Parameters + ---------- + sorting_analyzer_or_templates : SortingAnalyzer | Templates + A SortingAnalyzer or Templates object + peak_sign : "neg" | "pos" | "both", default: "neg" + Sign of the template to compute best channels + radius_um : float + Radius to consider in order to estimate the COM + feature : "ptp" | "mean" | "energy" | "peak_voltage", default: "ptp" + Feature to consider for computation + + Returns + ------- + unit_location: np.array + """ + unit_ids = sorting_analyzer_or_templates.unit_ids + + contact_locations = sorting_analyzer_or_templates.get_channel_locations() + + assert feature in ["ptp", "mean", "energy", "peak_voltage"], f"{feature} is not a valid feature" + + sparsity = compute_sparsity( + sorting_analyzer_or_templates, peak_sign=peak_sign, method="radius", radius_um=radius_um + ) + templates = get_dense_templates_array( + sorting_analyzer_or_templates, return_scaled=get_return_scaled(sorting_analyzer_or_templates) + ) + nbefore = _get_nbefore(sorting_analyzer_or_templates) + + unit_location = np.zeros((unit_ids.size, 2), dtype="float64") + for i, unit_id in enumerate(unit_ids): + chan_inds = sparsity.unit_id_to_channel_indices[unit_id] + local_contact_locations = contact_locations[chan_inds, :] + + wf = templates[i, :, :] + + if feature == "ptp": + wf_data = (wf[:, chan_inds]).ptp(axis=0) + elif feature == "mean": + wf_data = (wf[:, chan_inds]).mean(axis=0) + elif feature == "energy": + wf_data = np.linalg.norm(wf[:, chan_inds], axis=0) + elif feature == "peak_voltage": + wf_data = wf[nbefore, chan_inds] + + # center of mass + com = np.sum(wf_data[:, np.newaxis] * local_contact_locations, axis=0) / np.sum(wf_data) + unit_location[i, :] = com + + return unit_location + + +def compute_grid_convolution( + sorting_analyzer_or_templates: SortingAnalyzer | Templates, + peak_sign: str = "neg", + radius_um: float = 40.0, + upsampling_um: float = 5, + sigma_ms: float = 0.25, + margin_um: float = 50, + prototype: np.ndarray | None = None, + percentile: float = 5, + weight_method: dict = {}, +) -> np.ndarray: + """ + Estimate the positions of the templates from a large grid of fake templates + + Parameters + ---------- + sorting_analyzer_or_templates : SortingAnalyzer | Templates + A SortingAnalyzer or Templates object + peak_sign : "neg" | "pos" | "both", default: "neg" + Sign of the template to compute best channels + radius_um : float, default: 40.0 + Radius to consider for the fake templates + upsampling_um : float, default: 5 + Upsampling resolution for the grid of templates + sigma_ms : float, default: 0.25 + The temporal decay of the fake templates + margin_um : float, default: 50 + The margin for the grid of fake templates + prototype : np.array or None, default: None + Fake waveforms for the templates. If None, generated as Gaussian + percentile : float, default: 5 + The percentage in [0, 100] of the best scalar products kept to + estimate the position + weight_method : dict + Parameter that should be provided to the get_convolution_weights() function + in order to know how to estimate the positions. One argument is mode that could + be either gaussian_2d (KS like) or exponential_3d (default) + Returns + ------- + unit_location: np.array + """ + + contact_locations = sorting_analyzer_or_templates.get_channel_locations() + unit_ids = sorting_analyzer_or_templates.unit_ids + + templates = get_dense_templates_array( + sorting_analyzer_or_templates, return_scaled=get_return_scaled(sorting_analyzer_or_templates) + ) + nbefore = _get_nbefore(sorting_analyzer_or_templates) + nafter = templates.shape[1] - nbefore + + fs = sorting_analyzer_or_templates.sampling_frequency + percentile = 100 - percentile + assert 0 <= percentile <= 100, "Percentile should be in [0, 100]" + + time_axis = np.arange(-nbefore, nafter) * 1000 / fs + if prototype is None: + prototype = np.exp(-(time_axis**2) / (2 * (sigma_ms**2))) + if peak_sign == "neg": + prototype *= -1 + + prototype = prototype[:, np.newaxis] + + template_positions, weights, nearest_template_mask, z_factors = get_grid_convolution_templates_and_weights( + contact_locations, radius_um, upsampling_um, margin_um, weight_method + ) + + peak_channels = get_template_extremum_channel(sorting_analyzer_or_templates, peak_sign, outputs="index") + + weights_sparsity_mask = weights > 0 + + nb_weights = weights.shape[0] + unit_location = np.zeros((unit_ids.size, 3), dtype="float64") + + for i, unit_id in enumerate(unit_ids): + main_chan = peak_channels[unit_id] + wf = templates[i, :, :] + nearest_mask = nearest_template_mask[main_chan, :] + channel_mask = np.sum(weights_sparsity_mask[:, :, nearest_mask], axis=(0, 2)) > 0 + num_templates = np.sum(nearest_mask) + sub_w = weights[:, channel_mask, :][:, :, nearest_mask] + global_products = (wf[:, channel_mask] * prototype).sum(axis=0) + + dot_products = np.zeros((nb_weights, num_templates), dtype=np.float32) + for count in range(nb_weights): + dot_products[count] = np.dot(global_products, sub_w[count]) + + mask = dot_products < 0 + if percentile > 0: + dot_products[mask] = np.nan + ## We need to catch warnings because some line can have only NaN, and + ## if so the nanpercentile function throws a warning + with warnings.catch_warnings(): + warnings.filterwarnings("ignore") + thresholds = np.nanpercentile(dot_products, percentile) + thresholds = np.nan_to_num(thresholds) + dot_products[dot_products < thresholds] = 0 + dot_products[mask] = 0 + + nearest_templates = template_positions[nearest_mask] + for count in range(nb_weights): + unit_location[i, :2] += np.dot(dot_products[count], nearest_templates) + + scalar_products = dot_products.sum(1) + unit_location[i, 2] = np.dot(z_factors, scalar_products) + with np.errstate(divide="ignore", invalid="ignore"): + unit_location[i] /= scalar_products.sum() + unit_location = np.nan_to_num(unit_location) + + return unit_location + + +def get_return_scaled(sorting_analyzer_or_templates): + if isinstance(sorting_analyzer_or_templates, Templates): + return_scaled = sorting_analyzer_or_templates.is_scaled + else: + return_scaled = sorting_analyzer_or_templates.return_scaled + return return_scaled + + +def make_initial_guess_and_bounds(wf_data, local_contact_locations, max_distance_um, initial_z=20): + # constant for initial guess and bounds + ind_max = np.argmax(wf_data) + max_ptp = wf_data[ind_max] + max_alpha = max_ptp * max_distance_um + + # initial guess is the center of mass + com = np.sum(wf_data[:, np.newaxis] * local_contact_locations, axis=0) / np.sum(wf_data) + x0 = np.zeros(4, dtype="float32") + x0[:2] = com + x0[2] = initial_z + initial_alpha = np.sqrt(np.sum((com - local_contact_locations[ind_max, :]) ** 2) + initial_z**2) * max_ptp + x0[3] = initial_alpha + + # bounds depend on initial guess + bounds = ( + [x0[0] - max_distance_um, x0[1] - max_distance_um, 1, 0], + [x0[0] + max_distance_um, x0[1] + max_distance_um, max_distance_um * 10, max_alpha], + ) + + return x0, bounds + + +def solve_monopolar_triangulation(wf_data, local_contact_locations, max_distance_um, optimizer): + import scipy.optimize + + x0, bounds = make_initial_guess_and_bounds(wf_data, local_contact_locations, max_distance_um) + + if optimizer == "least_square": + args = (wf_data, local_contact_locations) + try: + output = scipy.optimize.least_squares(estimate_distance_error, x0=x0, bounds=bounds, args=args) + return tuple(output["x"]) + except Exception as e: + print(f"scipy.optimize.least_squares error: {e}") + return (np.nan, np.nan, np.nan, np.nan) + + if optimizer == "minimize_with_log_penality": + x0 = x0[:3] + bounds = [(bounds[0][0], bounds[1][0]), (bounds[0][1], bounds[1][1]), (bounds[0][2], bounds[1][2])] + max_data = wf_data.max() + args = (wf_data, local_contact_locations, max_data) + try: + output = scipy.optimize.minimize(estimate_distance_error_with_log, x0=x0, bounds=bounds, args=args) + # final alpha + q = data_at(*output["x"], 1.0, local_contact_locations) + alpha = (wf_data * q).sum() / np.square(q).sum() + return (*output["x"], alpha) + except Exception as e: + print(f"scipy.optimize.minimize error: {e}") + return (np.nan, np.nan, np.nan, np.nan) + + +# ---- +# optimizer "least_square" + + +def estimate_distance_error(vec, wf_data, local_contact_locations): + # vec dims ar (x, y, z amplitude_factor) + # given that for contact_location x=dim0 + z=dim1 and y is orthogonal to probe + dist = np.sqrt(((local_contact_locations - vec[np.newaxis, :2]) ** 2).sum(axis=1) + vec[2] ** 2) + data_estimated = vec[3] / dist + err = wf_data - data_estimated + return err + + +# ---- +# optimizer "minimize_with_log_penality" + + +def data_at(x, y, z, alpha, local_contact_locations): + return alpha / np.sqrt( + np.square(x - local_contact_locations[:, 0]) + np.square(y - local_contact_locations[:, 1]) + np.square(z) + ) + + +def estimate_distance_error_with_log(vec, wf_data, local_contact_locations, max_data): + x, y, z = vec + q = data_at(x, y, z, 1.0, local_contact_locations) + alpha = (q * wf_data / max_data).sum() / (q * q).sum() + err = ( + np.square(wf_data / max_data - data_at(x, y, z, alpha, local_contact_locations)).mean() + - np.log1p(10.0 * z) / 10000.0 + ) + return err + + +# --- +# waveform cleaning for localization. could be moved to another file + + +def make_shell(channel, geom, n_jumps=1): + """See make_shells""" + from scipy.spatial.distance import cdist + + pt = geom[channel] + dists = cdist([pt], geom).ravel() + radius = np.unique(dists)[1 : n_jumps + 1][-1] + return np.setdiff1d(np.flatnonzero(dists <= radius + 1e-8), [channel]) + + +def make_shells(geom, n_jumps=1): + """Get the neighbors of a channel within a radius + + That radius is found by figuring out the distance to the closest channel, + then the channel which is the next closest (but farther than the closest), + etc... for n_jumps. + + So, if n_jumps is 1, it will return the indices of channels which are + as close as the closest channel. If n_jumps is 2, it will include those + and also the indices of the next-closest channels. And so on... + + Returns + ------- + shell_neighbors : list + List of length geom.shape[0] (aka, the number of channels) + The ith entry in the list is an array with the indices of the neighbors + of the ith channel. + i is not included in these arrays (a channel is not in its own shell). + """ + return [make_shell(c, geom, n_jumps=n_jumps) for c in range(geom.shape[0])] + + +def make_radial_order_parents(geom, neighbours_mask, n_jumps_per_growth=1, n_jumps_parent=3): + """Pre-computes a helper data structure for enforce_decrease_shells""" + n_channels = len(geom) + + # which channels should we consider as possible parents for each channel? + shells = make_shells(geom, n_jumps=n_jumps_parent) + + radial_parents = [] + for channel, neighbors in enumerate(neighbours_mask): + channel_parents = [] + + # convert from boolean mask to list of indices + neighbors = np.flatnonzero(neighbors) + + # the closest shell will do nothing + already_seen = [channel] + shell0 = make_shell(channel, geom, n_jumps=n_jumps_per_growth) + already_seen += sorted(c for c in shell0 if c not in already_seen) + + # so we start at the second jump + jumps = 2 + while len(already_seen) < (neighbors < n_channels).sum(): + # grow our search -- what are the next-closest channels? + new_shell = make_shell(channel, geom, n_jumps=jumps * n_jumps_per_growth) + new_shell = list(sorted(c for c in new_shell if (c not in already_seen) and (c in neighbors))) + + # for each new channel, find the intersection of the channels + # from previous shells and that channel's shell in `shells` + for new_chan in new_shell: + parents = np.intersect1d(shells[new_chan], already_seen) + parents_rel = np.flatnonzero(np.isin(neighbors, parents)) + if not len(parents_rel): + # this can happen for some strange geometries. in that case, bail. + continue + channel_parents.append((np.flatnonzero(neighbors == new_chan).item(), parents_rel)) + + # add this shell to what we have seen + already_seen += new_shell + jumps += 1 + + radial_parents.append(channel_parents) + + return radial_parents + + +def enforce_decrease_shells_data(wf_data, maxchan, radial_parents, in_place=False): + """Radial enforce decrease""" + (C,) = wf_data.shape + + # allocate storage for decreasing version of data + decreasing_data = wf_data if in_place else wf_data.copy() + + # loop to enforce data decrease from parent shells + for c, parents_rel in radial_parents[maxchan]: + if decreasing_data[c] > decreasing_data[parents_rel].max(): + decreasing_data[c] *= decreasing_data[parents_rel].max() / decreasing_data[c] + + return decreasing_data + + +def get_grid_convolution_templates_and_weights( + contact_locations, radius_um=40, upsampling_um=5, margin_um=50, weight_method={"mode": "exponential_3d"} +): + """Get a upsampled grid of artificial templates given a particular probe layout + + Parameters + ---------- + contact_locations: array + The positions of the channels + radius_um: float + Radius in um for channel sparsity. + upsampling_um: float + Upsampling resolution for the grid of templates + margin_um: float + The margin for the grid of fake templates + weight_method: dict + Parameter that should be provided to the get_convolution_weights() function + in order to know how to estimate the positions. One argument is mode that could + be either gaussian_2d (KS like) or exponential_3d (default) + + Returns + ------- + template_positions: array + The positions of the upsampled templates + weights: + The weights of the templates, on a per channel basis + nearest_template_mask: array + A sparsity mask to to know which template is close to the contact locations, given + the radius_um parameter + z_factors: array + The z_factors that have been used to generate the weights along the third dimension + """ + + import sklearn.metrics + + x_min, x_max = contact_locations[:, 0].min(), contact_locations[:, 0].max() + y_min, y_max = contact_locations[:, 1].min(), contact_locations[:, 1].max() + + x_min -= margin_um + x_max += margin_um + y_min -= margin_um + y_max += margin_um + + eps = upsampling_um / 10 + + all_x, all_y = np.meshgrid( + np.arange(x_min, x_max + eps, upsampling_um), np.arange(y_min, y_max + eps, upsampling_um) + ) + + nb_templates = all_x.size + + template_positions = np.zeros((nb_templates, 2)) + template_positions[:, 0] = all_x.flatten() + template_positions[:, 1] = all_y.flatten() + + # mask to get nearest template given a channel + dist = sklearn.metrics.pairwise_distances(contact_locations, template_positions) + nearest_template_mask = dist <= radius_um + weights, z_factors = get_convolution_weights(dist, **weight_method) + + return template_positions, weights, nearest_template_mask, z_factors + + +def get_convolution_weights( + distances, + z_list_um=np.linspace(0, 120.0, 5), + sigma_list_um=np.linspace(5, 25, 5), + sparsity_threshold=None, + sigma_3d=2.5, + mode="exponential_3d", +): + """Get normalized weights for creating artificial templates, given some precomputed distances + + Parameters + ---------- + distances: 2D array + The distances between the source channels (real ones) and the upsampled one (virual ones) + sparsity_threshold: float, default None + The sparsity_threshold below which weights are set to 0 (speeding up computations). If None, + then a default value of 0.5/sqrt(distances.shape[0]) is set + mode: exponential_3d | gaussian_2d + The inference scheme to be used to get the convolution weights + Keyword arguments for the chosen method: + "gaussian_2d" (similar to KiloSort): + * sigma_list_um: array, default np.linspace(5, 25, 5) + The list of sigma to consider for decaying exponentials + "exponential_3d" (default): + * z_list_um: array, default np.linspace(0, 120.0, 5) + The list of z to consider for putative depth of the sources + * sigma_3d: float, default 2.5 + The scaling factor controling the decay of the exponential + + Returns + ------- + weights: + The weights of the templates, on a per channel basis + z_factors: array + The z_factors that have been used to generate the weights along the third dimension + """ + + if sparsity_threshold is not None: + assert 0 <= sparsity_threshold <= 1, "sparsity_threshold should be in [0, 1]" + + if mode == "exponential_3d": + weights = np.zeros((len(z_list_um), distances.shape[0], distances.shape[1]), dtype=np.float32) + for count, z in enumerate(z_list_um): + dist_3d = np.sqrt(distances**2 + z**2) + weights[count] = np.exp(-dist_3d / sigma_3d) + z_factors = z_list_um + elif mode == "gaussian_2d": + weights = np.zeros((len(sigma_list_um), distances.shape[0], distances.shape[1]), dtype=np.float32) + for count, sigma in enumerate(sigma_list_um): + alpha = 2 * (sigma**2) + weights[count] = np.exp(-(distances**2) / alpha) + z_factors = sigma_list_um + + # normalize to get normalized values in [0, 1] + with np.errstate(divide="ignore", invalid="ignore"): + norm = np.linalg.norm(weights, axis=1)[:, np.newaxis, :] + weights /= norm + + weights[~np.isfinite(weights)] = 0.0 + + # If sparsity is None or non zero, we are pruning weights that are below the + # sparsification factor. This will speed up furter computations + if sparsity_threshold is None: + sparsity_threshold = 0.5 / np.sqrt(distances.shape[0]) + weights[weights < sparsity_threshold] = 0 + + # re normalize to ensure we have unitary norms + with np.errstate(divide="ignore", invalid="ignore"): + norm = np.linalg.norm(weights, axis=1)[:, np.newaxis, :] + weights /= norm + + weights[~np.isfinite(weights)] = 0.0 + + return weights, z_factors + + +if HAVE_NUMBA: + enforce_decrease_shells = numba.jit(enforce_decrease_shells_data, nopython=True) diff --git a/src/spikeinterface/postprocessing/unit_locations.py b/src/spikeinterface/postprocessing/unit_locations.py index 16d9955e58..9435030775 100644 --- a/src/spikeinterface/postprocessing/unit_locations.py +++ b/src/spikeinterface/postprocessing/unit_locations.py @@ -1,21 +1,14 @@ from __future__ import annotations -import warnings - import numpy as np - - -try: - import numba - - HAVE_NUMBA = True -except ImportError: - HAVE_NUMBA = False +import warnings from ..core.sortinganalyzer import register_result_extension, AnalyzerExtension -from ..core import compute_sparsity -from ..core.template_tools import get_template_extremum_channel, _get_nbefore, get_dense_templates_array - +from .localization_tools import ( + compute_center_of_mass, + compute_grid_convolution, + compute_monopolar_triangulation, +) dtype_localize_by_method = { "center_of_mass": [("x", "float64"), ("y", "float64")], @@ -90,592 +83,3 @@ def get_data(self, outputs="numpy"): register_result_extension(ComputeUnitLocations) compute_unit_locations = ComputeUnitLocations.function_factory() - - -def make_initial_guess_and_bounds(wf_data, local_contact_locations, max_distance_um, initial_z=20): - # constant for initial guess and bounds - ind_max = np.argmax(wf_data) - max_ptp = wf_data[ind_max] - max_alpha = max_ptp * max_distance_um - - # initial guess is the center of mass - com = np.sum(wf_data[:, np.newaxis] * local_contact_locations, axis=0) / np.sum(wf_data) - x0 = np.zeros(4, dtype="float32") - x0[:2] = com - x0[2] = initial_z - initial_alpha = np.sqrt(np.sum((com - local_contact_locations[ind_max, :]) ** 2) + initial_z**2) * max_ptp - x0[3] = initial_alpha - - # bounds depend on initial guess - bounds = ( - [x0[0] - max_distance_um, x0[1] - max_distance_um, 1, 0], - [x0[0] + max_distance_um, x0[1] + max_distance_um, max_distance_um * 10, max_alpha], - ) - - return x0, bounds - - -def solve_monopolar_triangulation(wf_data, local_contact_locations, max_distance_um, optimizer): - import scipy.optimize - - x0, bounds = make_initial_guess_and_bounds(wf_data, local_contact_locations, max_distance_um) - - if optimizer == "least_square": - args = (wf_data, local_contact_locations) - try: - output = scipy.optimize.least_squares(estimate_distance_error, x0=x0, bounds=bounds, args=args) - return tuple(output["x"]) - except Exception as e: - print(f"scipy.optimize.least_squares error: {e}") - return (np.nan, np.nan, np.nan, np.nan) - - if optimizer == "minimize_with_log_penality": - x0 = x0[:3] - bounds = [(bounds[0][0], bounds[1][0]), (bounds[0][1], bounds[1][1]), (bounds[0][2], bounds[1][2])] - max_data = wf_data.max() - args = (wf_data, local_contact_locations, max_data) - try: - output = scipy.optimize.minimize(estimate_distance_error_with_log, x0=x0, bounds=bounds, args=args) - # final alpha - q = data_at(*output["x"], 1.0, local_contact_locations) - alpha = (wf_data * q).sum() / np.square(q).sum() - return (*output["x"], alpha) - except Exception as e: - print(f"scipy.optimize.minimize error: {e}") - return (np.nan, np.nan, np.nan, np.nan) - - -# ---- -# optimizer "least_square" - - -def estimate_distance_error(vec, wf_data, local_contact_locations): - # vec dims ar (x, y, z amplitude_factor) - # given that for contact_location x=dim0 + z=dim1 and y is orthogonal to probe - dist = np.sqrt(((local_contact_locations - vec[np.newaxis, :2]) ** 2).sum(axis=1) + vec[2] ** 2) - data_estimated = vec[3] / dist - err = wf_data - data_estimated - return err - - -# ---- -# optimizer "minimize_with_log_penality" - - -def data_at(x, y, z, alpha, local_contact_locations): - return alpha / np.sqrt( - np.square(x - local_contact_locations[:, 0]) + np.square(y - local_contact_locations[:, 1]) + np.square(z) - ) - - -def estimate_distance_error_with_log(vec, wf_data, local_contact_locations, max_data): - x, y, z = vec - q = data_at(x, y, z, 1.0, local_contact_locations) - alpha = (q * wf_data / max_data).sum() / (q * q).sum() - err = ( - np.square(wf_data / max_data - data_at(x, y, z, alpha, local_contact_locations)).mean() - - np.log1p(10.0 * z) / 10000.0 - ) - return err - - -def compute_monopolar_triangulation( - sorting_analyzer, - optimizer="minimize_with_log_penality", - radius_um=75, - max_distance_um=1000, - return_alpha=False, - enforce_decrease=False, - feature="ptp", -): - """ - Localize unit with monopolar triangulation. - This method is from Julien Boussard, Erdem Varol and Charlie Windolf - https://www.biorxiv.org/content/10.1101/2021.11.05.467503v1 - - There are 2 implementations of the 2 optimizer variants: - * https://github.com/int-brain-lab/spikes_localization_registration/blob/main/localization_pipeline/localizer.py - * https://github.com/cwindolf/spike-psvae/blob/main/spike_psvae/localization.py - - Important note about axis: - * x/y are dimmension on the probe plane (dim0, dim1) - * y is the depth by convention - * z it the orthogonal axis to the probe plan (dim2) - - Code from Erdem, Julien and Charlie do not use the same convention!!! - - - Parameters - ---------- - sorting_analyzer: SortingAnalyzer - A SortingAnalyzer object - method: "least_square" | "minimize_with_log_penality", default: "least_square" - The optimizer to use - radius_um: float, default: 75 - For channel sparsity - max_distance_um: float, default: 1000 - to make bounddary in x, y, z and also for alpha - return_alpha: bool, default: False - Return or not the alpha value - enforce_decrease : bool, default: False - Enforce spatial decreasingness for PTP vectors - feature: "ptp" | "energy" | "peak_voltage", default: "ptp" - The available features to consider for estimating the position via - monopolar triangulation are peak-to-peak amplitudes ("ptp", default), - energy ("energy", as L2 norm) or voltages at the center of the waveform - ("peak_voltage") - - Returns - ------- - unit_location: np.array - 3d or 4d, x, y, z, alpha - alpha is the amplitude at source estimation - """ - assert optimizer in ("least_square", "minimize_with_log_penality") - - assert feature in ["ptp", "energy", "peak_voltage"], f"{feature} is not a valid feature" - unit_ids = sorting_analyzer.unit_ids - - contact_locations = sorting_analyzer.get_channel_locations() - - sparsity = compute_sparsity(sorting_analyzer, method="radius", radius_um=radius_um) - templates = get_dense_templates_array(sorting_analyzer, return_scaled=sorting_analyzer.return_scaled) - nbefore = _get_nbefore(sorting_analyzer) - - if enforce_decrease: - neighbours_mask = np.zeros((templates.shape[0], templates.shape[2]), dtype=bool) - for i, unit_id in enumerate(unit_ids): - chan_inds = sparsity.unit_id_to_channel_indices[unit_id] - neighbours_mask[i, chan_inds] = True - enforce_decrease_radial_parents = make_radial_order_parents(contact_locations, neighbours_mask) - best_channels = get_template_extremum_channel(sorting_analyzer, outputs="index") - - unit_location = np.zeros((unit_ids.size, 4), dtype="float64") - for i, unit_id in enumerate(unit_ids): - chan_inds = sparsity.unit_id_to_channel_indices[unit_id] - local_contact_locations = contact_locations[chan_inds, :] - - # wf is (nsample, nchan) - chann is only nieghboor - wf = templates[i, :, :][:, chan_inds] - if feature == "ptp": - wf_data = wf.ptp(axis=0) - elif feature == "energy": - wf_data = np.linalg.norm(wf, axis=0) - elif feature == "peak_voltage": - wf_data = np.abs(wf[nbefore]) - - # if enforce_decrease: - # enforce_decrease_shells_data( - # wf_data, best_channels[unit_id], enforce_decrease_radial_parents, in_place=True - # ) - - unit_location[i] = solve_monopolar_triangulation(wf_data, local_contact_locations, max_distance_um, optimizer) - - if not return_alpha: - unit_location = unit_location[:, :3] - - return unit_location - - -def compute_center_of_mass(sorting_analyzer, peak_sign="neg", radius_um=75, feature="ptp"): - """ - Computes the center of mass (COM) of a unit based on the template amplitudes. - - Parameters - ---------- - sorting_analyzer: SortingAnalyzer - A SortingAnalyzer object - peak_sign: "neg" | "pos" | "both", default: "neg" - Sign of the template to compute best channels - radius_um: float - Radius to consider in order to estimate the COM - feature: "ptp" | "mean" | "energy" | "peak_voltage", default: "ptp" - Feature to consider for computation - - Returns - ------- - unit_location: np.array - """ - unit_ids = sorting_analyzer.unit_ids - - contact_locations = sorting_analyzer.get_channel_locations() - - assert feature in ["ptp", "mean", "energy", "peak_voltage"], f"{feature} is not a valid feature" - - sparsity = compute_sparsity(sorting_analyzer, peak_sign=peak_sign, method="radius", radius_um=radius_um) - templates = get_dense_templates_array(sorting_analyzer, return_scaled=sorting_analyzer.return_scaled) - nbefore = _get_nbefore(sorting_analyzer) - - unit_location = np.zeros((unit_ids.size, 2), dtype="float64") - for i, unit_id in enumerate(unit_ids): - chan_inds = sparsity.unit_id_to_channel_indices[unit_id] - local_contact_locations = contact_locations[chan_inds, :] - - wf = templates[i, :, :] - - if feature == "ptp": - wf_data = (wf[:, chan_inds]).ptp(axis=0) - elif feature == "mean": - wf_data = (wf[:, chan_inds]).mean(axis=0) - elif feature == "energy": - wf_data = np.linalg.norm(wf[:, chan_inds], axis=0) - elif feature == "peak_voltage": - wf_data = wf[nbefore, chan_inds] - - # center of mass - com = np.sum(wf_data[:, np.newaxis] * local_contact_locations, axis=0) / np.sum(wf_data) - unit_location[i, :] = com - - return unit_location - - -def compute_grid_convolution( - sorting_analyzer, - peak_sign="neg", - radius_um=40.0, - upsampling_um=5, - sigma_ms=0.25, - margin_um=50, - prototype=None, - percentile=5, - weight_method={}, -): - """ - Estimate the positions of the templates from a large grid of fake templates - - Parameters - ---------- - sorting_analyzer: SortingAnalyzer - A SortingAnalyzer object - peak_sign: "neg" | "pos" | "both", default: "neg" - Sign of the template to compute best channels - radius_um: float, default: 40.0 - Radius to consider for the fake templates - upsampling_um: float, default: 5 - Upsampling resolution for the grid of templates - sigma_ms: float, default: 0.25 - The temporal decay of the fake templates - margin_um: float, default: 50 - The margin for the grid of fake templates - prototype: np.array or None, default: None - Fake waveforms for the templates. If None, generated as Gaussian - percentile: float, default: 5 - The percentage in [0, 100] of the best scalar products kept to - estimate the position - weight_method: dict - Parameter that should be provided to the get_convolution_weights() function - in order to know how to estimate the positions. One argument is mode that could - be either gaussian_2d (KS like) or exponential_3d (default) - Returns - ------- - unit_location: np.array - """ - - contact_locations = sorting_analyzer.get_channel_locations() - unit_ids = sorting_analyzer.unit_ids - - templates = get_dense_templates_array(sorting_analyzer, return_scaled=sorting_analyzer.return_scaled) - nbefore = _get_nbefore(sorting_analyzer) - nafter = templates.shape[1] - nbefore - - fs = sorting_analyzer.sampling_frequency - percentile = 100 - percentile - assert 0 <= percentile <= 100, "Percentile should be in [0, 100]" - - time_axis = np.arange(-nbefore, nafter) * 1000 / fs - if prototype is None: - prototype = np.exp(-(time_axis**2) / (2 * (sigma_ms**2))) - if peak_sign == "neg": - prototype *= -1 - - prototype = prototype[:, np.newaxis] - - template_positions, weights, nearest_template_mask, z_factors = get_grid_convolution_templates_and_weights( - contact_locations, radius_um, upsampling_um, margin_um, weight_method - ) - - peak_channels = get_template_extremum_channel(sorting_analyzer, peak_sign, outputs="index") - - weights_sparsity_mask = weights > 0 - - nb_weights = weights.shape[0] - unit_location = np.zeros((unit_ids.size, 3), dtype="float64") - - for i, unit_id in enumerate(unit_ids): - main_chan = peak_channels[unit_id] - wf = templates[i, :, :] - nearest_mask = nearest_template_mask[main_chan, :] - channel_mask = np.sum(weights_sparsity_mask[:, :, nearest_mask], axis=(0, 2)) > 0 - num_templates = np.sum(nearest_mask) - sub_w = weights[:, channel_mask, :][:, :, nearest_mask] - global_products = (wf[:, channel_mask] * prototype).sum(axis=0) - - dot_products = np.zeros((nb_weights, num_templates), dtype=np.float32) - for count in range(nb_weights): - dot_products[count] = np.dot(global_products, sub_w[count]) - - mask = dot_products < 0 - if percentile > 0: - dot_products[mask] = np.nan - ## We need to catch warnings because some line can have only NaN, and - ## if so the nanpercentile function throws a warning - with warnings.catch_warnings(): - warnings.filterwarnings("ignore") - thresholds = np.nanpercentile(dot_products, percentile) - thresholds = np.nan_to_num(thresholds) - dot_products[dot_products < thresholds] = 0 - dot_products[mask] = 0 - - nearest_templates = template_positions[nearest_mask] - for count in range(nb_weights): - unit_location[i, :2] += np.dot(dot_products[count], nearest_templates) - - scalar_products = dot_products.sum(1) - unit_location[i, 2] = np.dot(z_factors, scalar_products) - with np.errstate(divide="ignore", invalid="ignore"): - unit_location[i] /= scalar_products.sum() - unit_location = np.nan_to_num(unit_location) - - return unit_location - - -# --- -# waveform cleaning for localization. could be moved to another file - - -def make_shell(channel, geom, n_jumps=1): - """See make_shells""" - from scipy.spatial.distance import cdist - - pt = geom[channel] - dists = cdist([pt], geom).ravel() - radius = np.unique(dists)[1 : n_jumps + 1][-1] - return np.setdiff1d(np.flatnonzero(dists <= radius + 1e-8), [channel]) - - -def make_shells(geom, n_jumps=1): - """Get the neighbors of a channel within a radius - - That radius is found by figuring out the distance to the closest channel, - then the channel which is the next closest (but farther than the closest), - etc... for n_jumps. - - So, if n_jumps is 1, it will return the indices of channels which are - as close as the closest channel. If n_jumps is 2, it will include those - and also the indices of the next-closest channels. And so on... - - Returns - ------- - shell_neighbors : list - List of length geom.shape[0] (aka, the number of channels) - The ith entry in the list is an array with the indices of the neighbors - of the ith channel. - i is not included in these arrays (a channel is not in its own shell). - """ - return [make_shell(c, geom, n_jumps=n_jumps) for c in range(geom.shape[0])] - - -def make_radial_order_parents(geom, neighbours_mask, n_jumps_per_growth=1, n_jumps_parent=3): - """Pre-computes a helper data structure for enforce_decrease_shells""" - n_channels = len(geom) - - # which channels should we consider as possible parents for each channel? - shells = make_shells(geom, n_jumps=n_jumps_parent) - - radial_parents = [] - for channel, neighbors in enumerate(neighbours_mask): - channel_parents = [] - - # convert from boolean mask to list of indices - neighbors = np.flatnonzero(neighbors) - - # the closest shell will do nothing - already_seen = [channel] - shell0 = make_shell(channel, geom, n_jumps=n_jumps_per_growth) - already_seen += sorted(c for c in shell0 if c not in already_seen) - - # so we start at the second jump - jumps = 2 - while len(already_seen) < (neighbors < n_channels).sum(): - # grow our search -- what are the next-closest channels? - new_shell = make_shell(channel, geom, n_jumps=jumps * n_jumps_per_growth) - new_shell = list(sorted(c for c in new_shell if (c not in already_seen) and (c in neighbors))) - - # for each new channel, find the intersection of the channels - # from previous shells and that channel's shell in `shells` - for new_chan in new_shell: - parents = np.intersect1d(shells[new_chan], already_seen) - parents_rel = np.flatnonzero(np.isin(neighbors, parents)) - if not len(parents_rel): - # this can happen for some strange geometries. in that case, bail. - continue - channel_parents.append((np.flatnonzero(neighbors == new_chan).item(), parents_rel)) - - # add this shell to what we have seen - already_seen += new_shell - jumps += 1 - - radial_parents.append(channel_parents) - - return radial_parents - - -def enforce_decrease_shells_data(wf_data, maxchan, radial_parents, in_place=False): - """Radial enforce decrease""" - (C,) = wf_data.shape - - # allocate storage for decreasing version of data - decreasing_data = wf_data if in_place else wf_data.copy() - - # loop to enforce data decrease from parent shells - for c, parents_rel in radial_parents[maxchan]: - if decreasing_data[c] > decreasing_data[parents_rel].max(): - decreasing_data[c] *= decreasing_data[parents_rel].max() / decreasing_data[c] - - return decreasing_data - - -def get_grid_convolution_templates_and_weights( - contact_locations, radius_um=40, upsampling_um=5, margin_um=50, weight_method={"mode": "exponential_3d"} -): - """Get a upsampled grid of artificial templates given a particular probe layout - - Parameters - ---------- - contact_locations: array - The positions of the channels - radius_um: float - Radius in um for channel sparsity. - upsampling_um: float - Upsampling resolution for the grid of templates - margin_um: float - The margin for the grid of fake templates - weight_method: dict - Parameter that should be provided to the get_convolution_weights() function - in order to know how to estimate the positions. One argument is mode that could - be either gaussian_2d (KS like) or exponential_3d (default) - - Returns - ------- - template_positions: array - The positions of the upsampled templates - weights: - The weights of the templates, on a per channel basis - nearest_template_mask: array - A sparsity mask to to know which template is close to the contact locations, given - the radius_um parameter - z_factors: array - The z_factors that have been used to generate the weights along the third dimension - """ - - import sklearn.metrics - - x_min, x_max = contact_locations[:, 0].min(), contact_locations[:, 0].max() - y_min, y_max = contact_locations[:, 1].min(), contact_locations[:, 1].max() - - x_min -= margin_um - x_max += margin_um - y_min -= margin_um - y_max += margin_um - - dx = np.abs(x_max - x_min) - dy = np.abs(y_max - y_min) - - eps = upsampling_um / 10 - - all_x, all_y = np.meshgrid( - np.arange(x_min, x_max + eps, upsampling_um), np.arange(y_min, y_max + eps, upsampling_um) - ) - - nb_templates = all_x.size - - template_positions = np.zeros((nb_templates, 2)) - template_positions[:, 0] = all_x.flatten() - template_positions[:, 1] = all_y.flatten() - - # mask to get nearest template given a channel - dist = sklearn.metrics.pairwise_distances(contact_locations, template_positions) - nearest_template_mask = dist <= radius_um - weights, z_factors = get_convolution_weights(dist, **weight_method) - - return template_positions, weights, nearest_template_mask, z_factors - - -def get_convolution_weights( - distances, - z_list_um=np.linspace(0, 120.0, 5), - sigma_list_um=np.linspace(5, 25, 5), - sparsity_threshold=None, - sigma_3d=2.5, - mode="exponential_3d", -): - """Get normalized weights for creating artificial templates, given some precomputed distances - - Parameters - ---------- - distances: 2D array - The distances between the source channels (real ones) and the upsampled one (virual ones) - sparsity_threshold: float, default None - The sparsity_threshold below which weights are set to 0 (speeding up computations). If None, - then a default value of 0.5/sqrt(distances.shape[0]) is set - mode: exponential_3d | gaussian_2d - The inference scheme to be used to get the convolution weights - Keyword arguments for the chosen method: - "gaussian_2d" (similar to KiloSort): - * sigma_list_um: array, default np.linspace(5, 25, 5) - The list of sigma to consider for decaying exponentials - "exponential_3d" (default): - * z_list_um: array, default np.linspace(0, 120.0, 5) - The list of z to consider for putative depth of the sources - * sigma_3d: float, default 2.5 - The scaling factor controling the decay of the exponential - - Returns - ------- - weights: - The weights of the templates, on a per channel basis - z_factors: array - The z_factors that have been used to generate the weights along the third dimension - """ - - if sparsity_threshold is not None: - assert 0 <= sparsity_threshold <= 1, "sparsity_threshold should be in [0, 1]" - - if mode == "exponential_3d": - weights = np.zeros((len(z_list_um), distances.shape[0], distances.shape[1]), dtype=np.float32) - for count, z in enumerate(z_list_um): - dist_3d = np.sqrt(distances**2 + z**2) - weights[count] = np.exp(-dist_3d / sigma_3d) - z_factors = z_list_um - elif mode == "gaussian_2d": - weights = np.zeros((len(sigma_list_um), distances.shape[0], distances.shape[1]), dtype=np.float32) - for count, sigma in enumerate(sigma_list_um): - alpha = 2 * (sigma**2) - weights[count] = np.exp(-(distances**2) / alpha) - z_factors = sigma_list_um - - # normalize to get normalized values in [0, 1] - with np.errstate(divide="ignore", invalid="ignore"): - norm = np.linalg.norm(weights, axis=1)[:, np.newaxis, :] - weights /= norm - - weights[~np.isfinite(weights)] = 0.0 - - # If sparsity is None or non zero, we are pruning weights that are below the - # sparsification factor. This will speed up furter computations - if sparsity_threshold is None: - sparsity_threshold = 0.5 / np.sqrt(distances.shape[0]) - weights[weights < sparsity_threshold] = 0 - - # re normalize to ensure we have unitary norms - with np.errstate(divide="ignore", invalid="ignore"): - norm = np.linalg.norm(weights, axis=1)[:, np.newaxis, :] - weights /= norm - - weights[~np.isfinite(weights)] = 0.0 - - return weights, z_factors - - -if HAVE_NUMBA: - enforce_decrease_shells = numba.jit(enforce_decrease_shells_data, nopython=True) diff --git a/src/spikeinterface/preprocessing/__init__.py b/src/spikeinterface/preprocessing/__init__.py index 38343f8804..5f9ac046e1 100644 --- a/src/spikeinterface/preprocessing/__init__.py +++ b/src/spikeinterface/preprocessing/__init__.py @@ -1,6 +1,6 @@ from .preprocessinglist import * -from .motion import correct_motion, load_motion_info +from .motion import correct_motion, load_motion_info, save_motion_info from .preprocessing_tools import get_spatial_interpolation_kernel from .detect_bad_channels import detect_bad_channels diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 8023bd4367..0d65b1936a 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -2,6 +2,7 @@ import numpy as np import json +import shutil from pathlib import Path import time @@ -205,6 +206,7 @@ def correct_motion( preset="nonrigid_accurate", folder=None, output_motion_info=False, + overwrite=False, detect_kwargs={}, select_kwargs={}, localize_peaks_kwargs={}, @@ -257,6 +259,8 @@ def correct_motion( If True, then the function returns a `motion_info` dictionary that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) This dictionary is the same when reloaded from the folder + overwrite : bool, default: False + If True and folder is given, overwrite the folder if it already exists detect_kwargs : dict Optional parameters to overwrite the ones in the preset for "detect" step. select_kwargs : dict @@ -314,14 +318,6 @@ def correct_motion( job_kwargs = fix_job_kwargs(job_kwargs) noise_levels = get_noise_levels(recording, return_scaled=False) - if folder is not None: - folder = Path(folder) - folder.mkdir(exist_ok=True, parents=True) - - (folder / "parameters.json").write_text(json.dumps(parameters, indent=4, cls=SIJsonEncoder), encoding="utf8") - if recording.check_serializability("json"): - recording.dump_to_json(folder / "recording.json") - if not do_selection: # maybe do this directly in the folder when not None, but might be slow on external storage gather_mode = "memory" @@ -332,7 +328,7 @@ def correct_motion( node1 = ExtractDenseWaveforms(recording, parents=[node0], ms_before=0.1, ms_after=0.3) - # node nolcalize + # node detect + localize method = localize_peaks_kwargs.pop("method", "center_of_mass") method_class = localize_peak_methods[method] node2 = method_class(recording, parents=[node0, node1], return_output=True, **localize_peaks_kwargs) @@ -371,9 +367,6 @@ def correct_motion( select_peaks=t2 - t1, localize_peaks=t3 - t2, ) - if folder is not None: - np.save(folder / "peaks.npy", peaks) - np.save(folder / "peak_locations.npy", peak_locations) t0 = time.perf_counter() motion = estimate_motion(recording, peaks, peak_locations, **estimate_motion_kwargs) @@ -382,18 +375,17 @@ def correct_motion( recording_corrected = InterpolateMotionRecording(recording, motion, **interpolate_motion_kwargs) + motion_info = dict( + parameters=parameters, + run_times=run_times, + peaks=peaks, + peak_locations=peak_locations, + motion=motion, + ) if folder is not None: - (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") - motion.save(folder / "motion") + save_motion_info(motion_info, folder, overwrite=overwrite) if output_motion_info: - motion_info = dict( - parameters=parameters, - run_times=run_times, - peaks=peaks, - peak_locations=peak_locations, - motion=motion, - ) return recording_corrected, motion_info else: return recording_corrected @@ -409,6 +401,25 @@ def correct_motion( correct_motion.__doc__ = correct_motion.__doc__.format(_doc_presets, _shared_job_kwargs_doc) +def save_motion_info(motion_info, folder, overwrite=False): + folder = Path(folder) + if folder.is_dir(): + if not overwrite: + raise FileExistsError(f"Folder {folder} already exists. Use `overwrite=True` to overwrite.") + else: + shutil.rmtree(folder) + folder.mkdir(exist_ok=True, parents=True) + + (folder / "parameters.json").write_text( + json.dumps(motion_info["parameters"], indent=4, cls=SIJsonEncoder), encoding="utf8" + ) + (folder / "run_times.json").write_text(json.dumps(motion_info["run_times"], indent=4), encoding="utf8") + + np.save(folder / "peaks.npy", motion_info["peaks"]) + np.save(folder / "peak_locations.npy", motion_info["peak_locations"]) + motion_info["motion"].save(folder / "motion") + + def load_motion_info(folder): from spikeinterface.sortingcomponents.motion_utils import Motion diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index a298b41d8f..baa7235263 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -1,10 +1,7 @@ import shutil -from pathlib import Path -import numpy as np -import pytest from spikeinterface.core import generate_recording -from spikeinterface.preprocessing import correct_motion, load_motion_info +from spikeinterface.preprocessing import correct_motion, load_motion_info, save_motion_info def test_estimate_and_correct_motion(create_cache_folder): @@ -19,9 +16,16 @@ def test_estimate_and_correct_motion(create_cache_folder): rec_corrected = correct_motion(rec, folder=folder) print(rec_corrected) + # test reloading motion info motion_info = load_motion_info(folder) print(motion_info.keys()) + # test saving motion info + save_folder = folder / "motion_info" + save_motion_info(motion_info=motion_info, folder=save_folder) + motion_info_loaded = load_motion_info(save_folder) + assert motion_info_loaded["motion"] == motion_info["motion"] + if __name__ == "__main__": # print(correct_motion.__doc__) diff --git a/src/spikeinterface/sorters/internal/spyking_circus2.py b/src/spikeinterface/sorters/internal/spyking_circus2.py index b5df0f1059..45cc93d0b6 100644 --- a/src/spikeinterface/sorters/internal/spyking_circus2.py +++ b/src/spikeinterface/sorters/internal/spyking_circus2.py @@ -278,29 +278,30 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): matching_params["templates"] = templates matching_job_params = job_kwargs.copy() - for value in ["chunk_size", "chunk_memory", "total_memory", "chunk_duration"]: - if value in matching_job_params: - matching_job_params[value] = None - matching_job_params["chunk_duration"] = "100ms" + if matching_method is not None: + for value in ["chunk_size", "chunk_memory", "total_memory", "chunk_duration"]: + if value in matching_job_params: + matching_job_params[value] = None + matching_job_params["chunk_duration"] = "100ms" + + spikes = find_spikes_from_templates( + recording_w, matching_method, method_kwargs=matching_params, **matching_job_params + ) - spikes = find_spikes_from_templates( - recording_w, matching_method, method_kwargs=matching_params, **matching_job_params - ) + if params["debug"]: + fitting_folder = sorter_output_folder / "fitting" + fitting_folder.mkdir(parents=True, exist_ok=True) + np.save(fitting_folder / "spikes", spikes) - if params["debug"]: - fitting_folder = sorter_output_folder / "fitting" - fitting_folder.mkdir(parents=True, exist_ok=True) - np.save(fitting_folder / "spikes", spikes) - - if verbose: - print("We found %d spikes" % len(spikes)) - - ## And this is it! We have a spyking circus - sorting = np.zeros(spikes.size, dtype=minimum_spike_dtype) - sorting["sample_index"] = spikes["sample_index"] - sorting["unit_index"] = spikes["cluster_index"] - sorting["segment_index"] = spikes["segment_index"] - sorting = NumpySorting(sorting, sampling_frequency, unit_ids) + if verbose: + print("We found %d spikes" % len(spikes)) + + ## And this is it! We have a spyking circus + sorting = np.zeros(spikes.size, dtype=minimum_spike_dtype) + sorting["sample_index"] = spikes["sample_index"] + sorting["unit_index"] = spikes["cluster_index"] + sorting["segment_index"] = spikes["segment_index"] + sorting = NumpySorting(sorting, sampling_frequency, unit_ids) sorting_folder = sorter_output_folder / "sorting" if sorting_folder.exists(): diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py index 3eda5db3b6..05d142113b 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_peak_localization.py @@ -1,6 +1,6 @@ from __future__ import annotations -from spikeinterface.postprocessing.unit_locations import ( +from spikeinterface.postprocessing.localization_tools import ( compute_center_of_mass, compute_monopolar_triangulation, compute_grid_convolution, diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion_utils.py index 26d4b35b1a..a8de3f6d13 100644 --- a/src/spikeinterface/sortingcomponents/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion_utils.py @@ -90,11 +90,13 @@ def get_displacement_at_time_and_depth(self, times_s, locations_um, segment_inde Parameters ---------- times_s: np.array + The time points at which to evaluate the displacement. locations_um: np.array Either this is a one-dimensional array (a vector of positions along self.dimension), or else a 2d array with the 2 or 3 spatial dimensions indexed along axis=1. - segment_index: int, optional - grid : bool + segment_index: int, default: None + The index of the segment to evaluate. If None, and there is only one segment, then that segment is used. + grid : bool, default: False If grid=False, the default, then times_s and locations_um should have the same one-dimensional shape, and the returned displacement[i] is the displacement at time times_s[i] and location locations_um[i]. @@ -153,6 +155,7 @@ def to_dict(self): displacement=self.displacement, temporal_bins_s=self.temporal_bins_s, spatial_bins_um=self.spatial_bins_um, + direction=self.direction, interpolation_method=self.interpolation_method, ) @@ -223,8 +226,9 @@ def __eq__(self, other): def copy(self): return Motion( - self.displacement.copy(), - self.temporal_bins_s.copy(), - self.spatial_bins_um.copy(), + [d.copy() for d in self.displacement], + [t.copy() for t in self.temporal_bins_s], + [s.copy() for s in self.spatial_bins_um], + direction=self.direction, interpolation_method=self.interpolation_method, ) diff --git a/src/spikeinterface/sortingcomponents/peak_detection.py b/src/spikeinterface/sortingcomponents/peak_detection.py index b6f7709d27..0d5c92ff28 100644 --- a/src/spikeinterface/sortingcomponents/peak_detection.py +++ b/src/spikeinterface/sortingcomponents/peak_detection.py @@ -23,7 +23,7 @@ base_peak_dtype, ) -from spikeinterface.postprocessing.unit_locations import get_convolution_weights +from spikeinterface.postprocessing.localization_tools import get_convolution_weights from .tools import make_multi_method_doc diff --git a/src/spikeinterface/sortingcomponents/peak_localization.py b/src/spikeinterface/sortingcomponents/peak_localization.py index 23faea2d79..6d2ad09239 100644 --- a/src/spikeinterface/sortingcomponents/peak_localization.py +++ b/src/spikeinterface/sortingcomponents/peak_localization.py @@ -24,8 +24,11 @@ from ..postprocessing.unit_locations import ( dtype_localize_by_method, possible_localization_methods, - solve_monopolar_triangulation, +) + +from ..postprocessing.localization_tools import ( make_radial_order_parents, + solve_monopolar_triangulation, enforce_decrease_shells_data, get_grid_convolution_templates_and_weights, ) @@ -66,6 +69,8 @@ def get_localization_pipeline_nodes( elif method == "grid_convolution": if "prototype" not in method_kwargs: assert isinstance(peak_source, (PeakRetriever, SpikeRetriever)) + # extract prototypes silently + job_kwargs["progress_bar"] = False method_kwargs["prototype"] = get_prototype_spike( recording, peak_source.peaks, ms_before=ms_before, ms_after=ms_after, **job_kwargs ) diff --git a/src/spikeinterface/sortingcomponents/tools.py b/src/spikeinterface/sortingcomponents/tools.py index cc45dd3e40..8ee36cc9e5 100644 --- a/src/spikeinterface/sortingcomponents/tools.py +++ b/src/spikeinterface/sortingcomponents/tools.py @@ -62,6 +62,7 @@ def extract_waveform_at_max_channel(rec, peaks, ms_before=0.5, ms_after=1.5, **j return_scaled=False, sparsity_mask=sparsity_mask, copy=True, + verbose=False, **job_kwargs, ) diff --git a/src/spikeinterface/widgets/unit_waveforms.py b/src/spikeinterface/widgets/unit_waveforms.py index b046e55fbf..59f91306ea 100644 --- a/src/spikeinterface/widgets/unit_waveforms.py +++ b/src/spikeinterface/widgets/unit_waveforms.py @@ -540,9 +540,7 @@ def _update_plot(self, change): if self.sorting_analyzer is not None: templates = self.templates_ext.get_templates(unit_ids=unit_ids, operator="average") - templates_shadings = self._get_template_shadings( - unit_ids, self.next_data_plot["templates_percentile_shading"] - ) + templates_shadings = self._get_template_shadings(unit_ids, data_plot["templates_percentile_shading"]) channel_locations = self.sorting_analyzer.get_channel_locations() else: unit_indices = [list(self.templates.unit_ids).index(unit_id) for unit_id in unit_ids] From ec79fb4b951d9b87dffb2d2a6429afca36220fa8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 29 Jun 2024 18:19:11 +0000 Subject: [PATCH 238/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/core/core_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 4cd902eff9..1f2e644be6 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -101,7 +101,7 @@ def default(self, obj): if isinstance(obj, Path): return str(obj) - + if isinstance(obj, Motion): return obj.to_dict() From be96f5e89371bafd2471d5357fac8300c037e2e3 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Mon, 1 Jul 2024 16:38:05 +0200 Subject: [PATCH 239/248] Bringin back the commit --- src/spikeinterface/preprocessing/motion.py | 61 ++++++++++------------ 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 8c7bb1f489..a98bdc171a 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -2,7 +2,6 @@ import numpy as np import json -import shutil from pathlib import Path import time @@ -205,8 +204,8 @@ def correct_motion( recording, preset="nonrigid_accurate", folder=None, - output_motion_info=False, overwrite=False, + output_motion_info=False, detect_kwargs={}, select_kwargs={}, localize_peaks_kwargs={}, @@ -261,8 +260,6 @@ def correct_motion( If True, then the function returns a `motion_info` dictionary that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) This dictionary is the same when reloaded from the folder - overwrite : bool, default: False - If True and folder is given, overwrite the folder if it already exists detect_kwargs : dict Optional parameters to overwrite the ones in the preset for "detect" step. select_kwargs : dict @@ -320,6 +317,21 @@ def correct_motion( job_kwargs = fix_job_kwargs(job_kwargs) noise_levels = get_noise_levels(recording, return_scaled=False) + if folder is not None: + folder = Path(folder) + if overwrite: + if folder.exists(): + import shutil + shutil.rmtree(folder) + else: + assert not folder.exists(), f"Folder {folder} already exists" + + folder.mkdir(exist_ok=True, parents=True) + + (folder / "parameters.json").write_text(json.dumps(parameters, indent=4, cls=SIJsonEncoder), encoding="utf8") + if recording.check_serializability("json"): + recording.dump_to_json(folder / "recording.json") + if not do_selection: # maybe do this directly in the folder when not None, but might be slow on external storage gather_mode = "memory" @@ -330,7 +342,7 @@ def correct_motion( node1 = ExtractDenseWaveforms(recording, parents=[node0], ms_before=0.1, ms_after=0.3) - # node detect + localize + # node nolcalize method = localize_peaks_kwargs.pop("method", "center_of_mass") method_class = localize_peak_methods[method] node2 = method_class(recording, parents=[node0, node1], return_output=True, **localize_peaks_kwargs) @@ -369,6 +381,9 @@ def correct_motion( select_peaks=t2 - t1, localize_peaks=t3 - t2, ) + if folder is not None: + np.save(folder / "peaks.npy", peaks) + np.save(folder / "peak_locations.npy", peak_locations) t0 = time.perf_counter() motion = estimate_motion(recording, peaks, peak_locations, **estimate_motion_kwargs) @@ -377,17 +392,18 @@ def correct_motion( recording_corrected = InterpolateMotionRecording(recording, motion, **interpolate_motion_kwargs) - motion_info = dict( - parameters=parameters, - run_times=run_times, - peaks=peaks, - peak_locations=peak_locations, - motion=motion, - ) if folder is not None: - save_motion_info(motion_info, folder, overwrite=overwrite) + (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") + motion.save(folder / "motion") if output_motion_info: + motion_info = dict( + parameters=parameters, + run_times=run_times, + peaks=peaks, + peak_locations=peak_locations, + motion=motion, + ) return recording_corrected, motion_info else: return recording_corrected @@ -403,25 +419,6 @@ def correct_motion( correct_motion.__doc__ = correct_motion.__doc__.format(_doc_presets, _shared_job_kwargs_doc) -def save_motion_info(motion_info, folder, overwrite=False): - folder = Path(folder) - if folder.is_dir(): - if not overwrite: - raise FileExistsError(f"Folder {folder} already exists. Use `overwrite=True` to overwrite.") - else: - shutil.rmtree(folder) - folder.mkdir(exist_ok=True, parents=True) - - (folder / "parameters.json").write_text( - json.dumps(motion_info["parameters"], indent=4, cls=SIJsonEncoder), encoding="utf8" - ) - (folder / "run_times.json").write_text(json.dumps(motion_info["run_times"], indent=4), encoding="utf8") - - np.save(folder / "peaks.npy", motion_info["peaks"]) - np.save(folder / "peak_locations.npy", motion_info["peak_locations"]) - motion_info["motion"].save(folder / "motion") - - def load_motion_info(folder): from spikeinterface.sortingcomponents.motion_utils import Motion From f8ea231a7a1d3573fb3d0bf26a636183cb8d6a21 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:39:22 +0000 Subject: [PATCH 240/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/preprocessing/motion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index a98bdc171a..71ae3f3ebb 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,8 +320,9 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.exists(): + if folder.exists(): import shutil + shutil.rmtree(folder) else: assert not folder.exists(), f"Folder {folder} already exists" From e052f047c4ea7d8b7c57a19b811251aa5ff3fd58 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Mon, 1 Jul 2024 16:42:53 +0200 Subject: [PATCH 241/248] Confused in the git... --- src/spikeinterface/preprocessing/motion.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index a98bdc171a..c9bc2a2207 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,12 +320,11 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.exists(): + if folder.is_dir(): import shutil shutil.rmtree(folder) else: assert not folder.exists(), f"Folder {folder} already exists" - folder.mkdir(exist_ok=True, parents=True) (folder / "parameters.json").write_text(json.dumps(parameters, indent=4, cls=SIJsonEncoder), encoding="utf8") From 684307073726bfdb41713adb50706c141f8f7b8d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:43:48 +0000 Subject: [PATCH 242/248] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/preprocessing/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 7ac08bb3cc..c36fc026a7 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,7 +320,7 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.is_dir(): + if folder.is_dir(): import shutil shutil.rmtree(folder) From 9049596a943b96febbfe6d21d25af569167e638f Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Mon, 1 Jul 2024 16:46:27 +0200 Subject: [PATCH 243/248] Lost --- src/spikeinterface/preprocessing/motion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index c36fc026a7..c04c392bac 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -320,7 +320,7 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.is_dir(): + if folder.exists(): import shutil shutil.rmtree(folder) From 7b26657717aefc2660a29a19529884a404a0e7f7 Mon Sep 17 00:00:00 2001 From: Pierre Yger Date: Mon, 1 Jul 2024 16:53:42 +0200 Subject: [PATCH 244/248] Fixing git history --- src/spikeinterface/preprocessing/motion.py | 57 +++++++++++++--------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index c04c392bac..57fe609e91 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -2,6 +2,7 @@ import numpy as np import json +import shutil from pathlib import Path import time @@ -204,8 +205,8 @@ def correct_motion( recording, preset="nonrigid_accurate", folder=None, - overwrite=False, output_motion_info=False, + overwrite=False, detect_kwargs={}, select_kwargs={}, localize_peaks_kwargs={}, @@ -254,12 +255,12 @@ def correct_motion( The preset name folder : Path str or None, default: None If not None then intermediate motion info are saved into a folder - overwrite : bool, default False - If folder is not None and already existing, should we overwrite output_motion_info : bool, default: False If True, then the function returns a `motion_info` dictionary that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) This dictionary is the same when reloaded from the folder + overwrite : bool, default: False + If True and folder is given, overwrite the folder if it already exists detect_kwargs : dict Optional parameters to overwrite the ones in the preset for "detect" step. select_kwargs : dict @@ -320,17 +321,12 @@ def correct_motion( if folder is not None: folder = Path(folder) if overwrite: - if folder.exists(): + if folder.is_dir(): import shutil shutil.rmtree(folder) else: - assert not folder.exists(), f"Folder {folder} already exists" - folder.mkdir(exist_ok=True, parents=True) - - (folder / "parameters.json").write_text(json.dumps(parameters, indent=4, cls=SIJsonEncoder), encoding="utf8") - if recording.check_serializability("json"): - recording.dump_to_json(folder / "recording.json") + assert not folder.is_dir(), f"Folder {folder} already exists" if not do_selection: # maybe do this directly in the folder when not None, but might be slow on external storage @@ -342,7 +338,7 @@ def correct_motion( node1 = ExtractDenseWaveforms(recording, parents=[node0], ms_before=0.1, ms_after=0.3) - # node nolcalize + # node detect + localize method = localize_peaks_kwargs.pop("method", "center_of_mass") method_class = localize_peak_methods[method] node2 = method_class(recording, parents=[node0, node1], return_output=True, **localize_peaks_kwargs) @@ -381,9 +377,6 @@ def correct_motion( select_peaks=t2 - t1, localize_peaks=t3 - t2, ) - if folder is not None: - np.save(folder / "peaks.npy", peaks) - np.save(folder / "peak_locations.npy", peak_locations) t0 = time.perf_counter() motion = estimate_motion(recording, peaks, peak_locations, **estimate_motion_kwargs) @@ -392,18 +385,17 @@ def correct_motion( recording_corrected = InterpolateMotionRecording(recording, motion, **interpolate_motion_kwargs) + motion_info = dict( + parameters=parameters, + run_times=run_times, + peaks=peaks, + peak_locations=peak_locations, + motion=motion, + ) if folder is not None: - (folder / "run_times.json").write_text(json.dumps(run_times, indent=4), encoding="utf8") - motion.save(folder / "motion") + save_motion_info(motion_info, folder, overwrite=overwrite) if output_motion_info: - motion_info = dict( - parameters=parameters, - run_times=run_times, - peaks=peaks, - peak_locations=peak_locations, - motion=motion, - ) return recording_corrected, motion_info else: return recording_corrected @@ -419,6 +411,25 @@ def correct_motion( correct_motion.__doc__ = correct_motion.__doc__.format(_doc_presets, _shared_job_kwargs_doc) +def save_motion_info(motion_info, folder, overwrite=False): + folder = Path(folder) + if folder.is_dir(): + if not overwrite: + raise FileExistsError(f"Folder {folder} already exists. Use `overwrite=True` to overwrite.") + else: + shutil.rmtree(folder) + folder.mkdir(exist_ok=True, parents=True) + + (folder / "parameters.json").write_text( + json.dumps(motion_info["parameters"], indent=4, cls=SIJsonEncoder), encoding="utf8" + ) + (folder / "run_times.json").write_text(json.dumps(motion_info["run_times"], indent=4), encoding="utf8") + + np.save(folder / "peaks.npy", motion_info["peaks"]) + np.save(folder / "peak_locations.npy", motion_info["peak_locations"]) + motion_info["motion"].save(folder / "motion") + + def load_motion_info(folder): from spikeinterface.sortingcomponents.motion_utils import Motion From 790011ae3c1baf0ebc911001465443c08e73243b Mon Sep 17 00:00:00 2001 From: Charlie Windolf Date: Mon, 1 Jul 2024 13:51:27 -0700 Subject: [PATCH 245/248] Needs out-of-place mul --- src/spikeinterface/generation/drift_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/generation/drift_tools.py b/src/spikeinterface/generation/drift_tools.py index 1f410f4330..cce2e08b58 100644 --- a/src/spikeinterface/generation/drift_tools.py +++ b/src/spikeinterface/generation/drift_tools.py @@ -567,7 +567,7 @@ def get_traces( wf = template[start_template:end_template] if self.amplitude_vector is not None: - wf *= self.amplitude_vector[i] + wf = wf * self.amplitude_vector[i] traces[start_traces:end_traces] += wf.astype(self.dtype, copy=False) return traces.astype(self.dtype) From 91c8ac6c424db83c547035e20cdb046afe2283f4 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Tue, 2 Jul 2024 09:55:52 +0100 Subject: [PATCH 246/248] Remove unit_params_range from generate.py --- src/spikeinterface/core/generate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py index af6664b886..11909bce0e 100644 --- a/src/spikeinterface/core/generate.py +++ b/src/spikeinterface/core/generate.py @@ -1440,7 +1440,6 @@ def generate_templates( dtype="float32", upsample_factor=None, unit_params=None, - unit_params_range=None, mode="ellipsoid", ): """ @@ -1497,9 +1496,7 @@ def generate_templates( * (num_units, num_samples, num_channels, upsample_factor) if upsample_factor is not None """ - unit_params = unit_params or dict() - unit_params_range = unit_params_range or dict() rng = np.random.default_rng(seed=seed) # neuron location must be 3D From d1744590ec11b65ed4cb43fc13051924ac880ebe Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Tue, 2 Jul 2024 16:20:32 +0200 Subject: [PATCH 247/248] Add pooch to docs extra --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 644d52608e..f40aa93140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,13 +175,16 @@ docs = [ "ipython", # for notebooks in the gallery - "MEArec", # Use as an example - "datalad==0.16.2", # Download mearec data, not sure if needed as is installed with conda as well because of git-annex + "MEArec", # Use as an example "pandas", # in the modules gallery comparison tutorial "hdbscan>=0.8.33", # For sorters spykingcircus2 + tridesclous "numba", # For many postprocessing functions "xarray", # For use of SortingAnalyzer zarr format "networkx", + # Download data + "pooch>=1.8.2", + "datalad>=1.0.2", + # for release we need pypi, so this needs to be commented # "probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git", # We always build from the latest version # "neo @ git+https://github.com/NeuralEnsemble/python-neo.git", # We always build from the latest version From a178b7e1a9428c4af1aff76134f498e85fa546aa Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 2 Jul 2024 08:27:33 -0600 Subject: [PATCH 248/248] Add plexon2 tests action for install wine and drop full (linux only) tests (#3101) * add plexon tests and drop full (linux only) tests --- .github/actions/install-wine/action.yml | 19 +- .github/determine_testing_environment.py | 77 ++++---- .github/workflows/all-tests.yml | 16 +- .github/workflows/full-test.yml | 167 ------------------ pyproject.toml | 22 ++- .../extractors/neoextractors/plexon2.py | 6 +- 6 files changed, 83 insertions(+), 224 deletions(-) delete mode 100644 .github/workflows/full-test.yml diff --git a/.github/actions/install-wine/action.yml b/.github/actions/install-wine/action.yml index 3ae08ecd34..85e70b471d 100644 --- a/.github/actions/install-wine/action.yml +++ b/.github/actions/install-wine/action.yml @@ -2,20 +2,29 @@ name: Install packages description: This action installs the package and its dependencies for testing inputs: - python-version: - description: 'Python version to set up' - required: false os: description: 'Operating system to set up' - required: false + required: true runs: using: "composite" steps: - - name: Install wine (needed for Plexon2) + - name: Install wine on Linux + if: runner.os == 'Linux' run: | sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list sudo dpkg --add-architecture i386 sudo apt-get update -qq sudo apt-get install -yqq --allow-downgrades libc6:i386 libgcc-s1:i386 libstdc++6:i386 wine shell: bash + - name: Install wine on macOS + if: runner.os == 'macOS' + run: | + brew install --cask xquartz + brew install --cask wine-stable + shell: bash + + - name: Skip installation on Windows + if: ${{ inputs.os == 'Windows' }} + run: echo "Skipping Wine installation on Windows. Not necessary." + shell: bash diff --git a/.github/determine_testing_environment.py b/.github/determine_testing_environment.py index 4945ccc807..0c0c5ef95b 100644 --- a/.github/determine_testing_environment.py +++ b/.github/determine_testing_environment.py @@ -36,50 +36,47 @@ file_is_in_src = changed_file.parts[0] == "src" - if not file_is_in_src: - - if changed_file.name == "pyproject.toml": - pyproject_toml_changed = True - - else: - if changed_file.name == "neobaseextractor.py": - neobaseextractor_changed = True - elif changed_file.name == "plexon2.py": - extractors_changed = True - elif "core" in changed_file.parts: - conditions_changed = True - elif "extractors" in changed_file.parts: - extractors_changed = True - elif "preprocessing" in changed_file.parts: - preprocessing_changed = True - elif "postprocessing" in changed_file.parts: - postprocessing_changed = True - elif "qualitymetrics" in changed_file.parts: - qualitymetrics_changed = True - elif "comparison" in changed_file.parts: - comparison_changed = True - elif "curation" in changed_file.parts: - curation_changed = True - elif "widgets" in changed_file.parts: - widgets_changed = True - elif "exporters" in changed_file.parts: - exporters_changed = True - elif "sortingcomponents" in changed_file.parts: - sortingcomponents_changed = True - elif "generation" in changed_file.parts: - generation_changed = True - elif "sorters" in changed_file.parts: - if "external" in changed_file.parts: - sorters_external_changed = True - elif "internal" in changed_file.parts: - sorters_internal_changed = True - else: - sorters_changed = True + + if changed_file.name == "pyproject.toml": + pyproject_toml_changed = True + elif changed_file.name == "neobaseextractor.py": + neobaseextractor_changed = True + elif changed_file.name == "plexon2.py": + plexon2_changed = True + elif "core" in changed_file.parts: + core_changed = True + elif "extractors" in changed_file.parts: + extractors_changed = True + elif "preprocessing" in changed_file.parts: + preprocessing_changed = True + elif "postprocessing" in changed_file.parts: + postprocessing_changed = True + elif "qualitymetrics" in changed_file.parts: + qualitymetrics_changed = True + elif "comparison" in changed_file.parts: + comparison_changed = True + elif "curation" in changed_file.parts: + curation_changed = True + elif "widgets" in changed_file.parts: + widgets_changed = True + elif "exporters" in changed_file.parts: + exporters_changed = True + elif "sortingcomponents" in changed_file.parts: + sortingcomponents_changed = True + elif "generation" in changed_file.parts: + generation_changed = True + elif "sorters" in changed_file.parts: + if "external" in changed_file.parts: + sorters_external_changed = True + elif "internal" in changed_file.parts: + sorters_internal_changed = True + else: + sorters_changed = True run_everything = core_changed or pyproject_toml_changed or neobaseextractor_changed run_generation_tests = run_everything or generation_changed -run_extractor_tests = run_everything or extractors_changed +run_extractor_tests = run_everything or extractors_changed or plexon2_changed run_preprocessing_tests = run_everything or preprocessing_changed run_postprocessing_tests = run_everything or postprocessing_changed run_qualitymetrics_tests = run_everything or qualitymetrics_changed diff --git a/.github/workflows/all-tests.yml b/.github/workflows/all-tests.yml index cce73a9008..8dfea50faf 100644 --- a/.github/workflows/all-tests.yml +++ b/.github/workflows/all-tests.yml @@ -78,9 +78,8 @@ jobs: run: pytest -m "core" shell: bash - - name: Install Other Testing Dependencies + - name: Install Dependencies for Timing Display run: | - pip install -e .[test] pip install tabulate pip install pandas shell: bash @@ -91,6 +90,7 @@ jobs: run: echo "dataset_hash=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT - name: Cache datasets + if: env.RUN_EXTRACTORS_TESTS == 'true' id: cache-datasets uses: actions/cache/restore@v4 with: @@ -119,6 +119,9 @@ jobs: fi git config --global filter.annex.process "git-annex filter-process" # recommended for efficiency + - name : Install Plexon dependencies + if: env.INSTALL_PLEXON_DEPENDENCIES == 'true' + uses: ./.github/actions/install-wine - name: Set execute permissions on run_tests.sh shell: bash @@ -130,16 +133,21 @@ jobs: HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell if: env.RUN_EXTRACTORS_TESTS == 'true' run: | - pip install -e .[extractors,streaming_extractors] + pip install -e .[extractors,streaming_extractors,test_extractors] ./.github/run_tests.sh "extractors and not streaming_extractors" --no-virtual-env - name: Test preprocessing shell: bash if: env.RUN_PREPROCESSING_TESTS == 'true' run: | - pip install -e .[preprocessing] + pip install -e .[preprocessing,test_preprocessing] ./.github/run_tests.sh "preprocessing and not deepinterpolation" --no-virtual-env + - name: Install remaining testing dependencies # TODO: Remove this step once we have better modularization + shell: bash + run: | + pip install -e .[test] + - name: Test postprocessing shell: bash if: env.RUN_POSTPROCESSING_TESTS == 'true' diff --git a/.github/workflows/full-test.yml b/.github/workflows/full-test.yml deleted file mode 100644 index ed2f28dc23..0000000000 --- a/.github/workflows/full-test.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Full spikeinterface tests - -on: - pull_request: - types: [synchronize, opened, reopened] - branches: - - main - -concurrency: # Cancel previous workflows on the same pull request - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: # For the sortingview backend - KACHERY_CLOUD_CLIENT_ID: ${{ secrets.KACHERY_CLOUD_CLIENT_ID }} - KACHERY_CLOUD_PRIVATE_KEY: ${{ secrets.KACHERY_CLOUD_PRIVATE_KEY }} - -jobs: - full-tests-depending-on-changed-files: - name: Test on (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # "macos-latest", "windows-latest" - os: ["ubuntu-latest", ] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - name: Get current year-month - id: date - run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT - - name: Get ephy_testing_data current head hash - # the key depends on the last comit repo https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git - id: vars - run: | - echo "HASH_EPHY_DATASET=$(git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1)" >> $GITHUB_OUTPUT - - name: Restore cached gin data for extractors tests - uses: actions/cache/restore@v4 - id: cache-datasets - env: - # the key depends on the last comit repo https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git - HASH_EPHY_DATASET: git ls-remote https://gin.g-node.org/NeuralEnsemble/ephy_testing_data.git HEAD | cut -f1 - with: - path: ~/spikeinterface_datasets - key: ${{ runner.os }}-datasets-${{ steps.vars.outputs.HASH_EPHY_DATASET }} - restore-keys: ${{ runner.os }}-datasets - - name: Install packages - uses: ./.github/actions/build-test-environment - - name: Get changed files - id: changed-files - uses: tj-actions/changed-files@v41 - - name: Module changes - id: modules-changed - run: | - for file in ${{ steps.changed-files.outputs.all_changed_files }}; do - if [[ $file == *"pyproject.toml" ]]; then - echo "pyproject.toml changed" - echo "CORE_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/core/"* || $file == *"/extractors/neoextractors/neobaseextractor.py" ]]; then - echo "Core changed" - echo "CORE_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/extractors/"* ]]; then - echo "Extractors changed" - echo "EXTRACTORS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"plexon2"* ]]; then - echo "Plexon2 changed" - echo "PLEXON2_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/preprocessing/"* ]]; then - echo "Preprocessing changed" - echo "PREPROCESSING_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/postprocessing/"* ]]; then - echo "Postprocessing changed" - echo "POSTPROCESSING_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/qualitymetrics/"* ]]; then - echo "Quality metrics changed" - echo "QUALITYMETRICS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/sorters/"* && $file != *"/sorters/internal/"* && $file != *"/sorters/external/"* ]]; then - echo "Sorters changed" - echo "SORTERS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/sorters/external"* ]]; then - echo "External sorters changed" - echo "SORTERS_EXTERNAL_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/sorters/internal"* ]]; then - echo "Internal sorters changed" - echo "SORTERS_INTERNAL_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/comparison/"* ]]; then - echo "Comparison changed" - echo "COMPARISON_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/curation/"* ]]; then - echo "Curation changed" - echo "CURATION_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/widgets/"* ]]; then - echo "Widgets changed" - echo "WIDGETS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/exporters/"* ]]; then - echo "Exporters changed" - echo "EXPORTERS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/sortingcomponents/"* ]]; then - echo "Sortingcomponents changed" - echo "SORTINGCOMPONENTS_CHANGED=true" >> $GITHUB_OUTPUT - fi - if [[ $file == *"/generation/"* ]]; then - echo "Generation changed" - echo "GENERATION_CHANGED=true" >> $GITHUB_OUTPUT - fi - done - - name: Set execute permissions on run_tests.sh - run: chmod +x .github/run_tests.sh - - name: Install Wine (Plexon2) - if: ${{ steps.modules-changed.outputs.PLEXON2_CHANGED == 'true' }} - uses: ./.github/actions/install-wine - - name: Test core - run: ./.github/run_tests.sh core - - name: Test extractors - env: - HDF5_PLUGIN_PATH: ${{ github.workspace }}/hdf5_plugin_path_maxwell - if: ${{ steps.modules-changed.outputs.EXTRACTORS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh "extractors and not streaming_extractors" - - name: Test preprocessing - if: ${{ steps.modules-changed.outputs.PREPROCESSING_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh "preprocessing and not deepinterpolation" - - name: Test postprocessing - if: ${{ steps.modules-changed.outputs.POSTPROCESSING_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh postprocessing - - name: Test quality metrics - if: ${{ steps.modules-changed.outputs.QUALITYMETRICS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh qualitymetrics - - name: Test core sorters - if: ${{ steps.modules-changed.outputs.SORTERS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh sorters - - name: Test comparison - if: ${{ steps.modules-changed.outputs.COMPARISON_CHANGED == 'true' || steps.modules-changed.outputs.GENERATION_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh comparison - - name: Test curation - if: ${{ steps.modules-changed.outputs.CURATION_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh curation - - name: Test widgets - if: ${{ steps.modules-changed.outputs.WIDGETS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' || steps.modules-changed.outputs.QUALITYMETRICS_CHANGED == 'true' || steps.modules-changed.outputs.PREPROCESSING_CHANGED == 'true'}} - run: ./.github/run_tests.sh widgets - - name: Test exporters - if: ${{ steps.modules-changed.outputs.EXPORTERS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' || steps.modules-changed.outputs.WIDGETS_CHANGED == 'true' }} - run: ./.github/run_tests.sh exporters - - name: Test sortingcomponents - if: ${{ steps.modules-changed.outputs.SORTINGCOMPONENTS_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh sortingcomponents - - name: Test internal sorters - if: ${{ steps.modules-changed.outputs.SORTERS_INTERNAL_CHANGED == 'true' || steps.modules-changed.outputs.SORTINGCOMPONENTS_CHANGED || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh sorters_internal - - name: Test generation - if: ${{ steps.modules-changed.outputs.GENERATION_CHANGED == 'true' || steps.modules-changed.outputs.CORE_CHANGED == 'true' }} - run: ./.github/run_tests.sh generation diff --git a/pyproject.toml b/pyproject.toml index 644d52608e..0e1d0a2cba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ streaming_extractors = [ "s3fs" ] + preprocessing = [ "scipy", ] @@ -124,10 +125,23 @@ test_core = [ # for github test : probeinterface and neo from master # for release we need pypi, so this need to be commented - # "probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git", - # "neo @ git+https://github.com/NeuralEnsemble/python-neo.git", + "probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git", + "neo @ git+https://github.com/NeuralEnsemble/python-neo.git", +] + +test_extractors = [ + # Functions to download data in neo test suite + "pooch>=1.8.2", + "datalad>=1.0.2", + "probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git", + "neo @ git+https://github.com/NeuralEnsemble/python-neo.git", ] +test_preprocessing = [ + "ibllib>=2.36.0", # for IBL +] + + test = [ "pytest", "pytest-dependency", @@ -148,9 +162,7 @@ test = [ # for sortingview backend "sortingview", - # Download data - "pooch>=1.8.2", - "datalad>=1.0.2", + ## install tridesclous for testing ## "tridesclous>=1.6.8", diff --git a/src/spikeinterface/extractors/neoextractors/plexon2.py b/src/spikeinterface/extractors/neoextractors/plexon2.py index c7351a308b..6c9160f13b 100644 --- a/src/spikeinterface/extractors/neoextractors/plexon2.py +++ b/src/spikeinterface/extractors/neoextractors/plexon2.py @@ -13,8 +13,8 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): Parameters ---------- - file_path : str - The file path to load the recordings from. + file_path : str | Path + The file path of the plexon2 file. It should have the .pl2 extension. stream_id : str, default: None If there are several streams, specify the stream id you want to load. stream_name : str, default: None @@ -23,7 +23,7 @@ class Plexon2RecordingExtractor(NeoBaseRecordingExtractor): If True, the names of the signals are used as channel ids. If False, the channel ids are a combination of the source id and the channel index. - Example for widegain signals: + Example for wideband signals: names: ["WB01", "WB02", "WB03", "WB04"] ids: ["source3.1" , "source3.2", "source3.3", "source3.4"] all_annotations : bool, default: False