Skip to content

Commit c50f809

Browse files
authored
Merge branch 'main' into limit-pytest
2 parents a395c80 + dfbc584 commit c50f809

22 files changed

Lines changed: 372 additions & 74 deletions

File tree

.github/scripts/test_kilosort4_ci.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@
112112
PARAMS_TO_TEST_DICT.update({"cluster_neighbors": 11})
113113
PARAMETERS_NOT_AFFECTING_RESULTS.append("cluster_neighbors")
114114

115+
if parse(kilosort.__version__) >= parse("4.0.37"):
116+
PARAMS_TO_TEST_DICT.update({"max_cluster_subset": 20})
117+
PARAMETERS_NOT_AFFECTING_RESULTS.append("max_cluster_subset")
118+
115119

116120
PARAMS_TO_TEST = list(PARAMS_TO_TEST_DICT.keys())
117121

@@ -254,6 +258,8 @@ def test_initialize_ops_arguments(self):
254258
"device",
255259
"save_preprocessed_copy",
256260
]
261+
if parse(kilosort.__version__) >= parse("4.0.37"):
262+
expected_arguments += ["gui_mode"]
257263

258264
self._check_arguments(
259265
initialize_ops,

doc/how_to/combine_recordings.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Combine recordings in SpikeInterface
44
In this tutorial we will walk through combining multiple recording objects. Sometimes this occurs due to hardware
55
settings (e.g. Intan software has a default setting of new files every 1 minute) or the experimenter decides to
66
split their recording into multiple files for different experimental conditions. If the probe has not been moved,
7-
however, then during sorting it would likely make sense to combine these individual reocrding objects into one
7+
however, then during sorting it would likely make sense to combine these individual recording objects into one
88
recording object.
99

1010
**Why Combine?**

doc/how_to/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ Guides on how to solve specific, short problems in SpikeInterface. Learn how to.
1717
drift_with_lfp
1818
auto_curation_training
1919
auto_curation_prediction
20+
physical_units
2021
customize_a_plot

doc/how_to/physical_units.rst

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
Working with physical units in SpikeInterface recordings
2+
========================================================
3+
4+
In neurophysiology recordings, data is often stored in raw ADC (Analog-to-Digital Converter) integer values but needs to be analyzed in physical units.
5+
For extracellular recordings, this is typically microvolts (µV), but some recording devices may use different physical units.
6+
SpikeInterface provides tools to handle both situations.
7+
8+
It's important to note that **most spike sorters work fine on raw digital (ADC) units** and scaling is not needed. Going a step further, some sorters, such as Kilosort 3, require their input to be in raw ADC units.
9+
The specific behavior however depends on the spike sorter, so it's important to understand the specific input requirements on a case per case basis.
10+
11+
Many preprocessing tools are also linear transformations, and if the ADC is implemented as a linear transformation which is fairly common, then the overall effect can be preserved.
12+
That is, **preprocessing steps can often be applied either before or after unit conversion without affecting the outcome.**. That being said, there are rough edges to this approach.
13+
preprocessing algorithms like filtering, whitening, centering, interpolation and common reference require casting to float within the pipeline. We advise users to experiment
14+
with different approaches to find the best one for their specific use case.
15+
16+
17+
Therefore, **it is usually safe to work in raw ADC integer values unless a specific tool or analysis requires physical units**.
18+
If you are interested in visualizations, comparability across devices, or outputs with interpretable physical scales (e.g., microvolts), converting to physical units is recommended.
19+
Otherwise, remaining in raw units can simplify processing and preserve performance.
20+
21+
Understanding Physical Units
22+
----------------------------
23+
24+
Most recording devices store data in ADC units (integers) to save space and preserve the raw data.
25+
To convert these values to physical units, two parameters are needed:
26+
27+
* **gain**: A multiplicative factor to scale the raw values
28+
* **offset**: An additive factor to shift the values
29+
30+
The conversion formula is:
31+
32+
.. code-block:: text
33+
34+
physical_value = raw_value * gain + offset
35+
36+
37+
Converting to Physical Units
38+
----------------------------
39+
40+
SpikeInterface provides two preprocessing classes for converting recordings to physical units. Both wrap the
41+
``RecordingExtractor`` class and ensures that the data is returned in physical units when calling `get_traces <https://spikeinterface.readthedocs.io/en/stable/api.html#spikeinterface.core.BaseRecording.get_traces>`_
42+
43+
1. ``scale_to_uV``: The primary function for extracellular recordings. SpikeInterface is centered around
44+
extracellular recordings, and this function is designed to convert the data to microvolts (µV).
45+
2. ``scale_to_physical_units``: A general function for any physical unit conversion. This will allow you to extract the data in any
46+
physical unit, not just microvolts. This is useful for other types of recordings, such as force measurements in Newtons but should be
47+
handled with care.
48+
49+
For most users working with extracellular recordings, ``scale_to_uV`` is the recommended choice if they want to work in physical units:
50+
51+
.. code-block:: python
52+
53+
from spikeinterface.extractors import read_intan
54+
from spikeinterface.preprocessing import scale_to_uV
55+
56+
# Load recording (data is in ADC units)
57+
recording = read_intan("path/to/file.rhs")
58+
59+
# Convert to microvolts
60+
recording_uv = scale_to_uV(recording)
61+
62+
For recordings with non-standard units (e.g., force measurements in Newtons), use ``scale_to_physical_units``:
63+
64+
.. code-block:: python
65+
66+
from spikeinterface.preprocessing import scale_to_physical_units
67+
68+
# Convert to physical units (whatever they may be)
69+
recording_physical = scale_to_physical_units(recording)
70+
71+
Both preprocessors automatically:
72+
73+
1. Detect the appropriate gain and offset from the recording properties
74+
2. Apply the conversion to all channels
75+
3. Update the recording properties to reflect that data is now in physical units
76+
77+
Setting Custom Physical Units
78+
-----------------------------
79+
80+
While most extractors automatically set the appropriate ``gain_to_uV`` and ``offset_to_uV`` values,
81+
there might be cases where you want to set custom physical units. In these cases, you can set
82+
the following properties:
83+
84+
* ``physical_unit``: The target physical unit (e.g., 'uV', 'mV', 'N')
85+
* ``gain_to_unit``: The gain to convert from raw values to the target unit
86+
* ``offset_to_unit``: The offset to convert from raw values to the target unit
87+
88+
You need to set these properties for every channel, which allows for the case when there are different gains and offsets on different channels. Here's an example:
89+
90+
.. code-block:: python
91+
92+
# Set custom physical units
93+
num_channels = recording.get_num_channels()
94+
values = ["volts"] * num_channels
95+
recording.set_property(key='physical_unit', values=values)
96+
97+
gain_values = [0.001] * num_channels # Convert from ADC to volts
98+
recording.set_property(key='gain_to_unit', values=gain_values) # Convert to volts
99+
100+
offset_values = [0] * num_channels # No offset
101+
recording.set_property(key='offset_to_unit', values=offset_values) # No offset
102+
103+
# Apply the conversion using scale_to_physical_units
104+
recording_physical = scale_to_physical_units(recording)
105+
106+
This approach gives you full control over the unit conversion process while maintaining
107+
compatibility with SpikeInterface's preprocessing pipeline.

src/spikeinterface/benchmark/benchmark_base.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,6 @@ def create(cls, study_folder, datasets={}, cases={}, levels=None):
134134
else:
135135
analyzer = data
136136

137-
rec, gt_sorting = analyzer.recording, analyzer.sorting
138-
139137
analyzers_path[key] = str(analyzer.folder.resolve())
140138

141139
# recordings are pickled
@@ -180,7 +178,11 @@ def scan_folder(self):
180178
self.analyzers[key] = analyzer
181179
# the sorting is in memory here we take the saved one because comparisons need to pickle it later
182180
sorting = load(analyzer.folder / "sorting")
183-
self.datasets[key] = analyzer.recording, sorting
181+
if analyzer.has_recording():
182+
recording = analyzer.recording
183+
else:
184+
recording = None
185+
self.datasets[key] = recording, sorting
184186

185187
with open(self.folder / "cases.pickle", "rb") as f:
186188
self.cases = pickle.load(f)
@@ -594,15 +596,22 @@ def load_folder(cls, folder):
594596
elif format == "sorting":
595597
from spikeinterface.core import load_extractor
596598

597-
result[k] = load(folder / k)
599+
sorting_folder = folder / k
600+
if sorting_folder.exists():
601+
result[k] = load(sorting_folder)
598602
elif format == "Motion":
599603
from spikeinterface.core.motion import Motion
600604

601-
result[k] = Motion.load(folder / k)
605+
motion_folder = folder / k
606+
if motion_folder.exists():
607+
result[k] = Motion.load(motion_folder)
602608
elif format == "zarr_templates":
603609
from spikeinterface.core.template import Templates
604610

605-
result[k] = Templates.from_zarr(folder / k)
611+
zarr_folder = folder / k
612+
if zarr_folder.exists():
613+
614+
result[k] = Templates.from_zarr(zarr_folder)
606615

607616
return result
608617

src/spikeinterface/core/baserecording.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ class BaseRecording(BaseRecordingSnippets):
2020
"""
2121

2222
_main_annotations = BaseRecordingSnippets._main_annotations + ["is_filtered"]
23-
_main_properties = ["group", "location", "gain_to_uV", "offset_to_uV"]
23+
_main_properties = [
24+
"group",
25+
"location",
26+
"gain_to_uV",
27+
"offset_to_uV",
28+
"gain_to_physical_unit",
29+
"offset_to_physical_unit",
30+
"physical_unit",
31+
]
2432
_main_features = [] # recording do not handle features
2533

2634
_skip_properties = [
@@ -541,13 +549,14 @@ def shift_times(self, shift: int | float, segment_index: int | None = None) -> N
541549
else:
542550
segments_to_shift = (segment_index,)
543551

544-
for idx in segments_to_shift:
545-
rs = self._recording_segments[idx]
552+
for segment_index in segments_to_shift:
553+
rs = self._recording_segments[segment_index]
546554

547-
if self.has_time_vector(segment_index=idx):
555+
if self.has_time_vector(segment_index=segment_index):
548556
rs.time_vector += shift
549557
else:
550-
rs.t_start += shift
558+
new_start_time = 0 + shift if rs.t_start is None else rs.t_start + shift
559+
rs.t_start = new_start_time
551560

552561
def sample_index_to_time(self, sample_ind, segment_index=None):
553562
"""
@@ -749,9 +758,9 @@ def frame_slice(self, start_frame: int | None, end_frame: int | None) -> BaseRec
749758
Parameters
750759
----------
751760
start_frame : int, optional
752-
The start frame, if not provided it is set to 0
761+
Start frame index. If None, defaults to the beginning of the recording (frame 0).
753762
end_frame : int, optional
754-
The end frame, it not provided it is set to the total number of samples
763+
End frame index. If None, defaults to the last frame of the recording.
755764
756765
Returns
757766
-------
@@ -771,20 +780,46 @@ def time_slice(self, start_time: float | None, end_time: float | None) -> BaseRe
771780
Parameters
772781
----------
773782
start_time : float, optional
774-
The start time in seconds. If not provided it is set to 0.
783+
Start time in seconds. If None, defaults to the beginning of the recording.
775784
end_time : float, optional
776-
The end time in seconds. If not provided it is set to the total duration.
785+
End time in seconds. If None, defaults to the end of the recording.
777786
778787
Returns
779788
-------
780789
BaseRecording
781790
A new recording object with only samples between start_time and end_time
782791
"""
792+
num_segments = self.get_num_segments()
793+
assert (
794+
num_segments == 1
795+
), f"Time slicing is only supported for single segment recordings. Found {num_segments} segments."
796+
797+
t_start = self.get_start_time()
798+
t_end = self.get_end_time()
799+
800+
if start_time is not None:
801+
t_start = self.get_start_time()
802+
t_start_too_early = start_time < t_start
803+
if t_start_too_early:
804+
raise ValueError(f"start_time {start_time} is before the start time {t_start} of the recording.")
805+
t_start_too_late = start_time > t_end
806+
if t_start_too_late:
807+
raise ValueError(f"start_time {start_time} is after the end time {t_end} of the recording.")
808+
start_frame = self.time_to_sample_index(start_time)
809+
else:
810+
start_frame = None
783811

784-
assert self.get_num_segments() == 1, "Time slicing is only supported for single segment recordings."
812+
if end_time is not None:
813+
t_end_too_early = end_time < t_start
814+
if t_end_too_early:
815+
raise ValueError(f"end_time {end_time} is before the start time {t_start} of the recording.")
785816

786-
start_frame = self.time_to_sample_index(start_time) if start_time else None
787-
end_frame = self.time_to_sample_index(end_time) if end_time else None
817+
t_end_too_late = end_time > t_end
818+
if t_end_too_late:
819+
raise ValueError(f"end_time {end_time} is after the end time {t_end} of the recording.")
820+
end_frame = self.time_to_sample_index(end_time)
821+
else:
822+
end_frame = None
788823

789824
return self.frame_slice(start_frame=start_frame, end_frame=end_frame)
790825

src/spikeinterface/core/generate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def generate_recording(
3333
set_probe: bool | None = True,
3434
ndim: int | None = 2,
3535
seed: int | None = None,
36-
) -> NumpySorting:
36+
) -> BaseRecording:
3737
"""
3838
Generate a lazy recording object.
3939
Useful for testing API and algos.

src/spikeinterface/core/tests/test_baserecording.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,27 @@ def test_time_slice():
412412
assert np.allclose(sliced_recording_times.get_traces(), sliced_recording_frames.get_traces())
413413

414414

415+
def test_out_of_range_time_slice():
416+
recording = generate_recording(durations=[0.100]) # duration = 0.1 s
417+
recording.shift_times(1.0) # shifts start time to 1.0 s, end time to 1.1 s
418+
419+
# start_time before recording
420+
with pytest.raises(ValueError, match="start_time .* is before the start time"):
421+
recording.time_slice(start_time=0, end_time=None)
422+
423+
# end_time before start of recording
424+
with pytest.raises(ValueError, match="end_time .* is before the start time"):
425+
recording.time_slice(start_time=None, end_time=0.5)
426+
427+
# start_time after end of recording
428+
with pytest.raises(ValueError, match="start_time .* is after the end time"):
429+
recording.time_slice(start_time=2.0, end_time=None)
430+
431+
# end_time after end of recording
432+
with pytest.raises(ValueError, match="end_time .* is after the end time"):
433+
recording.time_slice(start_time=None, end_time=2.0)
434+
435+
415436
def test_time_slice_with_time_vector():
416437

417438
# Case with time vector

src/spikeinterface/core/tests/test_time_handling.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,12 @@ def _get_sorting_with_recording_attached(self, recording_for_durations, recordin
435435
assert sorting.has_recording()
436436

437437
return sorting
438+
439+
440+
def test_shift_times_with_None_as_t_start():
441+
"""Ensures we can shift times even when t_stat is None which is interpeted as zero"""
442+
recording = generate_recording(num_channels=4, durations=[10])
443+
444+
assert recording._recording_segments[0].t_start is None
445+
recording.shift_times(shift=1.0) # Shift by one seconds should not generate an error
446+
assert recording.get_start_time() == 1.0

src/spikeinterface/extractors/neoextractors/maxwell.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class MaxwellRecordingExtractor(NeoBaseRecordingExtractor):
3636
rec_name : str, default: None
3737
When the file contains several recordings you need to specify the one
3838
you want to extract. (rec_name='rec0000').
39-
install_maxwell_plugin : bool, default: False
39+
install_maxwell_plugin : bool, default: True
4040
If True, install the maxwell plugin for neo.
4141
block_index : int, default: None
4242
If there are several blocks (experiments), specify the block index you want to load
@@ -52,7 +52,7 @@ def __init__(
5252
block_index=None,
5353
all_annotations=False,
5454
rec_name=None,
55-
install_maxwell_plugin=False,
55+
install_maxwell_plugin=True,
5656
use_names_as_ids: bool = False,
5757
):
5858
if install_maxwell_plugin:

0 commit comments

Comments
 (0)