Repository: SpikeInterface/spikeinterface
Pull request #2398 (2024-01-11 16:04:47)

Implementation of SortingResult (or SortingAnalyse ?)
**EDITED frequently**

Here a PR foolowing this discussion #2282.

Finally the name choosen is `SortingResult`. Can still be change.
`SortingAnalyse` or `SortingAnalyser` could be also candidate.

I started an implementation that will make everything more flexible.
It looks like the `WaveformExtractor` wihout the waveforms and template handling.
The implementation is really more simple.

Importantly, the original recording and sorting do not need to be kept.
A copy of the sorting is done internally.
And rec_attributes are used when possible.
Some extension will need the recording but not all.

This object focus on:
 * handleExtension
 * handle persistency
 * handle save_as to any format
 * handle select_units to any format

The plan is to make a release 0.100.0 without this object.
Then we will be able to merge in main (if ready) for the next relase (or the following one).

Step1
* [X] start Sortingresult and ResultExtension
   * [X] **memory** format
   * [X] **binary_folder** format
   * [x] **zarr** format
   * [x] handle sparsity
   * [x] handle spike sampling
* [x] Create new ResultExtension
  * [x] Waveforms
  * [x] Templates
* [ ] Change BaseWaveformExtractorExtension into ResultExtension
* [ ] propagate in quality metrics
* [ ] propagate in widget
* [ ] make compatibility of old WaveformExtractor that was saved in previous version.



```python
import spikeinterface as si

# Extract dense waveforms and save to disk with binary_folder format.
sortres = si.start_sorting_result(sorting, recording, format="binary_folder", folder="/path/to_my/result", sparse=True)

# Can be reload
sortres = si.load_sorting_result(folder="/path/to_my/result")

# Can some extension need random spikes
sortres.select_random_spikes(max_spikes_per_unit=300, seed=2205)
# Then can run extensions (waveforms and templates coresponf to what did WaveformsExtractor)
sortres = si.compute("waveforms", ...)
sortres = si.compute("templates", ...)



# Can be copy to another format (extensions are propagated)
sortres2 = sortres.save_as(format="memory")
sortres3 = sortres.save_as(format="zarr", folder="/path/to_my/result.zarr")

# Can make a copy with a subset of units (extensions are propagated for the unit subset)
sortres4 = sortres.select_units(unit_ids=sorting.units_ids[:5], format="memory")
sortres5 = sortres.select_units(unit_ids=sorting.units_ids[:5], format="binary_folder", folder="/result_5units")
```







Patch:
diff --git a/src/spikeinterface/core/__init__.py b/src/spikeinterface/core/__init__.py
index 9d948e7e0..1d091c60d 100644
--- a/src/spikeinterface/core/__init__.py
+++ b/src/spikeinterface/core/__init__.py
@@ -107,6 +107,8 @@ from .waveform_tools import extract_waveforms_to_buffers
 from .snippets_tools import snippets_from_sorting
 
 # waveform extractor
