Repository: SpikeInterface/spikeinterface
Pull request #2937 (2024-05-30 13:13:41)

Add tests for windows and mac
Full testing suit, right now is set as a push for debugging. Will be a cron job per week once we are sure it works.


Patch:
diff --git a/.github/workflows/caches_cron_job.yml b/.github/workflows/caches_cron_job.yml
index 20e2a5517..2454e97ad 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
diff --git a/doc/modules/core.rst b/doc/modules/core.rst
index 239e42bc3..f8f410018 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
 ---------
 
@@ -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, :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`.
 
diff --git a/doc/modules/postprocessing.rst b/doc/modules/postprocessing.rst
index 6465e4af4..9aad8568f 100644
--- a/doc/modules/postprocessing.rst
+++ b/doc/modules/postprocessing.rst
@@ -301,21 +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 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 :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/examples/tutorials/core/plot_3_handle_probe_info.py b/examples/tutorials/core/plot_3_handle_probe_info.py
index 157efb683..deff58ebb 100644
--- a/examples/tutorials/core/plot_3_handle_probe_info.py
+++ b/examples/tutorials/core/plot_3_handle_probe_info.py
@@ -47,16 +47,23 @@ recording_2_shanks = recording.set_probe(other_probe, group_mode="by_shank")
 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 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)
-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')
+
+# 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
diff --git a/pyproject.toml b/pyproject.toml
index a2e1b3d3a..b26337ad0 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",
@@ -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/base.py b/src/spikeinterface/core/base.py
index 6fbc5ac28..4922707b3 100644
--- a/src/spikeinterface/core/base.py
+++ b/src/spikeinterface/core/base.py
@@ -550,7 +550,7 @@ class BaseExtractor:
                         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 @@ class BaseExtractor:
         """
         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 @@ class BaseExtractor:
         # 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/baserecording.py b/src/spikeinterface/core/baserecording.py
index 184959512..aab7577b3 100644
--- a/src/spikeinterface/core/baserecording.py
+++ b/src/spikeinterface/core/baserecording.py
@@ -45,7 +45,6 @@ class BaseRecording(BaseRecordingSnippets):
         self.annotate(is_filtered=False)
 
     def __repr__(self):
-
         extractor_name = self.__class__.__name__
         num_segments = self.get_num_segments()
 
@@ -182,7 +181,7 @@ class BaseRecording(BaseRecordingSnippets):
         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.
 
@@ -331,6 +330,9 @@ class BaseRecording(BaseRecordingSnippets):
         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
+        num_samples = rs.get_num_samples()
+        end_frame = int(min(end_frame, num_samples)) if end_frame is not None else 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"]
@@ -657,21 +659,21 @@ class BaseRecording(BaseRecordingSnippets):
         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 +681,27 @@ class BaseRecording(BaseRecordingSnippets):
         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/baserecordingsnippets.py b/src/spikeinterface/core/baserecordingsnippets.py
index 2a9f07595..428472bf9 100644
--- a/src/spikeinterface/core/baserecordingsnippets.py
+++ b/src/spikeinterface/core/baserecordingsnippets.py
@@ -48,7 +48,7 @@ class BaseRecordingSnippets(BaseExtractor):
     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 @@ class BaseRecordingSnippets(BaseExtractor):
         )
         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 @@ class BaseRecordingSnippets(BaseExtractor):
             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 7214d2780..fd68df9dd 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 @@ class BaseSorting(BaseExtractor):
     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 @@ class BaseSorting(BaseExtractor):
             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 @@ class BaseSorting(BaseExtractor):
     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 @@ class BaseSorting(BaseExtractor):
         """
         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 ec9bdfcc5..546ac85f9 100644
--- a/src/spikeinterface/core/binaryfolder.py
+++ b/src/spikeinterface/core/binaryfolder.py
@@ -53,7 +53,7 @@ class BinaryFolderRecording(BinaryRecordingExtractor):
             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 5d7253270..8fb9a78f2 100644
--- a/src/spikeinterface/core/binaryrecordingextractor.py
+++ b/src/spikeinterface/core/binaryrecordingextractor.py
@@ -147,7 +147,7 @@ class BinaryRecordingExtractor(BaseRecording):
         """
         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 664eac169..f3d8b3df7 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/datasets.py b/src/spikeinterface/core/datasets.py
index 59cfbfac5..c8d897d9f 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/core/frameslicerecording.py b/src/spikeinterface/core/frameslicerecording.py
index 533328ad4..f2ef38e69 100644
--- a/src/spikeinterface/core/frameslicerecording.py
+++ b/src/spikeinterface/core/frameslicerecording.py
@@ -30,7 +30,7 @@ class FrameSliceRecording(BaseRecording):
 
         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:
@@ -82,14 +82,10 @@ class FrameSliceRecordingSegment(BaseRecordingSegment):
         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):
-        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/generate.py b/src/spikeinterface/core/generate.py
index 05c1ebc7e..9924a2240 100644
--- a/src/spikeinterface/core/generate.py
+++ b/src/spikeinterface/core/generate.py
@@ -1134,7 +1134,7 @@ class NoiseGeneratorRecordingSegment(BaseRecordingSegment):
         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(
@@ -1143,9 +1143,6 @@ class NoiseGeneratorRecordingSegment(BaseRecordingSegment):
         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_within_block = start_frame % self.noise_block_size
         end_frame_within_block = end_frame % self.noise_block_size
         num_samples = end_frame - start_frame
@@ -1810,9 +1807,6 @@ class InjectTemplatesRecordingSegment(BaseRecordingSegment):
         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
-
         if channel_indices is None:
             n_channels = self.templates.shape[2]
         elif isinstance(channel_indices, slice):
@@ -1848,6 +1842,8 @@ class InjectTemplatesRecordingSegment(BaseRecordingSegment):
             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/core/numpyextractors.py b/src/spikeinterface/core/numpyextractors.py
index 62cd2fe2c..0ba1c0541 100644
--- a/src/spikeinterface/core/numpyextractors.py
+++ b/src/spikeinterface/core/numpyextractors.py
@@ -110,7 +110,7 @@ class NumpyRecordingSegment(BaseRecordingSegment):
         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 8b1b29354..b4c07e77c 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/segmentutils.py b/src/spikeinterface/core/segmentutils.py
index 959b7f8c4..b23b7202c 100644
--- a/src/spikeinterface/core/segmentutils.py
+++ b/src/spikeinterface/core/segmentutils.py
@@ -163,11 +163,6 @@ class ProxyConcatenateRecordingSegment(BaseRecordingSegment):
         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/core/sorting_tools.py b/src/spikeinterface/core/sorting_tools.py
index 2313e7d25..02f4529a9 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, 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.
@@ -61,6 +61,11 @@ 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
+    absolute_index: bool, default False
+        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.
+
     Returns
     -------
     spike_indices: dict[dict]:
@@ -82,10 +87,16 @@ 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 absolute_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/core/sortinganalyzer.py b/src/spikeinterface/core/sortinganalyzer.py
index 46d02099d..e439ddf1e 100644
--- a/src/spikeinterface/core/sortinganalyzer.py
+++ b/src/spikeinterface/core/sortinganalyzer.py
@@ -1229,7 +1229,7 @@ class SortingAnalyzer:
         """
         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.
 
@@ -1608,7 +1608,9 @@ class AnalyzerExtension:
         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":
diff --git a/src/spikeinterface/core/waveform_tools.py b/src/spikeinterface/core/waveform_tools.py
index acc368b2e..befc49d03 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/extractors/cbin_ibl.py b/src/spikeinterface/extractors/cbin_ibl.py
index e5ff8ed37..a6da19408 100644
--- a/src/spikeinterface/extractors/cbin_ibl.py
+++ b/src/spikeinterface/extractors/cbin_ibl.py
@@ -134,10 +134,6 @@ class CBinIblRecordingSegment(BaseRecordingSegment):
         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/cellexplorersortingextractor.py b/src/spikeinterface/extractors/cellexplorersortingextractor.py
index 3436313b4..9b77965c4 100644
--- a/src/spikeinterface/extractors/cellexplorersortingextractor.py
+++ b/src/spikeinterface/extractors/cellexplorersortingextractor.py
@@ -2,8 +2,7 @@ from __future__ import annotations
 
 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]
diff --git a/src/spikeinterface/extractors/extractorlist.py b/src/spikeinterface/extractors/extractorlist.py
index 228f7085b..8948aad60 100644
--- a/src/spikeinterface/extractors/extractorlist.py
+++ b/src/spikeinterface/extractors/extractorlist.py
@@ -45,6 +45,12 @@ from .spykingcircusextractors import SpykingCircusSortingExtractor, read_spyking
 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 .sinapsrecordingextractors import (
+    SinapsResearchPlatformRecordingExtractor,
+    SinapsResearchPlatformH5RecordingExtractor,
+    read_sinaps_research_platform,
+    read_sinaps_research_platform_h5,
+)
 
 # sorting in relation with simulator
 from .shybridextractors import (
@@ -77,6 +83,7 @@ recording_extractor_full_list = [
     CompressedBinaryIblExtractor,
     IblRecordingExtractor,
     MCSH5RecordingExtractor,
+    SinapsResearchPlatformRecordingExtractor,
 ]
 recording_extractor_full_list += neo_recording_extractors_list
 
diff --git a/src/spikeinterface/extractors/iblextractors.py b/src/spikeinterface/extractors/iblextractors.py
index 6e3ee59ca..2444314ae 100644
--- a/src/spikeinterface/extractors/iblextractors.py
+++ b/src/spikeinterface/extractors/iblextractors.py
@@ -269,10 +269,6 @@ class IblRecordingSegment(BaseRecordingSegment):
         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/neoextractors/intan.py b/src/spikeinterface/extractors/neoextractors/intan.py
index 7b3816a04..c37ff4780 100644
--- a/src/spikeinterface/extractors/neoextractors/intan.py
+++ b/src/spikeinterface/extractors/neoextractors/intan.py
@@ -53,6 +53,8 @@ class IntanRecordingExtractor(NeoBaseRecordingExtractor):
         )
 
         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):
diff --git a/src/spikeinterface/extractors/neoextractors/plexon2.py b/src/spikeinterface/extractors/neoextractors/plexon2.py
index fe24ba6f4..941158def 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,10 +34,15 @@ 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, **neo_kwargs
+            self,
+            stream_id=stream_id,
+            stream_name=stream_name,
+            all_annotations=all_annotations,
+            use_names_as_ids=use_names_as_ids,
+            **neo_kwargs,
         )
         self._kwargs.update({"file_path": str(file_path)})
 
diff --git a/src/spikeinterface/extractors/neoextractors/spikeglx.py b/src/spikeinterface/extractors/neoextractors/spikeglx.py
index 4f92fca98..adfd0f702 100644
--- a/src/spikeinterface/extractors/neoextractors/spikeglx.py
+++ b/src/spikeinterface/extractors/neoextractors/spikeglx.py
@@ -10,7 +10,7 @@ from spikeinterface.extractors.neuropixels_utils import get_neuropixels_sample_s
 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):
@@ -98,3 +98,47 @@ class SpikeGLXRecordingExtractor(NeoBaseRecordingExtractor):
 
 
 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
diff --git a/src/spikeinterface/extractors/nwbextractors.py b/src/spikeinterface/extractors/nwbextractors.py
index 1f413ae2b..ccb2ff437 100644
--- a/src/spikeinterface/extractors/nwbextractors.py
+++ b/src/spikeinterface/extractors/nwbextractors.py
@@ -932,11 +932,6 @@ class NwbRecordingSegment(BaseRecordingSegment):
         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/extractors/sinapsrecordingextractors.py b/src/spikeinterface/extractors/sinapsrecordingextractors.py
new file mode 100644
index 000000000..522f63976
--- /dev/null
+++ b/src/spikeinterface/extractors/sinapsrecordingextractors.py
@@ -0,0 +1,261 @@
+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"],
+            num_bits=sinaps_info["num_bits"],
+        )
+        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/postprocessing/spike_amplitudes.py b/src/spikeinterface/postprocessing/spike_amplitudes.py
index 09b46362e..aebfd1fd7 100644
--- a/src/spikeinterface/postprocessing/spike_amplitudes.py
+++ b/src/spikeinterface/postprocessing/spike_amplitudes.py
@@ -127,7 +127,7 @@ class ComputeSpikeAmplitudes(AnalyzerExtension):
         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, 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 d468bd90a..52a91342b 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 @@ class ComputeSpikeLocations(AnalyzerExtension):
     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)
 
@@ -140,7 +138,7 @@ class ComputeSpikeLocations(AnalyzerExtension):
         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, 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] = {}
diff --git a/src/spikeinterface/postprocessing/template_metrics.py b/src/spikeinterface/postprocessing/template_metrics.py
index d7179ffef..fdc4ef471 100644
--- a/src/spikeinterface/postprocessing/template_metrics.py
+++ b/src/spikeinterface/postprocessing/template_metrics.py
@@ -410,7 +410,10 @@ 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. 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
     ----------
@@ -454,11 +457,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
-    the peak.
-
-    Takes a numpy array of waveforms and returns an array with
-    recovery slopes per waveform.
+    in dV/dT. The returned slope is in units of (unit of template)
+    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
     ----------
@@ -619,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.
+    Compute the velocity above the max channel of the template in units um/s.
 
     Parameters
     ----------
@@ -697,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.
+    Compute the velocity below the max channel of the template in units um/s.
 
     Parameters
     ----------
@@ -775,7 +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.
+    Compute the exponential decay of the template amplitude over distance in units um/s.
 
     Parameters
     ----------
@@ -788,6 +790,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 +860,7 @@ def get_exp_decay(template, channel_locations, sampling_frequency=None, **kwargs
 
 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 in units um/s.
 
     Parameters
     ----------
@@ -867,6 +874,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"]
diff --git a/src/spikeinterface/preprocessing/average_across_direction.py b/src/spikeinterface/preprocessing/average_across_direction.py
index 71051f07a..53f0d5414 100644
--- a/src/spikeinterface/preprocessing/average_across_direction.py
+++ b/src/spikeinterface/preprocessing/average_across_direction.py
@@ -116,11 +116,6 @@ class AverageAcrossDirectionRecordingSegment(BasePreprocessorSegment):
         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 8c4970c4e..aa5c60018 100644
--- a/src/spikeinterface/preprocessing/decimate.py
+++ b/src/spikeinterface/preprocessing/decimate.py
@@ -123,13 +123,6 @@ class DecimateRecordingSegment(BaseRecordingSegment):
         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 80b212ded..31ebb9083 100644
--- a/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py
+++ b/src/spikeinterface/preprocessing/deepinterpolation/deepinterpolation.py
@@ -150,12 +150,6 @@ class DeepInterpolatedRecordingSegment(BasePreprocessorSegment):
 
         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 5e77cc8ae..f8aeac05f 100644
--- a/src/spikeinterface/preprocessing/directional_derivative.py
+++ b/src/spikeinterface/preprocessing/directional_derivative.py
@@ -103,11 +103,6 @@ class DirectionalDerivativeRecordingSegment(BasePreprocessorSegment):
         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 ca93d5836..5d483b3ce 100644
--- a/src/spikeinterface/preprocessing/phase_shift.py
+++ b/src/spikeinterface/preprocessing/phase_shift.py
@@ -84,10 +84,6 @@ class PhaseShiftRecordingSegment(BasePreprocessorSegment):
         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 3c0f76673..d2aef6ba3 100644
--- a/src/spikeinterface/preprocessing/remove_artifacts.py
+++ b/src/spikeinterface/preprocessing/remove_artifacts.py
@@ -263,11 +263,6 @@ class RemoveArtifactsRecordingSegment(BasePreprocessorSegment):
             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 4843df544..f8324817d 100644
--- a/src/spikeinterface/preprocessing/resample.py
+++ b/src/spikeinterface/preprocessing/resample.py
@@ -115,11 +115,6 @@ class ResampleRecordingSegment(BaseRecordingSegment):
         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 74d370b3a..3758d2955 100644
--- a/src/spikeinterface/preprocessing/silence_periods.py
+++ b/src/spikeinterface/preprocessing/silence_periods.py
@@ -111,12 +111,6 @@ class SilencedPeriodsRecordingSegment(BasePreprocessorSegment):
     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 cf4ba6a4a..0b2ff9449 100644
--- a/src/spikeinterface/preprocessing/zero_channel_pad.py
+++ b/src/spikeinterface/preprocessing/zero_channel_pad.py
@@ -75,11 +75,6 @@ class TracePaddedRecordingSegment(BasePreprocessorSegment):
         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 @@ class ZeroChannelPaddedRecordingSegment(BasePreprocessorSegment):
         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
diff --git a/src/spikeinterface/sorters/basesorter.py b/src/spikeinterface/sorters/basesorter.py
index 8c5262670..a9513f9f5 100644
--- a/src/spikeinterface/sorters/basesorter.py
+++ b/src/spikeinterface/sorters/basesorter.py
@@ -343,7 +343,7 @@ class BaseSorter:
         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 @@ class BaseSorter:
         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:
diff --git a/src/spikeinterface/sorters/external/kilosort2.py b/src/spikeinterface/sorters/external/kilosort2.py
index bdc037278..0425ad5e5 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 @@ class Kilosort2Sorter(KilosortBase, BaseSorter):
         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/kilosort2_5.py b/src/spikeinterface/sorters/external/kilosort2_5.py
index abde2ab32..b3d1718d5 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,
@@ -69,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",
@@ -220,7 +222,7 @@ class Kilosort2_5Sorter(KilosortBase, BaseSorter):
         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 3d2103ea6..f560fd7e1 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 @@ class Kilosort3Sorter(KilosortBase, BaseSorter):
         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
diff --git a/src/spikeinterface/sorters/external/kilosort4.py b/src/spikeinterface/sorters/external/kilosort4.py
index 47846f10c..a7f40a955 100644
--- a/src/spikeinterface/sorters/external/kilosort4.py
+++ b/src/spikeinterface/sorters/external/kilosort4.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 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 @@ class Kilosort4Sorter(BaseSorter):
         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 @@ class Kilosort4Sorter(BaseSorter):
         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)
diff --git a/src/spikeinterface/widgets/peaks_on_probe.py b/src/spikeinterface/widgets/peaks_on_probe.py
new file mode 100644
index 000000000..0d23b6c67
--- /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/widget_list.py b/src/spikeinterface/widgets/widget_list.py
index d6df59b0f..6367e098e 100644
--- a/src/spikeinterface/widgets/widget_list.py
+++ b/src/spikeinterface/widgets/widget_list.py
@@ -13,6 +13,7 @@ from .isi_distribution import ISIDistributionWidget
 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 @@ widget_list = [
     MultiCompAgreementBySorterWidget,
     MultiCompGraphWidget,
     PeakActivityMapWidget,
+    PeaksOnProbeWidget,
     PotentialMergesWidget,
     ProbeMapWidget,
     QualityMetricsWidget,
@@ -123,6 +125,7 @@ plot_multicomparison_agreement = MultiCompGlobalAgreementWidget
 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


Test patch:
diff --git a/.github/run_tests.sh b/.github/run_tests.sh
index 04a6b5ac6..558e0b64d 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 000000000..1c426ba11
--- /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/src/spikeinterface/core/tests/test_binaryrecordingextractor.py b/src/spikeinterface/core/tests/test_binaryrecordingextractor.py
index 61af8f322..8ea99e3d0 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)
diff --git a/src/spikeinterface/core/tests/test_sortinganalyzer.py b/src/spikeinterface/core/tests/test_sortinganalyzer.py
index d78093214..7456680b2 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)
diff --git a/src/spikeinterface/extractors/tests/common_tests.py b/src/spikeinterface/extractors/tests/common_tests.py
index dcbd2304f..5432efa9f 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 97e68146a..8abccc670 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 379bf00c6..acd7ebe8a 100644
--- a/src/spikeinterface/extractors/tests/test_neoextractors.py
+++ b/src/spikeinterface/extractors/tests/test_neoextractors.py
@@ -351,8 +351,10 @@ class EDFRecordingTest(RecordingCommonTestSuite, unittest.TestCase):
         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/common_extension_tests.py b/src/spikeinterface/postprocessing/tests/common_extension_tests.py
index c99b2d4f3..bb2f5aaaf 100644
--- a/src/spikeinterface/postprocessing/tests/common_extension_tests.py
+++ b/src/spikeinterface/postprocessing/tests/common_extension_tests.py
@@ -77,19 +77,6 @@ class AnalyzerExtensionCommonTestSuite:
         )
         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
 
diff --git a/src/spikeinterface/postprocessing/tests/test_principal_component.py b/src/spikeinterface/postprocessing/tests/test_principal_component.py
index 08ec32c6c..38ae3b2c5 100644
--- a/src/spikeinterface/postprocessing/tests/test_principal_component.py
+++ b/src/spikeinterface/postprocessing/tests/test_principal_component.py
@@ -136,7 +136,7 @@ class TestPrincipalComponentsExtension(AnalyzerExtensionCommonTestSuite):
         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 3ae03abff..0369bca86 100644
--- a/src/spikeinterface/sorters/tests/test_container_tools.py
+++ b/src/spikeinterface/sorters/tests/test_container_tools.py
@@ -8,6 +8,7 @@ import spikeinterface as si
 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")