+# Important not for compatibility!!
+# This wil be commented after 0.100 relase but the module will not be removed.
 from .waveform_extractor import (
     WaveformExtractor,
     BaseWaveformExtractorExtension,
@@ -141,3 +143,16 @@ from .template_tools import (
 from .sparsity import ChannelSparsity, compute_sparsity, estimate_sparsity
 
 from .template import Templates
+
+# SortingResult and ResultExtension
+from .sortingresult import SortingResult, ResultExtension, start_sorting_result, load_sorting_result
+from .result_core import (
+    ComputeWaveforms, compute_waveforms,
+    ComputeTemplates, compute_templates,
+    ComputeFastTemplates, compute_fast_templates,
+)
+
+# Important not for compatibility!!
+# This wil be uncommented after 0.100
+# from .waveforms_extractor_backwards_compatibility import extract_waveforms, load_waveforms
+
diff --git a/src/spikeinterface/core/generate.py b/src/spikeinterface/core/generate.py
index 46af73624..84b8b4a90 100644
--- a/src/spikeinterface/core/generate.py
+++ b/src/spikeinterface/core/generate.py
@@ -1259,7 +1259,7 @@ def generate_single_fake_waveform(
     bins = np.arange(-n, n + 1)
     smooth_kernel = np.exp(-(bins**2) / (2 * smooth_size**2))
     smooth_kernel /= np.sum(smooth_kernel)
-    smooth_kernel = smooth_kernel[4:]
+    # smooth_kernel = smooth_kernel[4:]
     wf = np.convolve(wf, smooth_kernel, mode="same")
 
     # ensure the the peak to be extatly at nbefore (smooth can modify this)
diff --git a/src/spikeinterface/core/result_core.py b/src/spikeinterface/core/result_core.py
new file mode 100644
index 000000000..17174c84a
--- /dev/null
+++ b/src/spikeinterface/core/result_core.py
@@ -0,0 +1,269 @@
+"""
+Implement ResultExtension that are essential and imported in core
+  * ComputeWaveforms
+  * ComputeTemplates
+Theses two classes replace the WaveformExtractor
+
+It also implement ComputeFastTemplates which is equivalent but without extacting waveforms.
+"""
+
+import numpy as np
+
+from .sortingresult import ResultExtension, register_result_extension
+from .waveform_tools import extract_waveforms_to_single_buffer, estimate_templates
+
+class ComputeWaveforms(ResultExtension):
+    """
+    ResultExtension that extract some waveforms of each units.
+
+    The sparsity is controlled by the SortingResult sparsity.
+    """
+    extension_name = "waveforms"
+    depend_on = []
+    need_recording = True
+    use_nodepipeline = False
+
+    def _run(self, **kwargs):
+        self.data.clear()
+
+        if self.sorting_result.random_spikes_indices is None:
+            raise ValueError("compute_waveforms need SortingResult.select_random_spikes() need to be run first")
+
+        recording = self.sorting_result.recording
+        sorting = self.sorting_result.sorting
+        unit_ids = sorting.unit_ids
+
+        # retrieve spike vector and the sampling
+        spikes = sorting.to_spike_vector()
+        some_spikes = spikes[self.sorting_result.random_spikes_indices]
+        
+        nbefore = int(self.params["ms_before"] * sorting.sampling_frequency / 1000.0)
+        nafter = int(self.params["ms_after"] * sorting.sampling_frequency / 1000.0)
+
+        if self.format == "binary_folder":
+            # in that case waveforms are extacted directly in files
+            file_path = self._get_binary_extension_folder() / "waveforms.npy"
+            mode = "memmap"
+            copy = False
+        else:
+            file_path = None
+            mode = "shared_memory"
+            copy = True
+
+        if self.sparsity is None:
+            sparsity_mask = None
+        else:
+            sparsity_mask = self.sparsity.mask
+
+        # TODO propagate some job_kwargs
+        job_kwargs = dict(n_jobs=-1)
+
+        all_waveforms = extract_waveforms_to_single_buffer(
+            recording,
+            some_spikes,
+            unit_ids,
+            nbefore,
+            nafter,
+            mode=mode,
+            return_scaled=self.params["return_scaled"],
+            file_path=file_path,
+            dtype=self.params["dtype"],
+            sparsity_mask=sparsity_mask,
+            copy=copy,
+            job_name="compute_waveforms",
+            **job_kwargs,
+        )
+
+        self.data["waveforms"] = all_waveforms
+
+    def _set_params(self, 
+            ms_before: float = 1.0,
+            ms_after: float = 2.0,
+            return_scaled: bool = True,
+            dtype=None,
+        ):
+        recording = self.sorting_result.recording
+        if dtype is None:
+            dtype = recording.get_dtype()
+
+        if return_scaled:
+            # check if has scaled values:
+            if not recording.has_scaled():
+                print("Setting 'return_scaled' to False")
+                return_scaled = False
+
+        if np.issubdtype(dtype, np.integer) and return_scaled:
+            dtype = "float32"
+
+        dtype = np.dtype(dtype)
+
+        params = dict(
+            ms_before=float(ms_before),
+            ms_after=float(ms_after),
+            return_scaled=return_scaled,
+            dtype=dtype.str,
+        )
+        return params
+
+    def _select_extension_data(self, unit_ids):
+        keep_unit_indices = np.flatnonzero(np.isin(self.sorting_result.unit_ids, unit_ids))
+        spikes = self.sorting_result.sorting.to_spike_vector()
+        some_spikes = spikes[self.sorting_result.random_spikes_indices]
+        keep_spike_mask = np.isin(some_spikes["unit_index"], keep_unit_indices)
+
+        new_data = dict()
+        new_data["waveforms"] = self.data["waveforms"][keep_spike_mask, :, :]
+
+        return new_data
+
+
+
+
+compute_waveforms = ComputeWaveforms.function_factory()
+register_result_extension(ComputeWaveforms)
+
+
+class ComputeTemplates(ResultExtension):
+    """
+    ResultExtension that compute templates (average, str, median, percentile, ...)
+    
+    This must be run after "waveforms" extension (`SortingResult.compute("waveforms")`)
+
+    Note that when "waveforms" is already done, then the recording is not needed anymore for this extension.
+    """
+    extension_name = "templates"
+    depend_on = ["waveforms"]
+    need_recording = False
+    use_nodepipeline = False
+
+    def _run(self, **kwargs):
+        
+        unit_ids = self.sorting_result.unit_ids
+        channel_ids = self.sorting_result.channel_ids
+        waveforms_extension = self.sorting_result.get_extension("waveforms")
+        waveforms = waveforms_extension.data["waveforms"]
+        
+        num_samples = waveforms.shape[1]
+        
+        for operator in self.params["operators"]:
+            if isinstance(operator, str) and operator in ("average", "std", "median"):
+                key = operator
+            elif isinstance(operator, (list, tuple)):
+                operator, percentile = operator
+                assert operator == "percentile"
+                key = f"pencentile_{percentile}"
+            else:
+                raise ValueError(f"ComputeTemplates: wrong operator {operator}")
+            self.data[key] = np.zeros((unit_ids.size, num_samples, channel_ids.size))
+
+        spikes = self.sorting_result.sorting.to_spike_vector()
+        some_spikes = spikes[self.sorting_result.random_spikes_indices]
+        for unit_index, unit_id in enumerate(unit_ids):
+            spike_mask = some_spikes["unit_index"] == unit_index
+            wfs = waveforms[spike_mask, :, :]
+            if wfs.shape[0] == 0:
+                continue
+            
+            for operator in self.params["operators"]:
+                if operator == "average":
+                    arr = np.average(wfs, axis=0)
+                    key = operator
+                elif operator == "std":
+                    arr = np.std(wfs, axis=0)
+                    key = operator
+                elif operator == "median":
+                    arr = np.median(wfs, axis=0)
+                    key = operator
+                elif isinstance(operator, (list, tuple)):
+                    operator, percentile = operator
+                    arr = np.percentile(wfs, percentile, axis=0)
+                    key = f"pencentile_{percentile}"
+
+                if self.sparsity is None:
+                    self.data[key][unit_index, :, :] = arr
+                else:
+                    channel_indices = self.sparsity.unit_id_to_channel_indices[unit_id]
+                    self.data[key][unit_index, :, :][:, channel_indices] = arr[:, :channel_indices.size]
+
+    def _set_params(self, operators = ["average", "std"]):
+        assert isinstance(operators, list)
+        for operator in operators:
+            if isinstance(operator, str):
+                assert operator in ("average", "std", "median", "mad")
+            else:
+                assert isinstance(operator, (list, tuple))
+                assert len(operator) == 2
+                assert operator[0] == "percentile"
+
+        params = dict(operators=operators)
+        return params
+
+    def _select_extension_data(self, unit_ids):
+        keep_unit_indices = np.flatnonzero(np.isin(self.sorting_result.unit_ids, unit_ids))
+
+        new_data = dict()
+        for key, arr in self.data.items():
+            new_data[key] = arr[keep_unit_indices, :, :]
+
+        return new_data
+
+compute_templates = ComputeTemplates.function_factory()
+register_result_extension(ComputeTemplates)
+
+
+class ComputeFastTemplates(ResultExtension):
+    """
+    ResultExtension which is similar to the extension "templates" (ComputeTemplates) **but only for average**.
+    This is way faster because it do not need "waveforms" to be computed first. 
+    """
+    extension_name = "fast_templates"
+    depend_on = []
+    need_recording = True
+    use_nodepipeline = False
+
+    def _run(self, **kwargs):
+        self.data.clear()
+
+        if self.sorting_result.random_spikes_indices is None:
+            raise ValueError("compute_waveforms need SortingResult.select_random_spikes() need to be run first")
+
+        recording = self.sorting_result.recording
+        sorting = self.sorting_result.sorting
+        unit_ids = sorting.unit_ids
+
+        # retrieve spike vector and the sampling
+        spikes = sorting.to_spike_vector()
+        some_spikes = spikes[self.sorting_result.random_spikes_indices]
+        
+        nbefore = int(self.params["ms_before"] * sorting.sampling_frequency / 1000.0)
+        nafter = int(self.params["ms_after"] * sorting.sampling_frequency / 1000.0)
+
+        return_scaled = self.params["return_scaled"]
+
+        # TODO jobw_kwargs
+        self.data["average"] = estimate_templates(recording, some_spikes, unit_ids, nbefore, nafter, return_scaled=return_scaled)
+    
+    def _set_params(self,
+            ms_before: float = 1.0,
+            ms_after: float = 2.0,
+            return_scaled: bool = True,
+        ):
+        params = dict(
+            ms_before=float(ms_before),
+            ms_after=float(ms_after),
+            return_scaled=return_scaled,
+        )
+        return params
+
+        
+
+    def _select_extension_data(self, unit_ids):
+        keep_unit_indices = np.flatnonzero(np.isin(self.sorting_result.unit_ids, unit_ids))
+
+        new_data = dict()
+        new_data["average"] = self.data["average"][keep_unit_indices, :, :]
+
+        return new_data
+
+compute_fast_templates = ComputeFastTemplates.function_factory()
+register_result_extension(ComputeFastTemplates)
diff --git a/src/spikeinterface/core/sortingresult.py b/src/spikeinterface/core/sortingresult.py
new file mode 100644
index 000000000..1b5b33bd7
--- /dev/null
+++ b/src/spikeinterface/core/sortingresult.py
@@ -0,0 +1,1324 @@
+from __future__ import annotations
+from typing import Literal, Optional
+
+from pathlib import Path
+import os
+import json
+import pickle
+import weakref
+import shutil
+import warnings
+
+import numpy as np
+
+import probeinterface
+
+import spikeinterface
+
+from .baserecording import BaseRecording
+from .basesorting import BaseSorting
+
+from .base import load_extractor
+from .recording_tools import check_probe_do_not_overlap, get_rec_attributes
+from .sorting_tools import random_spikes_selection
+from .core_tools import check_json
+from .numpyextractors import SharedMemorySorting
+from .sparsity import ChannelSparsity, estimate_sparsity
+from .sortingfolder import NumpyFolderSorting
+from .zarrextractors import get_default_zarr_compressor, ZarrSortingExtractor
+
+
+
+
+# high level function
+def start_sorting_result(sorting, recording, format="memory", folder=None, 
+    sparse=True, sparsity=None, **sparsity_kwargs
+    ):
+    """
+    Create a SortingResult by pairing a Sorting and the corresponding Recording.
+
+    This object will handle a list of ResultExtension for all the post processing steps like: waveforms,
+    templates, unit locations, spike locations, quality mertics ...
+
+    This object will be also use used for ploting purpose.
+
+
+    Parameters
+    ----------
+    sorting: Sorting
+        The sorting object
+    recording: Recording
+        The recording object
+    folder: str or Path or None, default: None
+        The folder where waveforms are cached
+    format: "memory | "binary_folder" | "zarr", default: "memory"
+        The mode to store waveforms. If "folder", waveforms are stored on disk in the specified folder.
+        The "folder" argument must be specified in case of mode "folder".
+        If "memory" is used, the waveforms are stored in RAM. Use this option carefully!
+    sparse: bool, default: True
+        If True, then a sparsity mask is computed usingthe `estimate_sparsity()` function is run using
+        a few spikes to get an estimate of dense templates to create a ChannelSparsity object.
+        Then, the sparsity will be propagated to all ResultExtention that handle sparsity (like wavforms, pca, ...)
+        You can control `estimate_sparsity()` : all extra arguments are propagated to it (included job_kwargs)
+    sparsity: ChannelSparsity or None, default: None
+        The sparsity used to compute waveforms. If this is given, `sparse` is ignored. Default None.
+
+    Returns
+    -------
+    sorting_result: SortingResult
+        The SortingResult object
+
+    Examples
+    --------
+    >>> import spikeinterface as si
+
+    >>> # Extract dense waveforms and save to disk with binary_folder format.
+    >>> sortres = si.start_sorting_result(sorting, recording, format="binary_folder", folder="/path/to_my/result")
+
+    >>> # Can be reload
+    >>> sortres = si.load_sorting_result(folder="/path/to_my/result")
+
+    >>> # Can run extension
+    >>> sortres = si.compute("unit_locations", ...)
+
+    >>> # Can be copy to another format (extensions are propagated)
+    >>> sortres2 = sortres.save_as(format="memory")
+    >>> sortres3 = sortres.save_as(format="zarr", folder="/path/to_my/result.zarr")
+
+    >>> # Can make a copy with a subset of units (extensions are propagated for the unit subset)
+    >>> sortres4 = sortres.select_units(unit_ids=sorting.units_ids[:5], format="memory")
+    >>> sortres5 = sortres.select_units(unit_ids=sorting.units_ids[:5], format="binary_folder", folder="/result_5units")
+    """
+
+    # handle sparsity
+    if sparsity is not None:
+        # some checks
+        assert isinstance(sparsity, ChannelSparsity), "'sparsity' must be a ChannelSparsity object"
+        assert np.array_equal(sorting.unit_ids, sparsity.unit_ids), "start_sorting_result(): if external sparsity is given unit_ids must correspond"
+        assert np.array_equal(recording.channel_ids, recording.channel_ids), "start_sorting_result(): if external sparsity is given unit_ids must correspond"
+    elif sparse:
+        sparsity = estimate_sparsity( recording, sorting, **sparsity_kwargs)
+    else:
+        sparsity = None
+
+    sorting_result = SortingResult.create(
+        sorting, recording, format=format, folder=folder, sparsity=sparsity)
+
+    return sorting_result
+
+
+def load_sorting_result(folder, load_extensions=True, format="auto"):
+    """
+    Load a SortingResult object from disk.
+
+    Parameters
+    ----------
+    folder : str or Path
+        The folder / zarr folder where the waveform extractor is stored
+    load_extensions : bool, default: True
+        Load all extensions or not.
+    format: "auto" | "binary_folder" | "zarr"
+        The format of the folder.
+
+    Returns
+    -------
+    sorting_result: SortingResult
+        The loaded SortingResult
+
+    """
+
+    return SortingResult.load(folder, load_extensions=load_extensions, format=format)
+
+
+class SortingResult:
+    """
+    Class to make a pair of Recording-Sorting which will be used used for all post postprocessing,
+    visualization and quality metric computation.
+
+    This internaly maintain a list of computed ResultExtention (waveform, pca, unit position, spike poisition, ...).
+
+    This can live in memory and/or can be be persistent to disk in 2 internal formats (folder/json/npz or zarr).
+    A SortingResult can be transfer to another format using `save_as()`
+
+    This handle unit sparsity that can be propagated to ResultExtention.
+
+    This handle spike sampling that can be propagated to ResultExtention : work only on a subset of spikes.
+
+    This internally save a copy of the Sorting and extract main recording attributes (without traces) so
+    the SortingResult object can be reload even if references to the original sorting and/or to the original recording
+    are lost.
+    """
+    def __init__(self, sorting=None, recording=None, rec_attributes=None, format=None, sparsity=None, random_spikes_indices=None):
+        # very fast init because checks are done in load and create
+        self.sorting = sorting
+        # self.recorsding will be a property
+        self._recording = recording
+        self.rec_attributes = rec_attributes
+        self.format = format
+        self.sparsity = sparsity
+        self.random_spikes_indices = random_spikes_indices
+
+        # extensions are not loaded at init
+        self.extensions = dict()
+
+    def __repr__(self) -> str:
+        clsname = self.__class__.__name__
+        nseg = self.get_num_segments()
+        nchan = self.get_num_channels()
+        nunits = self.sorting.get_num_units()
+        txt = f"{clsname}: {nchan} channels - {nunits} units - {nseg} segments - {self.format}"
+        if self.is_sparse():
+            txt += " - sparse"
+        if self.has_recording():
+            txt += " - has recording"
+        ext_txt = f"Loaded {len(self.extensions)} extenstions: " + ", ".join(self.extensions.keys())
+        txt += "\n" + ext_txt
+        return txt
+
+    ## create and load zone
+
+    @classmethod
+    def create(cls,
+            sorting: BaseSorting,
+            recording: BaseRecording,
+            format: Literal["memory", "binary_folder", "zarr", ] = "memory",
+            folder=None,
+            sparsity=None,
+        ):
+        # some checks
+        assert sorting.sampling_frequency == recording.sampling_frequency
+        # check that multiple probes are non-overlapping
+        all_probes = recording.get_probegroup().probes
+        check_probe_do_not_overlap(all_probes)
+
+        if format == "memory":
+            sortres = cls.create_memory(sorting, recording, sparsity, rec_attributes=None)
+        elif format == "binary_folder":
+            cls.create_binary_folder(folder, sorting, recording, sparsity, rec_attributes=None)
+            sortres = cls.load_from_binary_folder(folder, recording=recording)
+            sortres.folder = folder
+        elif format == "zarr":
+            cls.create_zarr(folder, sorting, recording, sparsity, rec_attributes=None)
+            sortres = cls.load_from_zarr(folder, recording=recording)
+            sortres.folder = folder
+        else:
+            raise ValueError("SortingResult.create: wrong format")
+
+        return sortres
+
+    @classmethod
+    def load(cls, folder, recording=None, load_extensions=True, format="auto"):
+        """
+        Load folder or zarr.
+        The recording can be given if the recording location has changed.
+        Otherwise the recording is loaded when possible.
+        """
+        folder = Path(folder)
+        assert folder.is_dir(), "Waveform folder does not exists"
+        if format == "auto":
+            # make better assumption and check for auto guess format
+            if folder.suffix == ".zarr":
+                format = "zarr"
+            else:
+                format = "binary_folder"
+        
+        if format == "binary_folder":
+            sortres = SortingResult.load_from_binary_folder(folder, recording=recording)
+        elif format == "zarr":
+            sortres = SortingResult.load_from_zarr(folder, recording=recording)
+            
+        
+        sortres.folder = folder
+
+        if load_extensions:
+            sortres.load_all_saved_extension()
+
+        return sortres
+
+    @classmethod
+    def create_memory(cls, sorting, recording, sparsity, rec_attributes):
+        # used by create and save_as
+
+        if rec_attributes is None:
+            assert recording is not None
+            rec_attributes = get_rec_attributes(recording)
+            rec_attributes["probegroup"] = recording.get_probegroup()
+        else:
+            # a copy is done to avoid shared dict between instances (which can block garbage collector)
+            rec_attributes = rec_attributes.copy()
+
+        # a copy of sorting is created directly in shared memory format to avoid further duplication of spikes.
+        sorting_copy = SharedMemorySorting.from_sorting(sorting)
+        sortres = SortingResult(sorting=sorting_copy, recording=recording, rec_attributes=rec_attributes,
+                                format="memory", sparsity=sparsity)
+        return sortres
+
+    @classmethod
+    def create_binary_folder(cls, folder, sorting, recording, sparsity, rec_attributes):
+        # used by create and save_as
+
+        assert recording is not None, "To create a SortingResult you need recording not None"
+
+        folder = Path(folder)
+        if folder.is_dir():
+            raise ValueError(f"Folder already exists {folder}")
+        folder.mkdir(parents=True)
+
+
+        info_file = folder / f"spikeinterface_info.json"
+        info = dict(
+            version=spikeinterface.__version__,
+            dev_mode=spikeinterface.DEV_MODE,
+            object="SortingResult",
+        )
+        with open(info_file, mode="w") as f:
+            json.dump(check_json(info), f, indent=4)
+
+        # save a copy of the sorting
+        NumpyFolderSorting.write_sorting(sorting, folder / "sorting")
+
+        # save recording and sorting provenance
+        if recording.check_serializability("json"):
+            recording.dump(folder / "recording.json", relative_to=folder)
+        elif recording.check_serializability("pickle"):
+            recording.dump(folder / "recording.pickle", relative_to=folder)
+
+        if sorting.check_serializability("json"):
+            sorting.dump(folder / "sorting_provenance.json", relative_to=folder)
+        elif sorting.check_serializability("pickle"):
+            sorting.dump(folder / "sorting_provenance.pickle", relative_to=folder)
+
+        # dump recording attributes
+        probegroup = None
+        rec_attributes_file = folder / "recording_info" / "recording_attributes.json"
+        rec_attributes_file.parent.mkdir()
+        if rec_attributes is None:
+            assert recording is not None
+            rec_attributes = get_rec_attributes(recording)
+            rec_attributes_file.write_text(json.dumps(check_json(rec_attributes), indent=4), encoding="utf8")
+            probegroup = recording.get_probegroup()
+        else:
+            rec_attributes_copy = rec_attributes.copy()
+            probegroup = rec_attributes_copy.pop("probegroup")
+            rec_attributes_file.write_text(json.dumps(check_json(rec_attributes_copy), indent=4), encoding="utf8")
+
+        if probegroup is not None:
+            probegroup_file = folder / "recording_info" / "probegroup.json"
+            probeinterface.write_probeinterface(probegroup_file, probegroup)
+
+        if sparsity is not None:
+            np.save(folder / "sparsity_mask.npy", sparsity.mask)
+            # with open(folder / "sparsity.json", mode="w") as f:
+            #     json.dump(check_json(sparsity.to_dict()), f)
+
+    @classmethod
+    def load_from_binary_folder(cls, folder, recording=None):
+        folder = Path(folder)
+        assert folder.is_dir(), f"This folder does not exists {folder}"
+
+        # load internal sorting copy and make it sharedmem
+        sorting = SharedMemorySorting.from_sorting(NumpyFolderSorting(folder / "sorting"))
+        
+        # load recording if possible
+        if recording is None:
+            # try to load the recording if not provided
+            for type in ("json", "pickle"):
+                filename = folder / f"recording.{type}"
+                if filename.exists():
+                    try:
+                        recording = load_extractor(filename, base_folder=folder)
+                        break
+                    except:
+                        recording = None
+        else:
+            # TODO maybe maybe not??? : do we need to check  attributes match internal rec_attributes
+            # Note this will make the loading too slow
+            pass
+
+        # recording attributes
+        rec_attributes_file = folder / "recording_info" / "recording_attributes.json"
+        if not rec_attributes_file.exists():
+            raise ValueError("This folder is not a SortingResult with format='binary_folder'")
+        with open(rec_attributes_file, "r") as f:
+            rec_attributes = json.load(f)
+        # the probe is handle ouside the main json
+        probegroup_file = folder / "recording_info" / "probegroup.json"
+
+        if probegroup_file.is_file():
+            rec_attributes["probegroup"] = probeinterface.read_probeinterface(probegroup_file)
+        else:
+            rec_attributes["probegroup"] = None
+        
+        # sparsity
+        # sparsity_file = folder / "sparsity.json"
+        sparsity_file = folder / "sparsity_mask.npy"
+        if sparsity_file.is_file():
+            sparsity_mask = np.load(sparsity_file)
+            # with open(sparsity_file, mode="r") as f:
+            #     sparsity = ChannelSparsity.from_dict(json.load(f))
+            sparsity = ChannelSparsity(sparsity_mask, sorting.unit_ids, rec_attributes["channel_ids"])
+        else:
+            sparsity = None
+
+        selected_spike_file = folder / "random_spikes_indices.npy"
+        if selected_spike_file.is_file():
+            random_spikes_indices = np.load(selected_spike_file)
+        else:
+            random_spikes_indices = None
+
+        sortres = SortingResult(
+            sorting=sorting,
+            recording=recording,
+            rec_attributes=rec_attributes,
+            format="binary_folder",
+            sparsity=sparsity,
+            random_spikes_indices=random_spikes_indices)
+
+        return sortres
+    
+    def _get_zarr_root(self, mode="r+"):
+        import zarr
+        zarr_root = zarr.open(self.folder, mode=mode)
+        return zarr_root
+
+    @classmethod
+    def create_zarr(cls, folder, sorting, recording, sparsity, rec_attributes):
+        # used by create and save_as
+        import zarr
+        import numcodecs
+
+        folder = Path(folder)
+        # force zarr sufix
+        if folder.suffix != ".zarr":
+            folder = folder.parent / f"{folder.stem}.zarr"
+
+        if folder.is_dir():
+            raise ValueError(f"Folder already exists {folder}")
+
+        zarr_root = zarr.open(folder, mode="w")
+
+        info = dict(
+            version=spikeinterface.__version__,
+            dev_mode=spikeinterface.DEV_MODE,
+            object="SortingResult"
+        )
+        zarr_root.attrs["spikeinterface_info"] = check_json(info)
+        
+        # the recording
+        rec_dict = recording.to_dict(relative_to=folder, recursive=True)
+        zarr_rec = np.array([rec_dict], dtype=object)
+        if recording.check_serializability("json"):
+            # zarr_root.create_dataset("recording", data=rec_dict, object_codec=numcodecs.JSON())
+            zarr_root.create_dataset("recording", data=zarr_rec, object_codec=numcodecs.JSON())
+        elif recording.check_serializability("pickle"):
+            # zarr_root.create_dataset("recording", data=rec_dict, object_codec=numcodecs.Pickle())
+            zarr_root.create_dataset("recording", data=zarr_rec, object_codec=numcodecs.Pickle())
+        else:
+            warnings.warn("SortingResult with zarr : the Recording is not json serializable, the recording link will be lost for futur load")
+        
+
+        # sorting provenance
+        sort_dict = sorting.to_dict(relative_to=folder, recursive=True)
+        if sorting.check_serializability("json"):
+            # zarr_root.attrs["sorting_provenance"] = check_json(sort_dict)
+            zarr_sort = np.array([sort_dict], dtype=object)
+            zarr_root.create_dataset("sorting_provenance", data=zarr_sort, object_codec=numcodecs.JSON())
+        elif sorting.check_serializability("pickle"):
+            # zarr_root.create_dataset("sorting_provenance", data=sort_dict, object_codec=numcodecs.Pickle())
+            zarr_sort = np.array([sort_dict], dtype=object)
+            zarr_root.create_dataset("sorting_provenance", data=zarr_sort, object_codec=numcodecs.Pickle())
+
+        # else:
+        #     warnings.warn("SortingResult with zarr : the sorting provenance is not json serializable, the sorting provenance link will be lost for futur load")
+
+        recording_info = zarr_root.create_group("recording_info")
+
+        if rec_attributes is None:
+            assert recording is not None
+            rec_attributes = get_rec_attributes(recording)
+            probegroup = recording.get_probegroup()
+        else:
+            rec_attributes = rec_attributes.copy()
+            probegroup = rec_attributes.pop("probegroup")
+        
+        recording_info.attrs["recording_attributes"] = check_json(rec_attributes)
+        # recording_info.create_dataset("recording_attributes", data=check_json(rec_attributes), object_codec=numcodecs.JSON())
+
+        if probegroup is not None:
+            recording_info.attrs["probegroup"] = check_json(probegroup.to_dict())
+            # recording_info.create_dataset("probegroup", data=check_json(probegroup.to_dict()), object_codec=numcodecs.JSON())
+
+        if sparsity is not None:
+            # zarr_root.attrs["sparsity"] = check_json(sparsity.to_dict())
+            # zarr_root.create_dataset("sparsity", data=check_json(sparsity.to_dict()), object_codec=numcodecs.JSON())
+            zarr_root.create_dataset("sparsity_mask", data=sparsity.mask)
+
+        # write sorting copy
+        from .zarrextractors import add_sorting_to_zarr_group
+        # Alessio : we need to find a way to propagate compressor for all steps.
+        # kwargs = dict(compressor=...)
+        zarr_kwargs = dict()
+        add_sorting_to_zarr_group(sorting, zarr_root.create_group("sorting"), **zarr_kwargs)
+
+        recording_info = zarr_root.create_group("extensions")
+
+
+    @classmethod
+    def load_from_zarr(cls, folder, recording=None):
+        import zarr
+        folder = Path(folder)
+        assert folder.is_dir(), f"This folder does not exists {folder}"
+
+        zarr_root = zarr.open(folder, mode="r")
+
+        # load internal sorting and make it sharedmem
+        # TODO propagate storage_options
+        sorting = SharedMemorySorting.from_sorting(ZarrSortingExtractor(folder, zarr_group="sorting"))
+        
+        # load recording if possible
+        if recording is None:
+            rec_dict = zarr_root["recording"][0]
+            try:
+                
+                recording = load_extractor(rec_dict, base_folder=folder)
+            except:
+                recording = None
+        else:
+            # TODO maybe maybe not??? : do we need to check  attributes match internal rec_attributes
+            # Note this will make the loading too slow
+            pass
+
+        # recording attributes
+        rec_attributes = zarr_root["recording_info"].attrs["recording_attributes"]
+        # rec_attributes = zarr_root["recording_info"]["recording_attributes"]
+        if "probegroup" in zarr_root["recording_info"].attrs:
+            probegroup_dict = zarr_root["recording_info"].attrs["probegroup"]
+            # probegroup_dict = zarr_root["recording_info"]["probegroup"]
+            rec_attributes["probegroup"] = probeinterface.ProbeGroup.from_dict(probegroup_dict)
+        else:
+            rec_attributes["probegroup"] = None
+
+        # sparsity
+        if "sparsity_mask" in zarr_root.attrs:
+            # sparsity = zarr_root.attrs["sparsity"]
+            sparsity = ChannelSparsity(zarr_root["sparsity_mask"], self.unit_ids, rec_attributes["channel_ids"])
+        else:
+            sparsity = None
+
+        if "random_spikes_indices" in zarr_root.keys():
+            random_spikes_indices = zarr_root["random_spikes_indices"]
+        else:
+            random_spikes_indices = None
+
+        sortres = SortingResult(
+            sorting=sorting,
+            recording=recording,
+            rec_attributes=rec_attributes,
+            format="zarr",
+            sparsity=sparsity,
+            random_spikes_indices=random_spikes_indices)
+
+        return sortres
+
+
+    def _save_or_select(self, format="binary_folder", folder=None, unit_ids=None) -> "SortingResult":
+        """
+        Internal used by both save_as(), copy() and select_units() which are more or less the same.
+        """
+
+        if self.has_recording():
+            recording = self.recording
+        else:
+            recording = None
+        
+        if self.sparsity is not None and unit_ids is None:
+            sparsity = self.sparsity
+        elif self.sparsity is not None and unit_ids is not None:
+            sparsity_mask = self.sparsity.mask[np.isin(self.unit_ids, unit_ids), :]
+            sparsity = ChannelSparsity(sparsity_mask, unit_ids, self.channel_ids)
+        else:
+            sparsity = None
+
+        # Note that the sorting is a copy we need to go back to the orginal sorting (if available)
+        sorting_provenance = self.get_sorting_provenance()
+        if sorting_provenance is None:
+            # if the original sorting objetc is not available anymore (kilosort folder deleted, ....), take the copy
+            sorting_provenance = self.sorting
+        
+        if unit_ids is not None:
+            # when only some unit_ids then the sorting must be sliced
+            sorting_provenance = sorting_provenance.select_units(unit_ids)
+
+        if format == "memory":
+            # This make a copy of actual SortingResult
+            new_sortres = SortingResult.create_memory(sorting_provenance, recording, sparsity, self.rec_attributes)
+
+        elif format == "binary_folder":
+            # create  a new folder
+            assert folder is not None, "For format='binary_folder' folder must be provided"
+            SortingResult.create_binary_folder(folder, sorting_provenance, recording, sparsity, self.rec_attributes)
+            new_sortres = SortingResult.load_from_binary_folder(folder, recording=recording)
+            new_sortres.folder = folder
+
+        elif format == "zarr":
+            assert folder is not None, "For format='zarr' folder must be provided"
+            SortingResult.create_zarr(folder, sorting_provenance, recording, sparsity, self.rec_attributes)
+            new_sortres = SortingResult.load_from_zarr(folder, recording=recording)
+            new_sortres.folder = folder
+        else:
+            raise ValueError("SortingResult.save: wrong format")
+
+        # make a copy of extensions
+        # note that the copy of extension handle itself the slicing of units when necessary
+        for extension_name, extension in self.extensions.items():
+            new_sortres.extensions[extension_name] = extension.copy(new_sortres, unit_ids=unit_ids)
+
+        return new_sortres
+
+    def save_as(self, format="memory", folder=None) -> "SortingResult":
+        """
+        Save SortingResult object into another format.
+        Uselfull for memory to zarr or memory to binray.
+        
+        Note that the recording provenance or sorting provenance can be lost.
+
+        Mainly propagate the copied sorting and recording property.
+
+        Parameters
+        ----------
+        folder : str or Path
+            The output waveform folder
+        format : "binary_folder" | "zarr", default: "binary_folder"
+            The backend to use for saving the waveforms
+        """
+        return self._save_or_select(format=format, folder=folder, unit_ids=None)
+
+
+    def select_units(self, unit_ids, format="memory", folder=None) -> "SortingResult":
+        """
+        This method is equivalent to `save_as()`but with a subset of units.
+        Filters units by creating a new waveform extractor object in a new folder.
+
+        Extensions are also updated to filter the selected unit ids.
+
+        Parameters
+        ----------
+        unit_ids : list or array
+            The unit ids to keep in the new WaveformExtractor object
+        folder : Path or None
+            The new folder where selected waveforms are copied
+        format: 
+        a
+        Returns
+        -------
+        we :  WaveformExtractor
+            The newly create waveform extractor with the selected units
+        """
+        return self._save_or_select(format=format, folder=folder, unit_ids=unit_ids)
+
+    def copy(self):
+        """
+        Create a a copy of SortingResult with format "memory".
+        """
+        return self._save_or_select(format="memory", folder=None, unit_ids=None)
+
+    def is_read_only(self) -> bool:
+        if self.format == "memory":
+            return False
+        return not os.access(self.folder, os.W_OK)
+
+
+    ## map attribute and property zone
+
+    @property
+    def recording(self) -> BaseRecording:
+        if not self.has_recording():
+            raise ValueError("SortingResult could not load the recording")
+        return self._recording
+
+    @property
+    def channel_ids(self) -> np.ndarray:
+        return np.array(self.rec_attributes["channel_ids"])
+
+    @property
+    def sampling_frequency(self) -> float:
+        return self.sorting.get_sampling_frequency()
+
+    @property
+    def unit_ids(self) -> np.ndarray:
+        return self.sorting.unit_ids
+
+    def has_recording(self) -> bool:
+        return self._recording is not None
+
+    def is_sparse(self) -> bool:
+        return self.sparsity is not None
+    
+    def get_sorting_provenance(self):
+        """
+        Get the original sorting if possible otherwise return None
+        """
+        if self.format == "memory":
+            # the orginal sorting provenance is not keps in that case
+            sorting_provenance =  None
+
+        elif self.format == "binary_folder":
+            for type in ("json", "pickle"):
+                filename = self.folder / f"sorting_provenance.{type}"
+                sorting_provenance = None
+                if filename.exists():
+                    try:
+                        sorting_provenance = load_extractor(filename, base_folder=self.folder)
+                        break
+                    except:
+                        pass
+                        # sorting_provenance = None
+
+        elif self.format == "zarr":
+            zarr_root = self._get_zarr_root(mode="r")
+            if "sorting_provenance" in zarr_root.keys():
+                sort_dict = zarr_root["sorting_provenance"][0]
+                sorting_provenance = load_extractor(sort_dict, base_folder=self.folder)
+            else:
+                sorting_provenance = None
+
+        return sorting_provenance
+
+    def get_num_samples(self, segment_index: Optional[int] = None) -> int:
+        # we use self.sorting to check segment_index
+        segment_index = self.sorting._check_segment_index(segment_index)
+        return self.rec_attributes["num_samples"][segment_index]
+
+    def get_total_samples(self) -> int:
+        s = 0
+        for segment_index in range(self.get_num_segments()):
+            s += self.get_num_samples(segment_index)
+        return s
+
+    def get_total_duration(self) -> float:
+        duration = self.get_total_samples() / self.sampling_frequency
+        return duration
+
+    def get_num_channels(self) -> int:
+        return self.rec_attributes["num_channels"]
+
+    def get_num_segments(self) -> int:
+        return self.sorting.get_num_segments()
+
+    def get_probegroup(self):
+        return self.rec_attributes["probegroup"]
+
+    def get_probe(self):
+        probegroup = self.get_probegroup()
+        assert len(probegroup.probes) == 1, "There are several probes. Use `get_probegroup()`"
+        return probegroup.probes[0]
+
+    def get_channel_locations(self) -> np.ndarray:
+        # important note : contrary to recording
+        # this give all channel locations, so no kwargs like channel_ids and axes
+        all_probes = self.get_probegroup().probes
+        all_positions = np.vstack([probe.contact_positions for probe in all_probes])
+        return all_positions
+
+    def channel_ids_to_indices(self, channel_ids) -> np.ndarray:
+        all_channel_ids = self.rec_attributes["channel_ids"]
+        indices = np.array([all_channel_ids.index(id) for id in channel_ids], dtype=int)
+        return indices
+
+    def get_recording_property(self, key) -> np.ndarray:
+        values = np.array(self.rec_attributes["properties"].get(key, None))
+        return values
+
+    def get_sorting_property(self, key) -> np.ndarray:
+        return self.sorting.get_property(key)
+
+    ## extensions zone
+    def compute(self, extension_name, save=True, **params):
+        """
+        Compute one extension
+
+        Parameters
+        ----------
+        extension_name: str
+            The name of the extension.
+            For instance "waveforms", "templates", ...
+        save: bool, default True
+            It the extension can be saved then it is saved.
+            If not then the extension will only live in memory as long as the object is deleted.
+            save=False is convinient to try some parameters without changing an already saved extension.
+
+        **params: 
+            All other kwargs are transimited to extension.set_params()
+
+        Returns
+        -------
+        sorting_result: SortingResult
+            The SortingResult object
+
+        Examples
+        --------
+
+        >>> extension = sortres.compute("waveforms", **some_params)
+        >>> wfs = extension.data["waveforms"]
+
+        """
+        
+
+        extension_class = get_extension_class(extension_name)
+
+        # check dependencies
+        if extension_class.need_recording:
+            assert self.has_recording(), f"Extension {extension_name} need the recording"
+        for dependency_name in extension_class.depend_on:
+            ext = self.get_extension(dependency_name)
+            assert ext is not None, f"Extension {extension_name} need {dependency_name} to be computed first"
+        
+        extension_instance = extension_class(self)
+        extension_instance.set_params(save=save, **params)
+        extension_instance.run(save=save)
+        
+        self.extensions[extension_name] = extension_instance
+
+        # TODO : need discussion
+        return extension_instance
+        # OR
+        return extension_instance.data
+
+    def get_saved_extension_names(self):
+        """
+        Get extension saved in folder or zarr that can be loaded.
+        """
+        assert self.format != "memory"
+        global _possible_extensions
+
+        if self.format == "zarr":
+            zarr_root = self._get_zarr_root(mode="r")
+            if "extensions" in zarr_root.keys():
+                extension_group = zarr_root["extensions"]
+            else:
+                extension_group = None
+
+        saved_extension_names = []
+        for extension_class in _possible_extensions:
+            extension_name = extension_class.extension_name
+            if self.format == "binary_folder":
+                is_saved = (self.folder / extension_name).is_dir() and (self.folder / extension_name / "params.json").is_file()
+            elif self.format == "zarr":
+                if extension_group is not None:
+                    is_saved = extension_name in extension_group.keys() and "params" in extension_group[extension_name].attrs.keys()
+                else:
+                    is_saved = False
+            if is_saved:
+                saved_extension_names.append(extension_class.extension_name)
+
+        return saved_extension_names
+
+    def get_extension(self, extension_name: str):
+        """
+        Get a ResultExtension.
+        If not loaded then load is automatic.
+
+        Return None if the extension is not computed yet (this avoid the use of has_extension() and then get it)
+        
+        """
+        if extension_name in self.extensions:
+            return self.extensions[extension_name]
+
+        if self.has_extension(extension_name):
+            self.load_extension(extension_name)
+            return self.extensions[extension_name]
+        
+        return None
+
+    def load_extension(self, extension_name: str):
+        """
+        Load an extensionet from folder or zarr into the `ResultSorting.extensions` dict.
+
+        Parameters
+        ----------
+        extension_name: str
+            The extension name.
+
+        Returns
+        -------
+        ext_instanace:
+            The loaded instance of the extension
+
+        """
+        assert self.format != "memory"
+
+        extension_class = get_extension_class(extension_name)
+
+        extension_instance = extension_class(self)
+        extension_instance.load_params()
+        extension_instance.load_data()
+
+        return extension_instance
+
+    def load_all_saved_extension(self):
+        """
+        Load all saved extension in memory.
+        """
+        for extension_name in self.get_saved_extension_names():
+            self.load_extension(extension_name)
+
+    def delete_extension(self, extension_name) -> None:
+        """
+        Delete the extension from the dict and also in the persistent zarr or folder.
+        """
+
+        # delete from folder or zarr
+        if self.format != "memory" and self.has_extension(extension_name):
+            # need a reload to reset the folder
+            ext = self.load_extension(extension_name)
+            ext.reset()
+
+        # remove from dict
+        self.extensions.pop(extension_name, None)
+
+    def get_loaded_extension_names(self):
+        """
+        Return the loaded or already computed extensions names.
+        """
+        return list(self.extensions.keys())
+    
+    def has_extension(self, extension_name: str) -> bool:
+        """
+        Check if the extension exists in memory (dict) or in the folder or in zarr.
+        """
+        if extension_name in self.extensions:
+            return True
+        elif self.format == "memory":
+            return False
+        elif extension_name in self.get_saved_extension_names():
+            return True
+        else:
+            return False
+
+    ## random_spikes_selection zone
+    def select_random_spikes(self, **random_kwargs):
+        # random_spikes_indices is a vector that refer to the spike vector of the sorting in absolut index
+        assert self.random_spikes_indices is None, "select random spikes is already computed"
+
+        self.random_spikes_indices = random_spikes_selection(self.sorting, self.rec_attributes["num_samples"], **random_kwargs)
+
+        if self.format == "binary_folder":
+            np.save(self.folder / "random_spikes_indices.npy", self.random_spikes_indices)
+        elif self.format == "zarr":
+            zarr_root = self._get_zarr_root()
+            zarr_root.create_dataset("random_spikes_indices", data=self.random_spikes_indices)
+
+    def get_selected_indices_in_spike_train(self, unit_id, segment_index):
+        # usefull for Waveforms extractor backwars compatibility
+        # In Waveforms extractor "selected_spikes" was a dict (key: unit_id) of list (segment_index) of indices of spikes in spiketrain
+        assert self.random_spikes_indices is not None, "random spikes selection is not computeds"
+        unit_index = self.sorting.id_to_index(unit_id)
+        spikes = self.sorting.to_spike_vector()
+        spike_indices_in_seg = np.flatnonzero((spikes["segment_index"] == segment_index) & (spikes["unit_index"] == unit_index))
+        common_element, inds_left, inds_right  = np.intersect1d(spike_indices_in_seg, self.random_spikes_indices, return_indices=True)
+        selected_spikes_in_spike_train = inds_left
+        return selected_spikes_in_spike_train
+
+
+global _possible_extensions
+_possible_extensions = []
+
+def register_result_extension(extension_class):
+    """
+    This maintains a list of possible extensions that are available.
+    It depends on the imported submodules (e.g. for postprocessing module).
+
+    For instance with:
+    import spikeinterface as si
+    only one extension will be available
+    but with 
+    import spikeinterface.postprocessing
+    more extensions will be available
+    """
+    assert issubclass(extension_class, ResultExtension)
+    assert extension_class.extension_name is not None, "extension_name must not be None"
+    global _possible_extensions
+
+    already_registered = any(extension_class is ext for ext in _possible_extensions)
+    if not already_registered:
+        assert all(
+            extension_class.extension_name != ext.extension_name for ext in _possible_extensions
+        ), "Extension name already exists"
+
+        _possible_extensions.append(extension_class)
+
+
+def get_extension_class(extension_name: str):
+    """
+    Get extension class from name and check if registered.
+
+    Parameters
+    ----------
+    extension_name: str
+        The extension name.
+
+    Returns
+    -------
+    ext_class:
+        The class of the extension.
+    """
+    global _possible_extensions
+    extensions_dict = {ext.extension_name: ext for ext in _possible_extensions}
+    assert extension_name in extensions_dict, "Extension is not registered, please import related module before"
+    ext_class = extensions_dict[extension_name]
+    return ext_class
+
+
+class ResultExtension:
+    """
+    This the base class to extend the SortingResult.
+    It can handle persistency to disk any computations related
+
+    For instance:
+      * waveforms
+      * principal components
+      * spike amplitudes
+      * quality metrics
+
+    Possible extension can be register on the fly at import time with register_result_extension() mechanism.
+    It also enables any custum computation on top on SortingResult to be implemented by the user.
+
+    An extension needs to inherit from this class and implement some attributes and abstract methods:
+      * extension_name
+      * depend_on
+      * need_recording
+      * use_nodepipeline
+      * _set_params()
+      * _run()
+      * _select_extension_data()
+
+    The subclass must also set an `extension_name` class attribute which is not None by default.
+
+    The subclass must also hanle an attribute `data` which is a dict contain the results after the `run()`.
+
+    All ResultExtension will have a function associate for instance (this use the function_factory):
+    comptute_unit_location(sorting_result, ...) will be equivalent to sorting_result.compute("unit_location", ...)
+    
+
+    """    
+    
+    extension_name = None
+    depend_on = []
+    need_recording = False
+    use_nodepipeline = False
+
+    def __init__(self, sorting_result):
+        self._sorting_result = weakref.ref(sorting_result)
+
+        self.params = None
+        self.data = dict()
+
+    #######
+    # This 3 methods must be implemented in the subclass!!!
+    # See DummyResultExtension in test_sortingresult.py as a simple example
+    def _run(self, **kwargs):
+        # must be implemented in subclass
+        # must populate the self.data dictionary
+        raise NotImplementedError
+
+    def _set_params(self, **params):
+        # must be implemented in subclass
+        # must return a cleaned version of params dict
+        raise NotImplementedError
+
+    def _select_extension_data(self, unit_ids):
+        # must be implemented in subclass
+        raise NotImplementedError
+    # 
+    #######
+
+    @classmethod
+    def function_factory(cls):
+        # make equivalent
+        # comptute_unit_location(sorting_result, ...) <> sorting_result.compute("unit_location", ...)
+        class FuncWrapper:
+            def __init__(self, extension_name):
+                self.extension_name = extension_name
+            def __call__(self, sorting_result, load_if_exists=None, *args, **kwargs):
+                # backward compatibility with "load_if_exists"
+                if load_if_exists is not None:
+                    warnings.warn(f"compute_{cls.extension_name}(..., load_if_exists=True/False) is kept for backward compatibility but should not be used anymore")
+                    assert isinstance(load_if_exists, bool)
+                    if load_if_exists:
+                        ext = sorting_result.get_extension(self.extension_name)
+                        return ext
+
+                ext = sorting_result.compute(cls.extension_name, *args, **kwargs)
+                # TODO be discussed 
+                return ext
+                # return ext.data
+                # return ext.get_data()
+
+        func = FuncWrapper(cls.extension_name)
+        func.__doc__ = cls.__doc__
+        # TODO: add load_if_exists
+        return func
+
+    @property
+    def sorting_result(self):
+        # Important : to avoid the SortingResult referencing a ResultExtension
+        # and ResultExtension referencing a SortingResult we need a weakref.
+        # Otherwise the garbage collector is not working properly.
+        # and so the SortingResult + its recording are still alive even after deleting explicitly
+        # the SortingResult which makes it impossible to delete the folder when using memmap.
+        sorting_result = self._sorting_result()
+        if sorting_result is None:
+            raise ValueError(f"The extension {self.extension_name} has lost its SortingResult")
+        return sorting_result
+
+    # some attribuites come from sorting_result
+    @property
+    def format(self):
+        return self.sorting_result.format
+    
+    @property
+    def sparsity(self):
+        return self.sorting_result.sparsity
+
+    @property
+    def folder(self):
+        return self.sorting_result.folder
+    
+    def _get_binary_extension_folder(self):
+        extension_folder = self.folder / "saved_extensions" /self.extension_name
+        return extension_folder
+
+
+    def _get_zarr_extension_group(self, mode='r+'):
+        zarr_root = self.sorting_result._get_zarr_root(mode=mode)
+        extension_group = zarr_root["extensions"][self.extension_name]
+        return extension_group
+
+    @classmethod
+    def load(cls, sorting_result):
+        ext = cls(sorting_result)
+        ext.load_params()
+        ext.load_data()
+        return ext
+
+    def load_params(self):
+        if self.format == "binary_folder":
+            extension_folder = self._get_binary_extension_folder()
+            params_file = extension_folder / "params.json"
+            assert params_file.is_file(), f"No params file in extension {self.extension_name} folder"
+            with open(str(params_file), "r") as f:
+                params = json.load(f)
+
+        elif self.format == "zarr":
+            extension_group = self._get_zarr_extension_group(mode='r')
+            assert "params" in extension_group.attrs, f"No params file in extension {self.extension_name} folder"
+            params = extension_group.attrs["params"]
+
+        self.params = params
+
+    def load_data(self):
+        if self.format == "binary_folder":
+            extension_folder = self._get_binary_extension_folder()
+            for ext_data_file in extension_folder.iterdir():
+                if ext_data_file.name == "params.json":
+                    continue
+                ext_data_name = ext_data_file.stem
+                if ext_data_file.suffix == ".json":
+                    ext_data = json.load(ext_data_file.open("r"))
+                elif ext_data_file.suffix == ".npy":
+                    # The lazy loading of an extension is complicated because if we compute again
+                    # and have a link to the old buffer on windows then it fails
+                    # ext_data = np.load(ext_data_file, mmap_mode="r")
+                    # so we go back to full loading
+                    ext_data = np.load(ext_data_file)
+                elif ext_data_file.suffix == ".csv":
+                    import pandas as pd
+                    ext_data = pd.read_csv(ext_data_file, index_col=0)
+                elif ext_data_file.suffix == ".pkl":
+                    ext_data = pickle.load(ext_data_file.open("rb"))
+                else:
+                    continue
+                self.data[ext_data_name] = ext_data
+        
+        elif self.format == "zarr":
+            # Alessio
+            # TODO: we need decide if we make a copy to memory or keep the lazy loading. For binary_folder it used to be lazy with memmap
+            # but this make the garbage complicated when a data is hold by a plot but the o SortingResult is delete
+            # lets talk
+            extension_group = self._get_zarr_extension_group(mode='r')
+            for ext_data_name in extension_group.keys():
+                ext_data_ = extension_group[ext_data_name]
+                if "dict" in ext_data_.attrs:
+                    ext_data = ext_data_[0]
+                elif "dataframe" in ext_data_.attrs:
+                    import xarray
+                    ext_data = xarray.open_zarr(
+                        ext_data_.store, group=f"{extension_group.name}/{ext_data_name}"
+                    ).to_pandas()
+                    ext_data.index.rename("", inplace=True)
+                elif "object" in ext_data_.attrs:
+                    ext_data = ext_data_[0]
+                else:
+                    ext_data = ext_data_
+                self.data[ext_data_name] = ext_data
+
+    def copy(self, new_sorting_result, unit_ids=None):
+        # alessio : please note that this also replace the old BaseWaveformExtractorExtension.select_units!!!
+        new_extension = self.__class__(new_sorting_result)
+        new_extension.params = self.params.copy()
+        if unit_ids is None:
+            new_extension.data = self.data
+        else:
+            new_extension.data = self._select_extension_data(unit_ids)
+        new_extension.save()
+        return new_extension
+
+    def run(self, save=True, **kwargs):
+        if save and not self.sorting_result.is_read_only():
+            # this also reset the folder or zarr group
+            self._save_params()
+
+        self._run(**kwargs)
+
+        if save and not self.sorting_result.is_read_only():
+            self._save_data(**kwargs)
+
+    def save(self, **kwargs):
+        self._save_params()
+        self._save_data(**kwargs)
+
+    def _save_data(self, **kwargs):
+        if self.format == "memory":
+            return
+
+        if self.sorting_result.is_read_only():
+            raise ValueError(f"The SortingResult is read only save extension {self.extension_name} is not possible")
+        
+        if self.format == "binary_folder":
+            import pandas as pd
+
+            extension_folder = self._get_binary_extension_folder()
+            for ext_data_name, ext_data in self.data.items():
+                if isinstance(ext_data, dict):
+                    with (extension_folder / f"{ext_data_name}.json").open("w") as f:
+                        json.dump(ext_data, f)
+                elif isinstance(ext_data, np.ndarray):
+                    data_file = extension_folder / f"{ext_data_name}.npy"
+                    if isinstance(ext_data, np.memmap) and data_file.exists():
+                        # important some SortingResult like ComputeWaveforms already run the computation with memmap
+                        # so no need to save theses array
+                        pass
+                    else:
+                        np.save(data_file, ext_data)
+                elif isinstance(ext_data, pd.DataFrame):
+                    ext_data.to_csv(extension_folder / f"{ext_data_name}.csv", index=True)
+                else:
+                    try:
+                        with (extension_folder / f"{ext_data_name}.pkl").open("wb") as f:
+                            pickle.dump(ext_data, f)
+                    except:
+                        raise Exception(f"Could not save {ext_data_name} as extension data")
+        elif self.format == "zarr":
+            
+            import pandas as pd
+            import numcodecs
+            
+            extension_group = self._get_zarr_extension_group(mode="r+")
+
+            compressor = kwargs.get("compressor", None)
+            if compressor is None:
+                compressor = get_default_zarr_compressor()
+            
+            for ext_data_name, ext_data in self.data.items():
+                if ext_data_name in extension_group:
+                    del extension_group[ext_data_name]
+                if isinstance(ext_data, dict):
+                    extension_group.create_dataset(
+                        name=ext_data_name, data=np.array([ext_data], dtype=object), object_codec=numcodecs.JSON()
+                    )
+                elif isinstance(ext_data, np.ndarray):
+                    extension_group.create_dataset(name=ext_data_name, data=ext_data, compressor=compressor)
+                elif isinstance(ext_data, pd.DataFrame):
+                    ext_data.to_xarray().to_zarr(
+                        store=extension_group.store,
+                        group=f"{extension_group.name}/{ext_data_name}",
+                        mode="a",
+                    )
+                    extension_group[ext_data_name].attrs["dataframe"] = True
+                else:
+                    # any object
+                    try:
+                        extension_group.create_dataset(
+                            name=ext_data_name, data=np.array([ext_data], dtype=object), object_codec=numcodecs.Pickle()
+                        )
+                    except:
+                        raise Exception(f"Could not save {ext_data_name} as extension data")
+                    extension_group[ext_data_name].attrs["object"] = True
+
+    def _reset_extension_folder(self):
+        """
+        Delete the extension in folder (binary or zarr) and create an empty one.
+        """
+        if self.format == "binary_folder":
+            extension_folder = self._get_binary_extension_folder()
+            if extension_folder.is_dir():
+                shutil.rmtree(extension_folder)
+            extension_folder.mkdir(exist_ok=False, parents=True)
+
+        elif self.format == "zarr":
+            import zarr
+            zarr_root = zarr.open(self.folder, mode="r+")
+            extension_group = zarr_root["extensions"].create_group(self.extension_name, overwrite=True)
+
+    def reset(self):
+        """
+        Reset the waveform extension.
+        Delete the sub folder and create a new empty one.
+        """
+        self._reset_extension_folder()
+        self.params = None
+        self.data = dict()
+
+
+    def set_params(self, save=True, **params):
+        """
+        Set parameters for the extension and
+        make it persistent in json.
+        """
+        # this ensure data is also deleted and corresponf to params
+        # this also ensure the group is created
+        self._reset_extension_folder()
+
+        params = self._set_params(**params)
+        self.params = params
+
+        if self.sorting_result.is_read_only():
+            return
+
+        if save:
+            self._save_params()
+
+    def _save_params(self):
+        params_to_save = self.params.copy()
+
+        self._reset_extension_folder()
+
+        # TODO make sparsity local Result specific
+        # if "sparsity" in params_to_save and params_to_save["sparsity"] is not None:
+        #     assert isinstance(
+        #         params_to_save["sparsity"], ChannelSparsity
+        #     ), "'sparsity' parameter must be a ChannelSparsity object!"
+        #     params_to_save["sparsity"] = params_to_save["sparsity"].to_dict()
+
+
+        if self.format == "binary_folder":
+            extension_folder = self._get_binary_extension_folder()
+            extension_folder.mkdir(exist_ok=True, parents=True)
+            param_file = extension_folder / "params.json"
+            param_file.write_text(json.dumps(check_json(params_to_save), indent=4), encoding="utf8")
+        elif self.format == "zarr":
+            extension_group = self._get_zarr_extension_group(mode="r+")
+            extension_group.attrs["params"] = check_json(params_to_save)
+
+
+
diff --git a/src/spikeinterface/core/waveform_tools.py b/src/spikeinterface/core/waveform_tools.py
index 58243ceea..cc24fb45a 100644
--- a/src/spikeinterface/core/waveform_tools.py
+++ b/src/spikeinterface/core/waveform_tools.py
@@ -771,9 +771,7 @@ def estimate_templates(
         array_pid,
     )
 
-    processor = ChunkRecordingExecutor(
-        recording, func, init_func, init_args, job_name="estimate_templates", **job_kwargs
-    )
+    processor = ChunkRecordingExecutor(recording, func, init_func, init_args, job_name="estimate_templates", **job_kwargs)
     processor.run()
 
     # average
diff --git a/src/spikeinterface/core/waveforms_extractor_backwards_compatibility.py b/src/spikeinterface/core/waveforms_extractor_backwards_compatibility.py
new file mode 100644
index 000000000..93c27cded
--- /dev/null
+++ b/src/spikeinterface/core/waveforms_extractor_backwards_compatibility.py
@@ -0,0 +1,481 @@
+"""
+This backwards compatibility module aims to:
+  * load old WaveformsExtractor saved with folder or zarr  (version <=0.100) into the  SortingResult (version>0.100)
+  * mock the function extract_waveforms() and the class SortingResult() but based SortingResult
+"""
+from __future__ import annotations
+
+from typing import Literal, Optional
+
+from pathlib import Path
+
+import json
+
+import numpy as np
+
+import probeinterface
+
+from .baserecording import BaseRecording
+from .basesorting import BaseSorting
+from .sortingresult import start_sorting_result
+from .job_tools import split_job_kwargs
+from .sparsity import ChannelSparsity
+from .sortingresult import SortingResult
+from .base import load_extractor
+from .result_core import ComputeWaveforms, ComputeTemplates
+
+_backwards_compatibility_msg = """####
+# extract_waveforms() and WaveformExtractor() have been replace by SortingResult since version 0.101
+# You should use start_sorting_result() instead.
+# extract_waveforms() is now mocking the old behavior for backwards compatibility only and will be removed after 0.103
+####"""
+
+
+def extract_waveforms(
+    recording,
+    sorting,
+    folder=None,
+    mode="folder",
+    precompute_template=("average",),
+    ms_before=1.0,
+    ms_after=2.0,
+    max_spikes_per_unit=500,
+    overwrite=None,
+    return_scaled=True,
+    dtype=None,
+    sparse=True,
+    sparsity=None,
+    sparsity_temp_folder=None,
+    num_spikes_for_sparsity=100,
+    unit_batch_size=None,
+    allow_unfiltered=None,
+    use_relative_path=None,
+    seed=None,
+    load_if_exists=None,
+    **kwargs,
+):
+    """
+    This mock the extract_waveforms() in version <= 0.100 to not break old codes but using
+    the SortingResult (version >0.100) internally.
+
+    This return a MockWaveformExtractor object that mock the old WaveformExtractor
+    """
+    print(_backwards_compatibility_msg)
+
+    assert load_if_exists is None, "load_if_exists=True/False is not supported anymore. use load_if_exists=None"
+    assert overwrite is None, "overwrite=True/False is not supported anymore. use overwrite=None"
+
+    other_kwargs, job_kwargs = split_job_kwargs(kwargs)
+
+    if mode == "folder":
+        assert folder is not None
+        folder = Path(folder)
+        format = "binary_folder"
+    else:
+        mode = "memory"
+
+    assert sparsity_temp_folder is None, "sparsity_temp_folder must be None"
+    assert unit_batch_size is None, "unit_batch_size must be None"
+
+    if use_relative_path is not None:
+        print("use_relative_path is ignored")
+
+    if allow_unfiltered is not None:
+        print("allow_unfiltered is ignored")
+
+    sparsity_kwargs = dict(
+        num_spikes_for_sparsity=num_spikes_for_sparsity,
+        ms_before=ms_before,
+        ms_after=ms_after,
+        **other_kwargs,
+        **job_kwargs
+    )
+    sorting_result = start_sorting_result(sorting, recording, format=format, folder=folder, 
+        sparse=sparse, sparsity=sparsity, **sparsity_kwargs
+    )
+
+    # TODO propagate job_kwargs
+
+    sorting_result.select_random_spikes(max_spikes_per_unit=max_spikes_per_unit, seed=seed)
+
+    waveforms_params = dict(ms_before=ms_before, ms_after=ms_after, return_scaled=return_scaled, dtype=dtype)
+    sorting_result.compute("waveforms", **waveforms_params)
+
+    templates_params = dict(operators=list(precompute_template))
+    sorting_result.compute("templates", **templates_params)
+
+    we = MockWaveformExtractor(sorting_result)
+
+    return we
+
+
+
+class MockWaveformExtractor:
+    def __init__(self, sorting_result):
+        self.sorting_result = sorting_result
+
+    def __repr__(self):
+        txt = "MockWaveformExtractor: mock the old WaveformExtractor with "
+        txt += self.sorting_result.__repr__()
+        return txt
+
+    def is_sparse(self) -> bool:
+        return self.sorting_result.is_sparse()
+    
+    def has_waveforms(self) -> bool:
+        return self.sorting_result.get_extension("waveforms") is not None
+
+    def delete_waveforms(self) -> None:
+        self.sorting_result.delete_extension("waveforms")
+
+    @property
+    def recording(self) -> BaseRecording:
+        return self.sorting_result.recording
+    
+    @property
+    def sorting(self) -> BaseSorting:
+        return self.sorting_result.sorting
+
+    @property
+    def channel_ids(self) -> np.ndarray:
+        return self.sorting_result.channel_ids
+
+    @property
+    def sampling_frequency(self) -> float:
+        return self.sorting_result.sampling_frequency
+
+    @property
+    def unit_ids(self) -> np.ndarray:
+        return self.sorting_result.unit_ids
+
+    @property
+    def nbefore(self) -> int:
+        ms_before = self.sorting_result.get_extension("waveforms").params["ms_before"]
+        return int(ms_before * self.sampling_frequency / 1000.0)
+
+    @property
+    def nafter(self) -> int:
+        ms_after = self.sorting_result.get_extension("waveforms").params["ms_after"]
+        return int(ms_after * self.sampling_frequency / 1000.0)
+
+    @property
+    def nsamples(self) -> int:
+        return self.nbefore + self.nafter
+
+    @property
+    def return_scaled(self) -> bool:
+        return self.sorting_result.get_extension("waveforms").params["return_scaled"]
+
+    @property
+    def dtype(self):
+        return self.sorting_result.get_extension("waveforms").params["dtype"]
+
+    def is_read_only(self) -> bool:
+        return self.sorting_result.is_read_only()
+
+    def has_recording(self) -> bool:
+        return self.sorting_result._recording is not None
+
+    def get_num_samples(self, segment_index: Optional[int] = None) -> int:
+        return self.sorting_result.get_num_samples(segment_index)
+
+    def get_total_samples(self) -> int:
+        return self.sorting_result.get_total_samples()
+
+    def get_total_duration(self) -> float:
+        return self.sorting_result.get_total_duration()
+
+    def get_num_channels(self) -> int:
+        return self.sorting_result.get_num_channels()
+
+    def get_num_segments(self) -> int:
+        return self.sorting_result.get_num_segments()
+
+    def get_probegroup(self):
+        return self.sorting_result.get_probegroup()
+
+    def get_probe(self):
+        return self.sorting_result.get_probe()
+
+    def is_filtered(self) -> bool:
+        return self.sorting_result.rec_attributes["is_filtered"]
+
+    def get_channel_locations(self) -> np.ndarray:
+        return self.sorting_result.get_channel_locations()
+
+    def channel_ids_to_indices(self, channel_ids) -> np.ndarray:
+        return self.sorting_result.channel_ids_to_indices(channel_ids)
+
+    def get_recording_property(self, key) -> np.ndarray:
+        return self.sorting_result.get_recording_property(key)
+
+    def get_sorting_property(self, key) -> np.ndarray:
+        return self.sorting_result.get_sorting_property(key)
+
+    def has_extension(self, extension_name: str) -> bool:
+        return self.sorting_result.has_extension(extension_name)
+
+    def get_sampled_indices(self, unit_id):
+        # In Waveforms extractor "selected_spikes" was a dict (key: unit_id) with a complex dtype as follow
+        selected_spikes = []
+        for segment_index in range(self.get_num_segments()):
+            inds = self.sorting_result.get_selected_indices_in_spike_train(unit_id, segment_index)
+            sampled_index = np.zeros(inds.size, dtype=[("spike_index", "int64"), ("segment_index", "int64")])
+            sampled_index["spike_index"] = inds
+            sampled_index["segment_index"][:] = segment_index
+            selected_spikes.append(sampled_index)
+        return np.concatenate(selected_spikes)
+
+    def get_waveforms(
+        self,
+        unit_id,
+        with_index: bool = False,
+        cache: bool = False,
+        lazy: bool = True,
+        sparsity=None,
+        force_dense: bool = False,
+    ):  
+        # lazy and cache are ingnored
+        ext = self.sorting_result.get_extension("waveforms")
+        unit_index = self.sorting.id_to_index(unit_id)
+        spikes = self.sorting.to_spike_vector()
+        some_spikes = spikes[self.sorting_result.random_spikes_indices]
+        spike_mask = some_spikes["unit_index"] == unit_index
+        wfs = ext.data["waveforms"][spike_mask, :, :]
+
+        if sparsity is not None:
+            assert self.sorting_result.sparsity is None, "Waveforms are alreayd sparse! Cannot apply an additional sparsity."
+            wfs = wfs[:, :, sparsity.mask[self.sorting.id_to_index(unit_id)]]
+
+        if force_dense:
+            assert sparsity is None
+            if self.sorting_result.sparsity is None:
+                # nothing to do
+                pass
+            else:
+                num_channels = self.get_num_channels()
+                dense_wfs = np.zeros((wfs.shape[0], wfs.shape[1], num_channels), dtype=np.float32)
+                unit_sparsity = self.sorting_result.sparsity.mask[unit_index]
+                dense_wfs[:, :, unit_sparsity] = wfs
+                wfs = dense_wfs
+
+        if with_index:
+            sampled_index = self.get_sampled_indices(unit_id)
+            return wfs, sampled_index
+        else:
+            return wfs
+
+    def get_all_templates(
+        self, unit_ids: list | np.array | tuple | None = None, mode="average", percentile: float | None = None
+    ):
+        ext = self.sorting_result.get_extension("templates")
+
+        if mode == "percentile":
+            key = f"pencentile_{percentile}"
+        else:
+            key = mode
+        
+        templates = ext.data.get(key)
+        if templates is None:
+            raise ValueError(f"{mode} is not computed")
+
+        if unit_ids is not None:
+            unit_indices = self.sorting.ids_to_indices(unit_ids)
+            templates = templates[unit_indices, :, :]
+
+        return templates
+
+
+    def get_template(
+        self, unit_id, mode="average", sparsity=None, force_dense: bool = False, percentile: float | None = None
+    ):
+        # force_dense and sparsity are ignored
+        templates = self.get_all_templates(unit_ids=[unit_id], mode=mode, percentile=percentile)
+        return templates[0]
+
+
+
+def load_waveforms(folder, with_recording: bool = True, sorting: Optional[BaseSorting] = None, output="SortingResult", ):
+    """
+    This read an old WaveformsExtactor folder (folder or zarr) and convert it into a SortingResult or MockWaveformExtractor.
+
+    """
+
+    folder = Path(folder)
+    assert folder.is_dir(), "Waveform folder does not exists"
+    if folder.suffix == ".zarr":
+        raise NotImplementedError
+        # Alessio this is for you
+    else:
+        sorting_result = _read_old_waveforms_extractor_binary(folder)
+
+    if output == "SortingResult":
+        return sorting_result
+    elif output in ("WaveformExtractor", "MockWaveformExtractor"):
+        return MockWaveformExtractor(sorting_result)
+
+
+
+def _read_old_waveforms_extractor_binary(folder):
+    params_file = folder / "params.json"
+    if not params_file.exists():
+        raise ValueError(f"This folder is not a WaveformsExtractor folder {folder}")
+    with open(params_file, "r") as f:
+        params = json.load(f)
+
+    sparsity_file = folder / "sparsity.json"
+    if params_file.exists():
+        with open(sparsity_file, "r") as f:
+            sparsity_dict = json.load(f)
+            sparsity = ChannelSparsity.from_dict(sparsity_dict)
+    else:
+        sparsity = None
+
+    # recording attributes
+    rec_attributes_file = folder / "recording_info" / "recording_attributes.json"
+    with open(rec_attributes_file, "r") as f:
+        rec_attributes = json.load(f)
+    probegroup_file = folder / "recording_info" / "probegroup.json"
+    if probegroup_file.is_file():
+        rec_attributes["probegroup"] = probeinterface.read_probeinterface(probegroup_file)
+    else:
+        rec_attributes["probegroup"] = None
+
+    # recording
+    recording = None
+    if (folder / "recording.json").exists():
+        try:
+            recording = load_extractor(folder / "recording.json", base_folder=folder)
+        except:
+            pass
+    elif (folder / "recording.pickle").exists():
+        try:
+            recording = load_extractor(folder / "recording.pickle", base_folder=folder)
+        except:
+            pass
+
+    # sorting
+    if (folder / "sorting.json").exists():
+        sorting = load_extractor(folder / "sorting.json", base_folder=folder)
+    elif (folder / "sorting.pickle").exists():
+        sorting = load_extractor(folder / "sorting.pickle", base_folder=folder)
+
+    sorting_result = SortingResult.create_memory(sorting, recording, sparsity, rec_attributes=rec_attributes)
+
+    # waveforms
+    # need to concatenate all waveforms in one unique buffer
+    # need to concatenate sampled_index and order it
+    waveform_folder = folder / "waveforms"
+    if waveform_folder.exists():
+        
+        spikes = sorting.to_spike_vector()
+        random_spike_mask = np.zeros(spikes.size, dtype="bool")
+
+        all_sampled_indices = []
+        # first readd all sampled_index to get the correct ordering
+        for unit_index, unit_id in enumerate(sorting.unit_ids):
+            # unit_indices has dtype=[("spike_index", "int64"), ("segment_index", "int64")] 
+            unit_indices = np.load(waveform_folder / f"sampled_index_{unit_id}.npy")
+            for segment_index in range(sorting.get_num_segments()):
+                in_seg_selected = unit_indices[unit_indices["segment_index"] == segment_index]["spike_index"]
+                spikes_indices = np.flatnonzero((spikes["unit_index"] == unit_index) & (spikes["segment_index"] == segment_index))
+                random_spike_mask[spikes_indices[in_seg_selected]] = True
+        random_spikes_indices = np.flatnonzero(random_spike_mask)
+
+        num_spikes = random_spikes_indices.size
+        if sparsity is None:
+            max_num_channel = len(rec_attributes["channel_ids"])
+        else:
+            max_num_channel = np.max(np.sum(sparsity.mask, axis=1))
+
+        nbefore = int(params["ms_before"] * sorting.sampling_frequency / 1000.0)
+        nafter = int(params["ms_after"] * sorting.sampling_frequency / 1000.0)
+
+        waveforms = np.zeros((num_spikes, nbefore + nafter, max_num_channel), dtype=params["dtype"])
+        # then read waveforms per units
+        some_spikes = spikes[random_spikes_indices]
+        for unit_index, unit_id in enumerate(sorting.unit_ids):
+            wfs = np.load(waveform_folder / f"waveforms_{unit_id}.npy")
+            mask = some_spikes["unit_index"] == unit_index
+            waveforms[:, :, :wfs.shape[2]][mask, :, :] = wfs
+
+        sorting_result.random_spikes_indices = random_spikes_indices
+
+        ext = ComputeWaveforms(sorting_result)
+        ext.params = params
+        ext.data["waveforms"] = waveforms
+        sorting_result.extensions["waveforms"] = ext
+
+    # templates saved dense
+    # load cached templates
+    templates = {}
+    for mode in ("average", "std", "median", "percentile"):
+        template_file = folder / f"templates_{mode}.npy"
+        if template_file.is_file():
+            templates [mode] = np.load(template_file)
+    if len(templates) > 0:
+        ext = ComputeTemplates(sorting_result)
+        ext.params = dict(operators=list(templates.keys()))
+        for mode, arr in templates.items():
+            ext.data[mode] = arr
+        sorting_result.extensions["templates"] = ext
+
+
+    # TODO : implement this when extension will be prted in the new API
+    # old_extension_to_new_class : {
+        # old extensions with same names and equvalent data
+        # "spike_amplitudes": ,
+        # "spike_locations": ,
+        # "amplitude_scalings": ,
+        # "template_metrics" : ,
+        # "similarity": , 
+        # "unit_locations": ,
+        # "correlograms" : ,
+        # isi_histograms: ,
+        # "noise_levels": ,
+        # "quality_metrics": ,
+        # "principal_components" : , 
+    # }
+    # for ext_name, new_class in old_extension_to_new_class.items():
+    #     ext_folder = folder / ext_name
+    #     ext = new_class(sorting_result)
+    #     with open(ext_folder / "params.json", "r") as f:
+    #         params = json.load(f)
+    #     ext.params = params
+    #     if ext_name == "spike_amplitudes":
+    #         amplitudes = []
+    #         for segment_index in range(sorting.get_num_segments()):
+    #             amplitudes.append(np.load(ext_folder / f"amplitude_segment_{segment_index}.npy"))
+    #         amplitudes = np.concatenate(amplitudes)
+    #         ext.data["amplitudes"] = amplitudes
+    #     elif ext_name == "spike_locations":
+    #         ext.data["spike_locations"] = np.load(ext_folder / "spike_locations.npy")
+    #     elif ext_name == "amplitude_scalings":
+    #         ext.data["amplitude_scalings"] = np.load(ext_folder / "amplitude_scalings.npy")
+    #     elif ext_name == "template_metrics":
+    #         import pandas as pd
+    #         ext.data["metrics"] = pd.read_csv(ext_folder / "metrics.csv", index_col=0)
+    #     elif ext_name == "similarity":
+    #         ext.data["similarity"] = np.load(ext_folder / "similarity.npy")
+    #     elif ext_name == "unit_locations":
+    #         ext.data["unit_locations"] = np.load(ext_folder / "unit_locations.npy")
+    #     elif ext_name == "correlograms":
+    #         ext.data["ccgs"] = np.load(ext_folder / "ccgs.npy")
+    #         ext.data["bins"] = np.load(ext_folder / "bins.npy")
+    #     elif ext_name == "isi_histograms":
+    #         ext.data["isi_histograms"] = np.load(ext_folder / "isi_histograms.npy")
+    #         ext.data["bins"] = np.load(ext_folder / "bins.npy")
+    #     elif ext_name == "noise_levels":
+    #         ext.data["noise_levels"] = np.load(ext_folder / "noise_levels.npy")
+    #     elif ext_name == "quality_metrics":
+    #         import pandas as pd
+    #         ext.data["metrics"] = pd.read_csv(ext_folder / "metrics.csv", index_col=0)
+    #     elif ext_name == "principal_components":
+    #         # TODO: this is for you
+    #         pass
+
+
+
+    return sorting_result
+
+


Test patch:
diff --git a/src/spikeinterface/core/tests/test_result_core.py b/src/spikeinterface/core/tests/test_result_core.py
new file mode 100644
index 000000000..949663294
--- /dev/null
+++ b/src/spikeinterface/core/tests/test_result_core.py
@@ -0,0 +1,164 @@
+import pytest
+from pathlib import Path
+
+import shutil
+
+from spikeinterface.core import generate_ground_truth_recording
+from spikeinterface.core import start_sorting_result
+
+import numpy as np
+
+if hasattr(pytest, "global_test_folder"):
+    cache_folder = pytest.global_test_folder / "core"
+else:
+    cache_folder = Path("cache_folder") / "core"
+
+
+def get_sorting_result(format="memory", sparse=True):
+    recording, sorting = generate_ground_truth_recording(
+        durations=[30.0], sampling_frequency=16000.0, num_channels=20, num_units=5,
+        generate_sorting_kwargs=dict(firing_rates=10.0, refractory_period_ms=4.0),
+        generate_unit_locations_kwargs=dict(
+            margin_um=5.0,
+            minimum_z=5.0,
+            maximum_z=20.0,
+        ),
+        generate_templates_kwargs=dict(
+            unit_params_range=dict(
+                alpha=(9_000.0, 12_000.0),
+            )
+        ),
+        noise_kwargs=dict(noise_level=5.0, strategy="tile_pregenerated"),
+        seed=2406,
+    )
+    if format == "memory":
+        folder = None
+    elif format == "binary_folder":
+        folder = cache_folder / f"test_ComputeWaveforms_{format}"
+    elif format == "zarr":
+        folder = cache_folder / f"test_ComputeWaveforms.zarr"
+    if folder and folder.exists():
+        shutil.rmtree(folder)
+    
+    sortres = start_sorting_result(sorting, recording, format=format, folder=folder, sparse=sparse, sparsity=None)
+
+    return sortres
+
+
+def _check_result_extension(sortres, extension_name):
+    # select unit_ids to several format
+    for format in ("memory", "binary_folder", "zarr"):
+    # for format in ("memory", ):
+        if format != "memory":
+            if format == "zarr":
+                folder = cache_folder / f"test_SortingResult_{extension_name}_select_units_with_{format}.zarr"
+            else:
+                folder = cache_folder / f"test_SortingResult_{extension_name}_select_units_with_{format}"
+            if folder.exists():
+                shutil.rmtree(folder)
+        else:
+            folder = None
+
+        # check unit slice
+        keep_unit_ids = sortres.sorting.unit_ids[::2]
+        sortres2 = sortres.select_units(unit_ids=keep_unit_ids, format=format, folder=folder)
+
+        data = sortres2.get_extension(extension_name).data
+        # for k, arr in data.items():
+        #     print(k, arr.shape)
+
+
+@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
+@pytest.mark.parametrize("sparse", [True, False])
+def test_ComputeWaveforms(format, sparse):
+    sortres = get_sorting_result(format=format, sparse=sparse)
+
+    sortres.select_random_spikes(max_spikes_per_unit=50, seed=2205)
+    ext = sortres.compute("waveforms")
+    wfs = ext.data["waveforms"]
+    _check_result_extension(sortres, "waveforms")
+
+
+@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
+@pytest.mark.parametrize("sparse", [True, False])
+def test_ComputeTemplates(format, sparse):
+    sortres = get_sorting_result(format=format, sparse=sparse)
+
+    sortres.select_random_spikes(max_spikes_per_unit=20, seed=2205)
+    
+    with pytest.raises(AssertionError):
+        # This require "waveforms first and should trig an error
+        sortres.compute("templates")
+    
+    sortres.compute("waveforms")
+    sortres.compute("templates", operators=["average", "std", "median", ("percentile", 5.), ("percentile", 95.),])
+
+
+    data = sortres.get_extension("templates").data
+    for k in ['average', 'std', 'median', 'pencentile_5.0', 'pencentile_95.0']:
+        assert k in data.keys()
+        assert data[k].shape[0] == sortres.unit_ids.size
+        assert data[k].shape[2] == sortres.channel_ids.size
+        assert np.any(data[k] > 0)
+
+    import matplotlib.pyplot as plt
+    for unit_index, unit_id in enumerate(sortres.unit_ids):
+        fig, ax = plt.subplots()
+        for k in data.keys():
+            wf0 = data[k][unit_index, :, :]
+            ax.plot(wf0.T.flatten(), label=k)
+        ax.legend()
+    # plt.show()
+
+    _check_result_extension(sortres, "templates")
+
+
+@pytest.mark.parametrize("format", ["memory", "binary_folder", "zarr"])
+@pytest.mark.parametrize("sparse", [True, False])
+def test_ComputeFastTemplates(format, sparse):
+    sortres = get_sorting_result(format=format, sparse=sparse)
+
+    ms_before=1.0
+    ms_after=2.5
+
+    sortres.select_random_spikes(max_spikes_per_unit=20, seed=2205)
+    sortres.compute("fast_templates", ms_before=ms_before, ms_after=ms_after, return_scaled=True)
+
+    _check_result_extension(sortres, "fast_templates")
+
+    # compare ComputeTemplates with dense and ComputeFastTemplates: should give the same on "average"
+    other_sortres = get_sorting_result(format=format, sparse=False)
+    other_sortres.select_random_spikes(max_spikes_per_unit=20, seed=2205)
+    other_sortres.compute("waveforms", ms_before=ms_before, ms_after=ms_after, return_scaled=True)
+    other_sortres.compute("templates", operators=["average",])
+
+    templates0 = sortres.get_extension("fast_templates").data["average"]
+    templates1 = other_sortres.get_extension("templates").data["average"]
+    np.testing.assert_almost_equal(templates0, templates1)
+
+    # import matplotlib.pyplot as plt
+    # fig, ax = plt.subplots()
+    # for unit_index, unit_id in enumerate(sortres.unit_ids):
+    #     wf0 = templates0[unit_index, :, :]
+    #     ax.plot(wf0.T.flatten(), label=f"{unit_id}")
+    #     wf1 = templates1[unit_index, :, :]
+    #     ax.plot(wf1.T.flatten(), ls='--', color='k')
+    # ax.legend()
+    # plt.show()
+
+
+
+if __name__ == '__main__':
+    # test_ComputeWaveforms(format="memory", sparse=True)
+    # test_ComputeWaveforms(format="memory", sparse=False)
+    # test_ComputeWaveforms(format="binary_folder", sparse=True)
+    # test_ComputeWaveforms(format="binary_folder", sparse=False)
+    # test_ComputeWaveforms(format="zarr", sparse=True)
+    # test_ComputeWaveforms(format="zarr", sparse=False)
+
+    # test_ComputeTemplates(format="memory", sparse=True)
+    # test_ComputeTemplates(format="memory", sparse=False)
+    # test_ComputeTemplates(format="binary_folder", sparse=True)
+    # test_ComputeTemplates(format="zarr", sparse=True)
+
+    test_ComputeFastTemplates(format="memory", sparse=True)
diff --git a/src/spikeinterface/core/tests/test_sorting_tools.py b/src/spikeinterface/core/tests/test_sorting_tools.py
index 63e4e7d6b..06819fa51 100644
--- a/src/spikeinterface/core/tests/test_sorting_tools.py
+++ b/src/spikeinterface/core/tests/test_sorting_tools.py
@@ -52,5 +52,5 @@ def test_random_spikes_selection():
 
 
 if __name__ == "__main__":
-    # test_spike_vector_to_spike_trains()
+    test_spike_vector_to_spike_trains()
     test_random_spikes_selection()
diff --git a/src/spikeinterface/core/tests/test_sortingresult.py b/src/spikeinterface/core/tests/test_sortingresult.py
new file mode 100644
index 000000000..111ccd6cd
--- /dev/null
+++ b/src/spikeinterface/core/tests/test_sortingresult.py
@@ -0,0 +1,200 @@
+import pytest
+from pathlib import Path
+
+import shutil
+
+from spikeinterface.core import generate_ground_truth_recording
+from spikeinterface.core import SortingResult, start_sorting_result, load_sorting_result
+from spikeinterface.core.sortingresult import register_result_extension, ResultExtension
+
+import numpy as np
+
+if hasattr(pytest, "global_test_folder"):
+    cache_folder = pytest.global_test_folder / "core"
+else:
+    cache_folder = Path("cache_folder") / "core"
+
+
+def get_dataset():
+    recording, sorting = generate_ground_truth_recording(
+        durations=[30.0], sampling_frequency=16000.0, num_channels=10, num_units=5,
+        generate_sorting_kwargs=dict(firing_rates=10.0, refractory_period_ms=4.0),
+        noise_kwargs=dict(noise_level=5.0, strategy="tile_pregenerated"),
+        seed=2205,
+    )
+    return recording, sorting
+
+
+def test_SortingResult_memory():
+    recording, sorting = get_dataset()
+    sortres = start_sorting_result(sorting, recording, format="memory", sparse=False, sparsity=None)
+    _check_sorting_results(sortres, sorting)
+
+    sortres = start_sorting_result(sorting, recording, format="memory", sparse=True, sparsity=None)
+    _check_sorting_results(sortres, sorting)
+
+
+
+def test_SortingResult_binary_folder():
+    recording, sorting = get_dataset()
+
+    folder = cache_folder / "test_SortingResult_binary_folder"
+    if folder.exists():
+        shutil.rmtree(folder)
+
+    sortres = start_sorting_result(sorting, recording, format="binary_folder", folder=folder,  sparse=False, sparsity=None)
+    sortres = load_sorting_result(folder, format="auto")
+    _check_sorting_results(sortres, sorting)
+
+
+def test_SortingResult_zarr():
+    recording, sorting = get_dataset()
+
+    folder = cache_folder / "test_SortingResult_zarr.zarr"
+    if folder.exists():
+        shutil.rmtree(folder)
+
+    sortres = start_sorting_result(sorting, recording, format="zarr", folder=folder,  sparse=False, sparsity=None)
+    sortres = load_sorting_result(folder, format="auto")
+    _check_sorting_results(sortres, sorting)
+
+
+
+def _check_sorting_results(sortres, original_sorting):
+
+    print()
+    print(sortres)
+
+    register_result_extension(DummyResultExtension)
+
+    assert "channel_ids" in sortres.rec_attributes
+    assert "sampling_frequency" in sortres.rec_attributes
+    assert "num_samples" in sortres.rec_attributes
+
+    probe = sortres.get_probe()
+    sparsity = sortres.sparsity
+    
+    # compute
+    sortres.compute("dummy", param1=5.5)
+    # equivalent
+    compute_dummy(sortres, param1=5.5)
+    ext = sortres.get_extension("dummy")
+    assert ext is not None
+    assert ext.params["param1"] == 5.5
+    print(sortres)
+    # recompute
+    sortres.compute("dummy", param1=5.5)
+    # and delete
+    sortres.delete_extension("dummy")
+    ext = sortres.get_extension("dummy")
+    assert ext is None
+
+    
+    assert sortres.has_recording()
+
+    if sortres.random_spikes_indices is None:
+        sortres.select_random_spikes(max_spikes_per_unit=10, seed=2205)
+        assert sortres.random_spikes_indices is not None
+        assert sortres.random_spikes_indices.size == 10 * sortres.sorting.unit_ids.size
+
+    # save to several format
+    for format in ("memory", "binary_folder", "zarr"):
+        if format != "memory":
+            if format == "zarr":
+                folder = cache_folder / f"test_SortingResult_save_as_{format}.zarr"
+            else:
+                folder = cache_folder / f"test_SortingResult_save_as_{format}"
+            if folder.exists():
+                shutil.rmtree(folder)
+        else:
+            folder = None
+
+        # compute one extension to check the save
+        sortres.compute("dummy")
+
+        sortres2 = sortres.save_as(format=format, folder=folder)
+        ext = sortres2.get_extension("dummy")
+        assert ext is not None
+        
+        data = sortres2.get_extension("dummy").data
+        assert "result_one" in data
+        assert data["result_two"].size == original_sorting.to_spike_vector().size
+
+    # select unit_ids to several format
+    for format in ("memory", "binary_folder", "zarr"):
+        if format != "memory":
+            if format == "zarr":
+                folder = cache_folder / f"test_SortingResult_select_units_with_{format}.zarr"
+            else:
+                folder = cache_folder / f"test_SortingResult_select_units_with_{format}"
+            if folder.exists():
+                shutil.rmtree(folder)
+        else:
+            folder = None
+        # compute one extension to check the slice
+        sortres.compute("dummy")
+        keep_unit_ids = original_sorting.unit_ids[::2]
+        sortres2 = sortres.select_units(unit_ids=keep_unit_ids, format=format, folder=folder)
+
+        # check propagation of result data and correct sligin
+        assert np.array_equal(keep_unit_ids, sortres2.unit_ids)
+        data = sortres2.get_extension("dummy").data
+        assert data["result_one"] == sortres.get_extension("dummy").data["result_one"]
+        # unit 1, 3, ... should be removed
+        assert np.all(~np.isin(data["result_two"], [1, 3]))
+
+
+class DummyResultExtension(ResultExtension):
+    extension_name = "dummy"
+    depend_on = []
+    need_recording = False
+    use_nodepipeline = False
+
+    def _set_params(self, param0="yep", param1=1.2, param2=[1,2, 3.]):
+        params = dict(param0=param0, param1=param1, param2=param2)
+        params["more_option"] = "yep"
+        return params
+    
+    def _run(self, **kwargs):
+        # print("dummy run")
+        self.data["result_one"] = "abcd"
+        # the result two has the same size of the spike vector!!
+        # and represent nothing (the trick is to use unit_index for testing slice)
+        spikes = self.sorting_result.sorting.to_spike_vector()
+        self.data["result_two"] = spikes["unit_index"].copy()
+    
+    def _select_extension_data(self, unit_ids):
+        keep_unit_indices = np.flatnonzero(np.isin(self.sorting_result.unit_ids, unit_ids))
+
+        spikes = self.sorting_result.sorting.to_spike_vector()
+        keep_spike_mask = np.isin(spikes["unit_index"], keep_unit_indices)
+        # here the first key do not depend on unit_id
+        # but the second need to be sliced!!
+        new_data = dict()
+        new_data["result_one"] = self.data["result_one"]
+        new_data["result_two"] = self.data["result_two"][keep_spike_mask]
+
+        return new_data
+
+compute_dummy = DummyResultExtension.function_factory()
+
+
+class DummyResultExtension2(ResultExtension):
+    extension_name = "dummy"
+
+
+def test_extension():
+    register_result_extension(DummyResultExtension)
+    # can be register twice without error
+    register_result_extension(DummyResultExtension)
+
+    # other extension with same name should trigger an error
+    with pytest.raises(AssertionError):
+        register_result_extension(DummyResultExtension2)
+
+
+if __name__ == "__main__":
+    # test_SortingResult_memory()
+    test_SortingResult_binary_folder()
+    test_SortingResult_zarr()
+    # test_extension()
diff --git a/src/spikeinterface/core/tests/test_waveforms_extractor_backwards_compatibility.py b/src/spikeinterface/core/tests/test_waveforms_extractor_backwards_compatibility.py
new file mode 100644
index 000000000..443d75c08
--- /dev/null
+++ b/src/spikeinterface/core/tests/test_waveforms_extractor_backwards_compatibility.py
@@ -0,0 +1,90 @@
+import pytest
+from pathlib import Path
+
+import shutil
+
+
+from spikeinterface.core import generate_ground_truth_recording
+
+from spikeinterface.core.waveforms_extractor_backwards_compatibility import extract_waveforms as mock_extract_waveforms
+from spikeinterface.core.waveforms_extractor_backwards_compatibility import load_waveforms as load_waveforms_backwards
+
+# remove this when WaveformsExtractor will be removed
+from spikeinterface.core import extract_waveforms as old_extract_waveforms
+
+
+
+
+if hasattr(pytest, "global_test_folder"):
+    cache_folder = pytest.global_test_folder / "core"
+else:
+    cache_folder = Path("cache_folder") / "core"
+
+
+def get_dataset():
+    recording, sorting = generate_ground_truth_recording(
+        durations=[30.0, 20.], sampling_frequency=16000.0, num_channels=4, num_units=5,
+        generate_sorting_kwargs=dict(firing_rates=10.0, refractory_period_ms=4.0),
+        generate_unit_locations_kwargs=dict(
+            margin_um=5.0,
+            minimum_z=5.0,
+            maximum_z=20.0,
+        ),
+        generate_templates_kwargs=dict(
+            unit_params_range=dict(
+                alpha=(9_000.0, 12_000.0),
+            )
+        ),
+        noise_kwargs=dict(noise_level=5.0, strategy="tile_pregenerated"),
+        seed=2406,
+    )
+    return recording, sorting
+
+
+def test_extract_waveforms():
+    recording, sorting = get_dataset()
+
+    folder = cache_folder / "old_waveforms_extractor"
+    if folder.exists():
+        shutil.rmtree(folder)
+
+    we_kwargs = dict(sparse=True, max_spikes_per_unit=30)
+
+    
+    we_old = old_extract_waveforms(recording, sorting, folder=folder, **we_kwargs)
+    print(we_old)
+
+    folder = cache_folder / "mock_waveforms_extractor"
+    if folder.exists():
+        shutil.rmtree(folder)
+
+    we_mock = mock_extract_waveforms(recording, sorting, folder=folder, **we_kwargs)
+    print(we_mock)
+    
+    for we in (we_old, we_mock):
+
+        selected_spikes = we.get_sampled_indices(unit_id=sorting.unit_ids[0])
+        # print(selected_spikes.size, selected_spikes.dtype)
+
+        wfs = we.get_waveforms(sorting.unit_ids[0])
+        # print(wfs.shape)
+
+        wfs = we.get_waveforms(sorting.unit_ids[0], force_dense=True)
+        # print(wfs.shape)
+
+        templates = we.get_all_templates()
+        # print(templates.shape)
+
+
+
+    # test reading old WaveformsExtractor folder
+    folder = cache_folder / "old_waveforms_extractor"
+    sorting_result_from_we = load_waveforms_backwards(folder, output="SortingResult")
+    print(sorting_result_from_we)
+    mock_loaded_we_old = load_waveforms_backwards(folder, output="MockWaveformExtractor")
+    print(mock_loaded_we_old)
+
+
+
+if __name__ == "__main__":
+    test_extract_waveforms()