aind-ecephys-s3-to-phy 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aind_ecephys_s3_to_phy/__init__.py +8 -0
- aind_ecephys_s3_to_phy/analyzer.py +236 -0
- aind_ecephys_s3_to_phy/cli.py +392 -0
- aind_ecephys_s3_to_phy/export.py +362 -0
- aind_ecephys_s3_to_phy/py.typed +1 -0
- aind_ecephys_s3_to_phy/recording.py +196 -0
- aind_ecephys_s3_to_phy-0.4.0.dist-info/METADATA +133 -0
- aind_ecephys_s3_to_phy-0.4.0.dist-info/RECORD +11 -0
- aind_ecephys_s3_to_phy-0.4.0.dist-info/WHEEL +4 -0
- aind_ecephys_s3_to_phy-0.4.0.dist-info/entry_points.txt +2 -0
- aind_ecephys_s3_to_phy-0.4.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Load SortingAnalyzers and discover recordings in S3 assets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import s3fs
|
|
9
|
+
import spikeinterface as si
|
|
10
|
+
from spikeinterface.core.baserecording import BaseRecording
|
|
11
|
+
from spikeinterface.core.sortinganalyzer import SortingAnalyzer
|
|
12
|
+
|
|
13
|
+
from aind_ecephys_s3_to_phy.recording import match_analyzer_to_raw
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_s3(uri: str) -> bool:
|
|
17
|
+
"""Check whether a URI is an S3 path."""
|
|
18
|
+
return uri.startswith("s3://")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_analyzer(
|
|
22
|
+
analyzer_path: str,
|
|
23
|
+
storage_options: dict[str, Any] | None = None,
|
|
24
|
+
) -> SortingAnalyzer:
|
|
25
|
+
"""Load a SortingAnalyzer and ensure the ``templates`` extension is available.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
analyzer_path : str
|
|
30
|
+
Path or S3 URI to the SortingAnalyzer ``.zarr`` directory.
|
|
31
|
+
storage_options : dict or None
|
|
32
|
+
fsspec storage options.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
SortingAnalyzer
|
|
37
|
+
The loaded analyzer with ``templates`` extension loaded.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If the ``templates`` extension is not available.
|
|
43
|
+
"""
|
|
44
|
+
backend_options = {"storage_options": storage_options or {}}
|
|
45
|
+
analyzer: SortingAnalyzer = si.load_sorting_analyzer(analyzer_path, backend_options=backend_options)
|
|
46
|
+
|
|
47
|
+
# Extensions are lazy for remote paths — explicitly load templates
|
|
48
|
+
if analyzer.get_extension("templates") is None:
|
|
49
|
+
analyzer.load_extension("templates")
|
|
50
|
+
if analyzer.get_extension("templates") is None:
|
|
51
|
+
raise ValueError(f"Analyzer at {analyzer_path} has no 'templates' extension (required for phy export).")
|
|
52
|
+
|
|
53
|
+
return analyzer
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def attach_recording(analyzer: SortingAnalyzer, recording: BaseRecording) -> None:
|
|
57
|
+
"""Attach a preprocessed recording to a SortingAnalyzer.
|
|
58
|
+
|
|
59
|
+
Validates that the recording attributes match the analyzer's expectations,
|
|
60
|
+
then sets both the temporary and internal recording references. The
|
|
61
|
+
internal ``_recording`` assignment is needed because ``export_to_phy``
|
|
62
|
+
checks ``has_recording()`` which only inspects ``_recording``.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
analyzer : SortingAnalyzer
|
|
67
|
+
The analyzer to attach the recording to.
|
|
68
|
+
recording : BaseRecording
|
|
69
|
+
The preprocessed recording (must match analyzer's channel_ids, etc.).
|
|
70
|
+
|
|
71
|
+
Raises
|
|
72
|
+
------
|
|
73
|
+
ValueError
|
|
74
|
+
If the recording attributes don't match the analyzer.
|
|
75
|
+
"""
|
|
76
|
+
# set_temporary_recording validates attributes and channel locations
|
|
77
|
+
analyzer.set_temporary_recording(recording)
|
|
78
|
+
# Work around: export_to_phy checks has_recording() which only looks at
|
|
79
|
+
# _recording, not _temporary_recording.
|
|
80
|
+
analyzer._recording = recording
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- Discovery ---
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_recordings(
|
|
87
|
+
sorted_asset: str,
|
|
88
|
+
storage_options: dict[str, Any] | None = None,
|
|
89
|
+
) -> list[tuple[str, int]]:
|
|
90
|
+
"""List postprocessed SortingAnalyzer Zarrs in a sorted asset.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
sorted_asset : str
|
|
95
|
+
S3 URI or local path to the sorted asset.
|
|
96
|
+
storage_options : dict or None
|
|
97
|
+
fsspec storage options (defaults to anonymous access for S3).
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
list of (name, size_bytes)
|
|
102
|
+
Analyzer Zarr names and their total sizes.
|
|
103
|
+
"""
|
|
104
|
+
subdir = sorted_asset.rstrip("/") + "/postprocessed"
|
|
105
|
+
return _list_zarrs(subdir, storage_options)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def list_raw_recordings(
|
|
109
|
+
raw_asset: str,
|
|
110
|
+
storage_options: dict[str, Any] | None = None,
|
|
111
|
+
) -> list[tuple[str, int]]:
|
|
112
|
+
"""List compressed raw recording Zarrs in a raw asset.
|
|
113
|
+
|
|
114
|
+
Filters to Neuropixels recordings only (excludes NI-DAQ).
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
raw_asset : str
|
|
119
|
+
S3 URI or local path to the raw asset.
|
|
120
|
+
storage_options : dict or None
|
|
121
|
+
fsspec storage options.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
list of (name, size_bytes)
|
|
126
|
+
Raw recording Zarr names and their total sizes.
|
|
127
|
+
"""
|
|
128
|
+
subdir = raw_asset.rstrip("/") + "/ecephys/ecephys_compressed"
|
|
129
|
+
all_zarrs = _list_zarrs(subdir, storage_options)
|
|
130
|
+
return [(name, size) for name, size in all_zarrs if "Neuropix" in name]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def discover_pairs(
|
|
134
|
+
sorted_asset_uri: str,
|
|
135
|
+
raw_asset_uri: str,
|
|
136
|
+
storage_options: dict[str, Any] | None = None,
|
|
137
|
+
) -> list[tuple[str, str]]:
|
|
138
|
+
"""Match postprocessed analyzers to their raw recordings.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
sorted_asset_uri : str
|
|
143
|
+
S3 URI of the sorted asset.
|
|
144
|
+
raw_asset_uri : str
|
|
145
|
+
S3 URI of the raw asset.
|
|
146
|
+
storage_options : dict or None
|
|
147
|
+
fsspec storage options.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
list of (analyzer_name, raw_name)
|
|
152
|
+
Matched pairs. Unmatched analyzers are excluded.
|
|
153
|
+
"""
|
|
154
|
+
analyzers = list_recordings(sorted_asset_uri, storage_options)
|
|
155
|
+
raw_recs = list_raw_recordings(raw_asset_uri, storage_options)
|
|
156
|
+
raw_names = {name for name, _ in raw_recs}
|
|
157
|
+
|
|
158
|
+
pairs: list[tuple[str, str]] = []
|
|
159
|
+
for az_name, _ in analyzers:
|
|
160
|
+
raw_candidate = match_analyzer_to_raw(az_name)
|
|
161
|
+
if raw_candidate in raw_names:
|
|
162
|
+
pairs.append((az_name, raw_candidate))
|
|
163
|
+
|
|
164
|
+
return pairs
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- Filtering ---
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def filter_recordings(
|
|
171
|
+
names: list[str],
|
|
172
|
+
*,
|
|
173
|
+
probe: str | None = None,
|
|
174
|
+
experiment: str | None = None,
|
|
175
|
+
) -> list[str]:
|
|
176
|
+
"""Filter recording names by probe ID and/or experiment.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
names : list of str
|
|
181
|
+
Recording names (e.g. analyzer zarr filenames).
|
|
182
|
+
probe : str or None
|
|
183
|
+
If given, keep only names containing this substring (e.g. ``"46101"``).
|
|
184
|
+
experiment : str or None
|
|
185
|
+
If given, keep only names starting with this prefix (e.g.
|
|
186
|
+
``"experiment1"``).
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
list of str
|
|
191
|
+
Filtered names, in original order.
|
|
192
|
+
"""
|
|
193
|
+
result = names
|
|
194
|
+
if experiment is not None:
|
|
195
|
+
result = [n for n in result if n.startswith(experiment)]
|
|
196
|
+
if probe is not None:
|
|
197
|
+
result = [n for n in result if probe in n]
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- Internal helpers ---
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _list_zarrs(directory: str, storage_options: dict[str, Any] | None = None) -> list[tuple[str, int]]:
|
|
205
|
+
"""List ``.zarr`` directories under a path (S3 or local)."""
|
|
206
|
+
if _is_s3(directory):
|
|
207
|
+
return _list_zarrs_s3(directory, storage_options)
|
|
208
|
+
return _list_zarrs_local(directory)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _list_zarrs_s3(directory: str, storage_options: dict[str, Any] | None) -> list[tuple[str, int]]:
|
|
212
|
+
"""List ``.zarr`` directories on S3."""
|
|
213
|
+
opts = storage_options or {"anon": True}
|
|
214
|
+
fs = s3fs.S3FileSystem(**opts)
|
|
215
|
+
prefix = directory.replace("s3://", "")
|
|
216
|
+
entries = fs.ls(prefix, detail=True)
|
|
217
|
+
results: list[tuple[str, int]] = []
|
|
218
|
+
for entry in entries:
|
|
219
|
+
name = entry["name"].split("/")[-1]
|
|
220
|
+
if name.endswith(".zarr"):
|
|
221
|
+
size: int = fs.du(entry["name"])
|
|
222
|
+
results.append((name, size))
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _list_zarrs_local(directory: str) -> list[tuple[str, int]]:
|
|
227
|
+
"""List ``.zarr`` directories on the local filesystem."""
|
|
228
|
+
parent = Path(directory)
|
|
229
|
+
if not parent.is_dir():
|
|
230
|
+
return []
|
|
231
|
+
results: list[tuple[str, int]] = []
|
|
232
|
+
for entry in sorted(parent.iterdir()):
|
|
233
|
+
if entry.name.endswith(".zarr") and entry.is_dir():
|
|
234
|
+
size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
|
|
235
|
+
results.append((entry.name, size))
|
|
236
|
+
return results
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""CLI entry point for s3-to-phy.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
s3-to-phy convert --sorted-uri s3://... --raw-uri s3://...
|
|
6
|
+
s3-to-phy convert --sorted-uri s3://... --raw-uri s3://... --probe 46101
|
|
7
|
+
s3-to-phy convert --sorted-uri s3://... --raw-uri s3://... --lazy
|
|
8
|
+
s3-to-phy list --sorted-uri s3://...
|
|
9
|
+
s3-to-phy extract-recording --analyzer-uri s3://... --raw-uri s3://... --output out.dat
|
|
10
|
+
s3-to-phy compress --phy-dir ./phy_output
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Annotated
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from spikeinterface.core.baserecording import BaseRecording
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import tyro
|
|
26
|
+
from aind_ephys_assets import correct_recording, load_motion
|
|
27
|
+
|
|
28
|
+
from aind_ecephys_s3_to_phy.analyzer import (
|
|
29
|
+
attach_recording,
|
|
30
|
+
discover_pairs,
|
|
31
|
+
filter_recordings,
|
|
32
|
+
list_raw_recordings,
|
|
33
|
+
list_recordings,
|
|
34
|
+
load_analyzer,
|
|
35
|
+
)
|
|
36
|
+
from aind_ecephys_s3_to_phy.export import compress_binary, export_phy, export_phy_lazy
|
|
37
|
+
from aind_ecephys_s3_to_phy.recording import lazy_recording_dict, match_analyzer_to_raw, reconstruct_recording
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Convert:
|
|
42
|
+
"""Full pipeline: export spike data, write binary traces, compress."""
|
|
43
|
+
|
|
44
|
+
sorted_uri: str
|
|
45
|
+
"""S3 URI or local path to the sorted asset."""
|
|
46
|
+
|
|
47
|
+
raw_uri: str
|
|
48
|
+
"""S3 URI or local path to the raw asset."""
|
|
49
|
+
|
|
50
|
+
output_dir: Path = Path(".")
|
|
51
|
+
"""Base output directory."""
|
|
52
|
+
|
|
53
|
+
recordings: list[str] = field(default_factory=list)
|
|
54
|
+
"""Recording names to export (default: all matched pairs)."""
|
|
55
|
+
|
|
56
|
+
probe: str | None = None
|
|
57
|
+
"""Filter recordings by probe ID substring (e.g. 46101)."""
|
|
58
|
+
|
|
59
|
+
experiment: str | None = None
|
|
60
|
+
"""Filter recordings by experiment prefix (e.g. experiment1)."""
|
|
61
|
+
|
|
62
|
+
compress: bool = True
|
|
63
|
+
"""Compress recording.dat to .cbin."""
|
|
64
|
+
|
|
65
|
+
skip_check: bool = False
|
|
66
|
+
"""Skip mtscomp compression verification."""
|
|
67
|
+
|
|
68
|
+
n_jobs: int = 1
|
|
69
|
+
"""Parallel jobs for binary writing and PC extraction."""
|
|
70
|
+
|
|
71
|
+
chunk_duration: str = "1s"
|
|
72
|
+
"""Chunk duration for binary writing."""
|
|
73
|
+
|
|
74
|
+
motion_correct: bool = True
|
|
75
|
+
"""Apply KS4 motion correction if a motion estimate exists (default: True)."""
|
|
76
|
+
|
|
77
|
+
lazy: bool = False
|
|
78
|
+
"""Write no traces: phy reads them from the raw asset when viewed, through recording.json.
|
|
79
|
+
Needs phylib with SpikeInterfaceEphysReader. Traces are not motion-corrected."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class List:
|
|
84
|
+
"""List available recordings in a sorted asset."""
|
|
85
|
+
|
|
86
|
+
sorted_uri: str
|
|
87
|
+
"""S3 URI or local path to the sorted asset."""
|
|
88
|
+
|
|
89
|
+
raw_uri: str | None = None
|
|
90
|
+
"""S3 URI or local path to the raw asset (shows matched pairs)."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ExtractRecording:
|
|
95
|
+
"""Write preprocessed recording to a flat binary file.
|
|
96
|
+
|
|
97
|
+
Reconstructs the full preprocessing chain from a SortingAnalyzer
|
|
98
|
+
(including channel selection) and writes the result as a flat binary.
|
|
99
|
+
With --compress, produces a .cbin (mtscomp) file instead.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
analyzer_uri: str
|
|
103
|
+
"""S3 URI or path to a postprocessed SortingAnalyzer .zarr."""
|
|
104
|
+
|
|
105
|
+
raw_uri: str
|
|
106
|
+
"""S3 URI or path to the raw recording .zarr."""
|
|
107
|
+
|
|
108
|
+
output: Path
|
|
109
|
+
"""Output file path (e.g. preprocessed.dat)."""
|
|
110
|
+
|
|
111
|
+
compress: bool = False
|
|
112
|
+
"""Compress output to .cbin (mtscomp)."""
|
|
113
|
+
|
|
114
|
+
dtype: str | None = None
|
|
115
|
+
"""Output dtype (default: same as recording, typically int16)."""
|
|
116
|
+
|
|
117
|
+
n_jobs: int = 1
|
|
118
|
+
"""Parallel jobs for binary writing."""
|
|
119
|
+
|
|
120
|
+
chunk_duration: str = "1s"
|
|
121
|
+
"""Chunk duration for binary writing."""
|
|
122
|
+
|
|
123
|
+
motion_correct: bool = True
|
|
124
|
+
"""Apply KS4 motion correction if a motion estimate exists (default: True)."""
|
|
125
|
+
|
|
126
|
+
sorted_uri: str | None = None
|
|
127
|
+
"""S3 URI or local path to the sorted asset (needed for motion correction)."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Compress:
|
|
132
|
+
"""Compress an existing recording.dat to mtscomp .cbin format."""
|
|
133
|
+
|
|
134
|
+
phy_dir: Path
|
|
135
|
+
"""Path to a phy output directory containing recording.dat."""
|
|
136
|
+
|
|
137
|
+
skip_check: bool = False
|
|
138
|
+
"""Skip compression verification."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
Cmd = Annotated[
|
|
142
|
+
Convert | List | ExtractRecording | Compress,
|
|
143
|
+
tyro.conf.arg(prefix_name=False),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> None:
|
|
148
|
+
"""CLI entry point."""
|
|
149
|
+
cmd = tyro.cli(Cmd, prog="s3-to-phy")
|
|
150
|
+
match cmd:
|
|
151
|
+
case Convert():
|
|
152
|
+
_run_convert(cmd)
|
|
153
|
+
case List():
|
|
154
|
+
_run_list(cmd)
|
|
155
|
+
case ExtractRecording():
|
|
156
|
+
_run_extract_recording(cmd)
|
|
157
|
+
case Compress():
|
|
158
|
+
_run_compress(cmd)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- Subcommand implementations ---
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run_convert(cmd: Convert) -> None:
|
|
165
|
+
"""Run the full conversion pipeline."""
|
|
166
|
+
recordings = _resolve_recordings(cmd)
|
|
167
|
+
sorted_name = cmd.sorted_uri.rstrip("/").split("/")[-1]
|
|
168
|
+
|
|
169
|
+
n_total = len(recordings)
|
|
170
|
+
n_ok = 0
|
|
171
|
+
n_fail = 0
|
|
172
|
+
|
|
173
|
+
for i, rec_name in enumerate(recordings, 1):
|
|
174
|
+
raw_name = match_analyzer_to_raw(rec_name)
|
|
175
|
+
analyzer_uri = f"{cmd.sorted_uri.rstrip('/')}/postprocessed/{rec_name}"
|
|
176
|
+
raw_uri = f"{cmd.raw_uri.rstrip('/')}/ecephys/ecephys_compressed/{raw_name}"
|
|
177
|
+
rec_stem = rec_name.removesuffix(".zarr")
|
|
178
|
+
phy_dir = cmd.output_dir / sorted_name / rec_stem
|
|
179
|
+
|
|
180
|
+
print(f"\n[{i}/{n_total}] {rec_name}")
|
|
181
|
+
print(f" Output: {phy_dir}")
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
_convert_one(cmd, analyzer_uri, raw_uri, phy_dir, rec_name)
|
|
185
|
+
n_ok += 1
|
|
186
|
+
print(f" Done: {phy_dir}")
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
print("\n Interrupted — cleaning up partial output...", file=sys.stderr)
|
|
189
|
+
_cleanup_partial(phy_dir)
|
|
190
|
+
sys.exit(130)
|
|
191
|
+
except Exception as exc: # noqa: BLE001 - one bad recording must not stop the batch
|
|
192
|
+
n_fail += 1
|
|
193
|
+
print(f" FAILED: {exc}", file=sys.stderr)
|
|
194
|
+
|
|
195
|
+
print(f"\nFinished: {n_ok} succeeded, {n_fail} failed out of {n_total}.")
|
|
196
|
+
if n_fail > 0:
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_recordings(cmd: Convert) -> list[str]:
|
|
201
|
+
"""Determine which recordings to export based on CLI flags."""
|
|
202
|
+
recordings = cmd.recordings
|
|
203
|
+
|
|
204
|
+
if not recordings:
|
|
205
|
+
print("Discovering recordings...")
|
|
206
|
+
pairs = discover_pairs(cmd.sorted_uri, cmd.raw_uri)
|
|
207
|
+
if not pairs:
|
|
208
|
+
print("No matching analyzer/raw recording pairs found.", file=sys.stderr)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
recordings = [az for az, _ in pairs]
|
|
211
|
+
|
|
212
|
+
recordings = filter_recordings(recordings, probe=cmd.probe, experiment=cmd.experiment)
|
|
213
|
+
|
|
214
|
+
if not recordings:
|
|
215
|
+
print("No recordings match the given filters.", file=sys.stderr)
|
|
216
|
+
sys.exit(1)
|
|
217
|
+
|
|
218
|
+
print(f"Selected {len(recordings)} recording(s):")
|
|
219
|
+
for name in recordings:
|
|
220
|
+
print(f" {name}")
|
|
221
|
+
|
|
222
|
+
return recordings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _convert_one(cmd: Convert, analyzer_uri: str, raw_uri: str, phy_dir: Path, rec_name: str) -> None:
|
|
226
|
+
"""Convert a single recording to phy."""
|
|
227
|
+
print(" Loading analyzer...")
|
|
228
|
+
analyzer = load_analyzer(analyzer_uri)
|
|
229
|
+
|
|
230
|
+
if cmd.lazy:
|
|
231
|
+
print(" Exporting to phy with lazy traces (no motion correction)...")
|
|
232
|
+
export_phy_lazy(analyzer, lazy_recording_dict(analyzer_uri, raw_uri), phy_dir)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
print(" Reconstructing preprocessed recording...")
|
|
236
|
+
recording = reconstruct_recording(analyzer_uri, raw_uri)
|
|
237
|
+
|
|
238
|
+
recording = _maybe_motion_correct(recording, cmd.sorted_uri, rec_name, cmd.motion_correct)
|
|
239
|
+
|
|
240
|
+
print(" Attaching recording to analyzer...")
|
|
241
|
+
attach_recording(analyzer, recording)
|
|
242
|
+
|
|
243
|
+
print(" Exporting to phy...")
|
|
244
|
+
export_phy(
|
|
245
|
+
analyzer,
|
|
246
|
+
recording,
|
|
247
|
+
phy_dir,
|
|
248
|
+
compress=cmd.compress,
|
|
249
|
+
check_compression=not cmd.skip_check,
|
|
250
|
+
n_jobs=cmd.n_jobs,
|
|
251
|
+
chunk_duration=cmd.chunk_duration,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _maybe_motion_correct(
|
|
256
|
+
recording: BaseRecording,
|
|
257
|
+
sorted_uri: str | None,
|
|
258
|
+
recording_name: str,
|
|
259
|
+
motion_correct: bool,
|
|
260
|
+
) -> BaseRecording:
|
|
261
|
+
"""Apply motion correction if requested and a motion estimate exists."""
|
|
262
|
+
if not motion_correct:
|
|
263
|
+
print(" Motion correction: disabled")
|
|
264
|
+
return recording
|
|
265
|
+
if sorted_uri is None:
|
|
266
|
+
print(" Motion correction: skipped (no sorted URI provided)")
|
|
267
|
+
return recording
|
|
268
|
+
|
|
269
|
+
storage_options = {"anon": True} if sorted_uri.startswith("s3://") else None
|
|
270
|
+
try:
|
|
271
|
+
motion, info = load_motion(sorted_uri, recording_name, recording, storage_options=storage_options)
|
|
272
|
+
except FileNotFoundError:
|
|
273
|
+
print(" Motion correction: the sort saved no KS4 estimate — writing uncorrected traces")
|
|
274
|
+
return recording
|
|
275
|
+
|
|
276
|
+
print(f" Applying KS4 motion correction from {info['folder']} ...")
|
|
277
|
+
from spikeinterface.preprocessing import astype
|
|
278
|
+
|
|
279
|
+
corrected = correct_recording(recording, motion)
|
|
280
|
+
# Interpolation runs in float32; integer traces go back to their dtype so .dat/.cbin keep their size.
|
|
281
|
+
if np.issubdtype(recording.get_dtype(), np.integer):
|
|
282
|
+
corrected = astype(corrected, recording.get_dtype(), round=True)
|
|
283
|
+
return corrected
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _run_list(cmd: List) -> None:
|
|
287
|
+
"""List recordings and optionally match to raw recordings."""
|
|
288
|
+
analyzers = list_recordings(cmd.sorted_uri)
|
|
289
|
+
|
|
290
|
+
if cmd.raw_uri is not None:
|
|
291
|
+
raw_recs = list_raw_recordings(cmd.raw_uri)
|
|
292
|
+
raw_names = {name for name, _ in raw_recs}
|
|
293
|
+
raw_sizes = {name: size for name, size in raw_recs}
|
|
294
|
+
|
|
295
|
+
print(f"{'Analyzer':<80} {'Size':>10} {'Raw Match':<60} {'Raw Size':>10}")
|
|
296
|
+
print("-" * 165)
|
|
297
|
+
for az_name, az_size in sorted(analyzers):
|
|
298
|
+
raw_candidate = match_analyzer_to_raw(az_name)
|
|
299
|
+
if raw_candidate in raw_names:
|
|
300
|
+
raw_s = raw_sizes[raw_candidate]
|
|
301
|
+
print(f"{az_name:<80} {az_size / 1e6:>9.1f}M {raw_candidate:<60} {raw_s / 1e6:>9.1f}M")
|
|
302
|
+
else:
|
|
303
|
+
print(f"{az_name:<80} {az_size / 1e6:>9.1f}M (no match)")
|
|
304
|
+
else:
|
|
305
|
+
print(f"{'Analyzer':<80} {'Size':>10}")
|
|
306
|
+
print("-" * 92)
|
|
307
|
+
for az_name, az_size in sorted(analyzers):
|
|
308
|
+
print(f"{az_name:<80} {az_size / 1e6:>9.1f}M")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _run_extract_recording(cmd: ExtractRecording) -> None:
|
|
312
|
+
"""Reconstruct the preprocessed recording and write to binary."""
|
|
313
|
+
import mtscomp
|
|
314
|
+
from spikeinterface.core.recording_tools import write_binary_recording
|
|
315
|
+
|
|
316
|
+
print("Reconstructing preprocessed recording...")
|
|
317
|
+
print(f" Analyzer: {cmd.analyzer_uri}")
|
|
318
|
+
print(f" Raw: {cmd.raw_uri}")
|
|
319
|
+
recording = reconstruct_recording(cmd.analyzer_uri, cmd.raw_uri)
|
|
320
|
+
|
|
321
|
+
# Derive recording name from analyzer URI for motion lookup
|
|
322
|
+
rec_name = cmd.analyzer_uri.rstrip("/").split("/")[-1]
|
|
323
|
+
recording = _maybe_motion_correct(recording, cmd.sorted_uri, rec_name, cmd.motion_correct)
|
|
324
|
+
|
|
325
|
+
n_ch = recording.get_num_channels()
|
|
326
|
+
n_frames = recording.get_num_frames()
|
|
327
|
+
dtype = cmd.dtype or str(recording.get_dtype())
|
|
328
|
+
sample_rate = recording.sampling_frequency
|
|
329
|
+
est_size_gb = n_frames * n_ch * recording.get_dtype().itemsize / 1e9
|
|
330
|
+
print(f" Channels: {n_ch}, Frames: {n_frames}, Duration: {recording.get_total_duration():.1f}s")
|
|
331
|
+
print(f" Estimated raw size: {est_size_gb:.2f} GB")
|
|
332
|
+
|
|
333
|
+
cmd.output.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
|
|
335
|
+
if cmd.compress:
|
|
336
|
+
dat_path = cmd.output.with_suffix(".dat")
|
|
337
|
+
print(f"Writing intermediate {dat_path}...")
|
|
338
|
+
write_binary_recording(
|
|
339
|
+
recording,
|
|
340
|
+
file_paths=dat_path,
|
|
341
|
+
dtype=dtype,
|
|
342
|
+
n_jobs=cmd.n_jobs,
|
|
343
|
+
chunk_duration=cmd.chunk_duration,
|
|
344
|
+
progress_bar=True,
|
|
345
|
+
pool_engine="thread",
|
|
346
|
+
)
|
|
347
|
+
cbin_path = cmd.output.with_suffix(".cbin")
|
|
348
|
+
ch_path = cmd.output.with_suffix(".ch")
|
|
349
|
+
print(f"Compressing to {cbin_path}...")
|
|
350
|
+
mtscomp.compress(
|
|
351
|
+
str(dat_path),
|
|
352
|
+
out=str(cbin_path),
|
|
353
|
+
outmeta=str(ch_path),
|
|
354
|
+
sample_rate=sample_rate,
|
|
355
|
+
n_channels=n_ch,
|
|
356
|
+
dtype=dtype,
|
|
357
|
+
)
|
|
358
|
+
dat_path.unlink()
|
|
359
|
+
print(f"Done: {cbin_path} ({cbin_path.stat().st_size / 1e9:.2f} GB)")
|
|
360
|
+
else:
|
|
361
|
+
print(f"Writing to {cmd.output}...")
|
|
362
|
+
write_binary_recording(
|
|
363
|
+
recording,
|
|
364
|
+
file_paths=cmd.output,
|
|
365
|
+
dtype=dtype,
|
|
366
|
+
n_jobs=cmd.n_jobs,
|
|
367
|
+
chunk_duration=cmd.chunk_duration,
|
|
368
|
+
progress_bar=True,
|
|
369
|
+
pool_engine="thread",
|
|
370
|
+
)
|
|
371
|
+
print(f"Done: {cmd.output} ({cmd.output.stat().st_size / 1e9:.2f} GB)")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _run_compress(cmd: Compress) -> None:
|
|
375
|
+
"""Compress recording.dat in the given phy directory."""
|
|
376
|
+
print(f"Compressing {cmd.phy_dir / 'recording.dat'}...")
|
|
377
|
+
compress_binary(cmd.phy_dir, check=not cmd.skip_check)
|
|
378
|
+
print(f"Done: {cmd.phy_dir / 'recording.cbin'}")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _cleanup_partial(phy_dir: Path) -> None:
|
|
382
|
+
"""Remove a partial phy output directory after interruption."""
|
|
383
|
+
dat_path = phy_dir / "recording.dat"
|
|
384
|
+
if dat_path.exists():
|
|
385
|
+
dat_path.unlink()
|
|
386
|
+
print(f" Removed partial {dat_path}", file=sys.stderr)
|
|
387
|
+
elif phy_dir.exists() and not any(phy_dir.iterdir()):
|
|
388
|
+
shutil.rmtree(phy_dir)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == "__main__":
|
|
392
|
+
main()
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Export a SortingAnalyzer to a phy directory, with traces written to disk or read lazily."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import mtscomp
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import spikeinterface as si
|
|
13
|
+
from spikeinterface.core.baserecording import BaseRecording
|
|
14
|
+
from spikeinterface.core.core_tools import SIJsonEncoder
|
|
15
|
+
from spikeinterface.core.recording_tools import write_binary_recording
|
|
16
|
+
from spikeinterface.core.sortinganalyzer import SortingAnalyzer
|
|
17
|
+
from spikeinterface.exporters import export_to_phy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def export_phy(
|
|
21
|
+
analyzer: SortingAnalyzer,
|
|
22
|
+
recording: BaseRecording,
|
|
23
|
+
output_dir: str | Path,
|
|
24
|
+
*,
|
|
25
|
+
compress: bool = True,
|
|
26
|
+
check_compression: bool = True,
|
|
27
|
+
n_jobs: int = 1,
|
|
28
|
+
chunk_duration: str = "1s",
|
|
29
|
+
) -> Path:
|
|
30
|
+
"""Export a SortingAnalyzer to a phy directory.
|
|
31
|
+
|
|
32
|
+
Writes spike data via SpikeInterface's ``export_to_phy``, then streams
|
|
33
|
+
the preprocessed recording to a flat binary and optionally compresses it
|
|
34
|
+
with mtscomp.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
analyzer : SortingAnalyzer
|
|
39
|
+
Analyzer with recording attached (see :func:`~.analyzer.attach_recording`).
|
|
40
|
+
recording : BaseRecording
|
|
41
|
+
The preprocessed recording to write as binary traces.
|
|
42
|
+
output_dir : str or Path
|
|
43
|
+
Directory to write phy files into.
|
|
44
|
+
compress : bool
|
|
45
|
+
If True (default), compress ``recording.dat`` to ``.cbin`` format.
|
|
46
|
+
check_compression : bool
|
|
47
|
+
If True (default), verify compression by decompressing and comparing.
|
|
48
|
+
n_jobs : int
|
|
49
|
+
Number of parallel jobs for binary writing.
|
|
50
|
+
chunk_duration : str
|
|
51
|
+
Chunk duration for binary writing (e.g. ``"1s"``).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
Path
|
|
56
|
+
The output directory.
|
|
57
|
+
"""
|
|
58
|
+
output_dir = Path(output_dir)
|
|
59
|
+
dat_path = output_dir / "recording.dat"
|
|
60
|
+
cbin_path = output_dir / "recording.cbin"
|
|
61
|
+
ch_path = output_dir / "recording.ch"
|
|
62
|
+
|
|
63
|
+
# 1. Write the binary first (most expensive step, skip if already done).
|
|
64
|
+
# This gives us local data for PC extraction in step 2.
|
|
65
|
+
if cbin_path.exists():
|
|
66
|
+
print(f" Skipping binary write — {cbin_path} already exists")
|
|
67
|
+
elif dat_path.exists():
|
|
68
|
+
print(f" Skipping binary write — {dat_path} already exists")
|
|
69
|
+
else:
|
|
70
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
write_binary_recording(
|
|
72
|
+
recording,
|
|
73
|
+
file_paths=dat_path,
|
|
74
|
+
dtype=recording.get_dtype(),
|
|
75
|
+
n_jobs=n_jobs,
|
|
76
|
+
chunk_duration=chunk_duration,
|
|
77
|
+
progress_bar=True,
|
|
78
|
+
pool_engine="thread",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# 2. Export spike data (templates, amplitudes, PCs, metrics, etc.).
|
|
82
|
+
# If the .dat exists, swap the analyzer's recording to a local
|
|
83
|
+
# BinaryRecordingExtractor so PC extraction reads from disk, not S3.
|
|
84
|
+
if dat_path.exists():
|
|
85
|
+
from spikeinterface.core import BinaryRecordingExtractor
|
|
86
|
+
|
|
87
|
+
local_rec = BinaryRecordingExtractor(
|
|
88
|
+
file_paths=dat_path,
|
|
89
|
+
sampling_frequency=recording.sampling_frequency,
|
|
90
|
+
num_channels=recording.get_num_channels(),
|
|
91
|
+
dtype=recording.get_dtype(),
|
|
92
|
+
)
|
|
93
|
+
local_rec.set_channel_locations(recording.get_channel_locations())
|
|
94
|
+
analyzer._recording = local_rec
|
|
95
|
+
|
|
96
|
+
# Stash binary files so export_to_phy can have a clean directory.
|
|
97
|
+
# Use try/finally to guarantee restoration — these files are expensive.
|
|
98
|
+
_binary_files = [cbin_path, ch_path, dat_path]
|
|
99
|
+
_preserved: list[tuple[Path, Path]] = []
|
|
100
|
+
for f in _binary_files:
|
|
101
|
+
if f.exists():
|
|
102
|
+
stash = output_dir.parent / f"._{output_dir.name}_{f.name}.stash"
|
|
103
|
+
if stash.exists():
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"Stash file {stash} already exists from a previous interrupted run. "
|
|
106
|
+
f"Restore it manually to {f} before re-running."
|
|
107
|
+
)
|
|
108
|
+
f.rename(stash)
|
|
109
|
+
_preserved.append((stash, f))
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
export_to_phy(
|
|
113
|
+
analyzer,
|
|
114
|
+
output_dir,
|
|
115
|
+
copy_binary=False,
|
|
116
|
+
compute_pc_features=True,
|
|
117
|
+
compute_amplitudes=True,
|
|
118
|
+
use_relative_path=True,
|
|
119
|
+
remove_if_exists=True,
|
|
120
|
+
n_jobs=n_jobs,
|
|
121
|
+
pool_engine="thread",
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
# Restore binary files even if export_to_phy fails
|
|
125
|
+
for stash, orig in _preserved:
|
|
126
|
+
if stash.exists():
|
|
127
|
+
orig.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
stash.rename(orig)
|
|
129
|
+
|
|
130
|
+
# Rewrite cluster_group.tsv using sorting labels
|
|
131
|
+
_write_cluster_labels(analyzer, output_dir)
|
|
132
|
+
|
|
133
|
+
# 3. Update params.py and optionally compress to .cbin
|
|
134
|
+
if cbin_path.exists():
|
|
135
|
+
_update_params_dat_path(output_dir, "recording.cbin")
|
|
136
|
+
elif compress and dat_path.exists():
|
|
137
|
+
_update_params_dat_path(output_dir, "recording.dat")
|
|
138
|
+
compress_binary(output_dir, check=check_compression)
|
|
139
|
+
elif dat_path.exists():
|
|
140
|
+
_update_params_dat_path(output_dir, "recording.dat")
|
|
141
|
+
|
|
142
|
+
return output_dir
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def export_phy_lazy(
|
|
146
|
+
analyzer: SortingAnalyzer,
|
|
147
|
+
recording_dict: dict[str, Any],
|
|
148
|
+
output_dir: str | Path,
|
|
149
|
+
) -> Path:
|
|
150
|
+
"""Export a SortingAnalyzer to a phy directory that reads its traces lazily.
|
|
151
|
+
|
|
152
|
+
No traces are written. ``recording.json`` holds *recording_dict*, which phylib opens with
|
|
153
|
+
SpikeInterface when ``dat_path`` names a ``.json`` file (phylib with ``SpikeInterfaceEphysReader``).
|
|
154
|
+
Waveforms come from the analyzer's stored subset (``_phy_spikes_subset.*``), so phy reads traces
|
|
155
|
+
only in the trace view, and needs no PC features.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
analyzer : SortingAnalyzer
|
|
160
|
+
Analyzer with the ``random_spikes`` and ``waveforms`` extensions; no recording needed.
|
|
161
|
+
recording_dict : dict
|
|
162
|
+
Provenance dict of the analyzer's preprocessed recording, from
|
|
163
|
+
:func:`~.recording.lazy_recording_dict`.
|
|
164
|
+
output_dir : str or Path
|
|
165
|
+
Directory to write phy files into.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
Path
|
|
170
|
+
The output directory.
|
|
171
|
+
|
|
172
|
+
Raises
|
|
173
|
+
------
|
|
174
|
+
ValueError
|
|
175
|
+
If the analyzer has empty units, lacks the waveform extensions, or the recording does not
|
|
176
|
+
match the analyzer's channels and samples.
|
|
177
|
+
"""
|
|
178
|
+
output_dir = Path(output_dir)
|
|
179
|
+
counts = analyzer.sorting.count_num_spikes_per_unit(outputs="array")
|
|
180
|
+
# export_to_phy drops empty units, which would renumber clusters away from the spike vector's unit_index
|
|
181
|
+
if counts.min() == 0:
|
|
182
|
+
raise ValueError(f"{int((counts == 0).sum())} empty units; phy cluster ids would not match the analyzer")
|
|
183
|
+
for ext in ("random_spikes", "waveforms"):
|
|
184
|
+
if analyzer.get_extension(ext) is None:
|
|
185
|
+
analyzer.load_extension(ext)
|
|
186
|
+
if analyzer.get_extension(ext) is None:
|
|
187
|
+
raise ValueError(f"Analyzer has no '{ext}' extension (required for lazy traces).")
|
|
188
|
+
|
|
189
|
+
export_to_phy(
|
|
190
|
+
analyzer,
|
|
191
|
+
output_dir,
|
|
192
|
+
copy_binary=False,
|
|
193
|
+
compute_pc_features=False,
|
|
194
|
+
use_relative_path=True,
|
|
195
|
+
remove_if_exists=True,
|
|
196
|
+
verbose=False,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
(output_dir / "recording.json").write_text(json.dumps(recording_dict, cls=SIJsonEncoder, indent=1))
|
|
200
|
+
recording = si.load(output_dir / "recording.json")
|
|
201
|
+
if list(recording.channel_ids) != list(analyzer.channel_ids) or (
|
|
202
|
+
recording.get_num_samples() != analyzer.get_num_samples()
|
|
203
|
+
):
|
|
204
|
+
raise ValueError("recording.json does not match the analyzer's channels or sample count")
|
|
205
|
+
|
|
206
|
+
_update_params_dat_path(output_dir, "recording.json")
|
|
207
|
+
_update_params_line(output_dir, "dtype", f"'{np.dtype(recording.get_dtype()).name}'")
|
|
208
|
+
_write_waveform_subset(analyzer, output_dir)
|
|
209
|
+
_write_cluster_labels(analyzer, output_dir)
|
|
210
|
+
return output_dir
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def compress_binary(phy_dir: str | Path, *, check: bool = True) -> None:
|
|
214
|
+
"""Compress ``recording.dat`` to mtscomp ``.cbin`` format.
|
|
215
|
+
|
|
216
|
+
Reads recording metadata from ``params.py``, compresses the ``.dat``
|
|
217
|
+
file, updates ``params.py`` to point at the ``.cbin``, and deletes
|
|
218
|
+
the original ``.dat``.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
phy_dir : str or Path
|
|
223
|
+
Path to a phy output directory containing ``recording.dat`` and
|
|
224
|
+
``params.py``.
|
|
225
|
+
check : bool
|
|
226
|
+
If True (default), verify the compressed file by decompressing and
|
|
227
|
+
comparing against the original. Doubles I/O time but guarantees
|
|
228
|
+
lossless compression.
|
|
229
|
+
|
|
230
|
+
Raises
|
|
231
|
+
------
|
|
232
|
+
FileNotFoundError
|
|
233
|
+
If ``recording.dat`` or ``params.py`` is missing.
|
|
234
|
+
"""
|
|
235
|
+
phy_dir = Path(phy_dir)
|
|
236
|
+
dat_path = phy_dir / "recording.dat"
|
|
237
|
+
cbin_path = phy_dir / "recording.cbin"
|
|
238
|
+
ch_path = phy_dir / "recording.ch"
|
|
239
|
+
|
|
240
|
+
if not dat_path.exists():
|
|
241
|
+
raise FileNotFoundError(f"No recording.dat found in {phy_dir}")
|
|
242
|
+
|
|
243
|
+
params = _read_params(phy_dir)
|
|
244
|
+
|
|
245
|
+
mtscomp.compress(
|
|
246
|
+
str(dat_path),
|
|
247
|
+
out=str(cbin_path),
|
|
248
|
+
outmeta=str(ch_path),
|
|
249
|
+
sample_rate=params["sample_rate"],
|
|
250
|
+
n_channels=params["n_channels_dat"],
|
|
251
|
+
dtype=params["dtype"],
|
|
252
|
+
check_after_compress=check,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
_update_params_dat_path(phy_dir, "recording.cbin")
|
|
256
|
+
dat_path.unlink()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# --- Internal helpers ---
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _read_params(phy_dir: Path) -> dict[str, Any]:
|
|
263
|
+
"""Parse ``params.py`` in a phy directory."""
|
|
264
|
+
params_path = phy_dir / "params.py"
|
|
265
|
+
if not params_path.exists():
|
|
266
|
+
raise FileNotFoundError(f"No params.py found in {phy_dir}")
|
|
267
|
+
namespace: dict[str, Any] = {"r": str, "np": np}
|
|
268
|
+
exec(params_path.read_text(), namespace) # noqa: S102 - params.py is Python by phy's format
|
|
269
|
+
return namespace
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _write_cluster_labels(analyzer: SortingAnalyzer, output_dir: Path) -> None:
|
|
273
|
+
"""Rewrite ``cluster_group.tsv`` using sorting labels and write individual label columns.
|
|
274
|
+
|
|
275
|
+
Assigns phy cluster groups based on a combined heuristic:
|
|
276
|
+
|
|
277
|
+
- **good**: KSLabel ``good`` AND decoder_label ``sua``
|
|
278
|
+
- **mua**: everything else that isn't noise
|
|
279
|
+
- **noise**: decoder_label ``noise`` OR default_qc ``False``
|
|
280
|
+
|
|
281
|
+
Also writes each available label (KSLabel, decoder_label, default_qc,
|
|
282
|
+
decoder_probability) as a separate ``cluster_*.tsv`` for visibility
|
|
283
|
+
in phy's cluster view.
|
|
284
|
+
"""
|
|
285
|
+
sorting = analyzer.sorting
|
|
286
|
+
unit_ids = sorting.unit_ids
|
|
287
|
+
n_units = len(unit_ids)
|
|
288
|
+
cluster_ids = list(range(n_units))
|
|
289
|
+
|
|
290
|
+
# Determine combined cluster group
|
|
291
|
+
ks_label = sorting.get_property("KSLabel")
|
|
292
|
+
decoder_label = sorting.get_property("decoder_label")
|
|
293
|
+
default_qc = sorting.get_property("default_qc")
|
|
294
|
+
|
|
295
|
+
groups: list[str] = ["unsorted"] * n_units
|
|
296
|
+
for i in range(n_units):
|
|
297
|
+
is_noise = False
|
|
298
|
+
if decoder_label is not None and decoder_label[i] == "noise":
|
|
299
|
+
is_noise = True
|
|
300
|
+
if default_qc is not None and not default_qc[i]:
|
|
301
|
+
is_noise = True
|
|
302
|
+
|
|
303
|
+
if is_noise:
|
|
304
|
+
groups[i] = "noise"
|
|
305
|
+
elif ks_label is not None and ks_label[i] == "good" and decoder_label is not None and decoder_label[i] == "sua":
|
|
306
|
+
groups[i] = "good"
|
|
307
|
+
else:
|
|
308
|
+
groups[i] = "mua"
|
|
309
|
+
|
|
310
|
+
# Overwrite cluster_group.tsv
|
|
311
|
+
cluster_group = pd.DataFrame({"cluster_id": cluster_ids, "group": groups})
|
|
312
|
+
cluster_group.to_csv(output_dir / "cluster_group.tsv", sep="\t", index=False)
|
|
313
|
+
|
|
314
|
+
# Write individual label columns as cluster_*.tsv
|
|
315
|
+
_label_properties = ["KSLabel", "decoder_label", "default_qc", "decoder_probability"]
|
|
316
|
+
for prop_name in _label_properties:
|
|
317
|
+
values = sorting.get_property(prop_name)
|
|
318
|
+
if values is not None:
|
|
319
|
+
df = pd.DataFrame({"cluster_id": cluster_ids, prop_name: values})
|
|
320
|
+
df.to_csv(output_dir / f"cluster_{prop_name}.tsv", sep="\t", index=False)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _write_waveform_subset(analyzer: SortingAnalyzer, output_dir: Path) -> None:
|
|
324
|
+
"""Write phylib's ``_phy_spikes_subset.*`` files from the analyzer's stored waveforms.
|
|
325
|
+
|
|
326
|
+
``random_spikes`` indexes the spike vector, whose order is also that of ``spike_times.npy``.
|
|
327
|
+
"""
|
|
328
|
+
spike_ids = analyzer.get_extension("random_spikes").get_data()
|
|
329
|
+
waveforms = analyzer.get_extension("waveforms").get_data()
|
|
330
|
+
unit_index = analyzer.sorting.to_spike_vector()["unit_index"][spike_ids]
|
|
331
|
+
mask = (
|
|
332
|
+
analyzer.sparsity.mask
|
|
333
|
+
if analyzer.sparsity is not None
|
|
334
|
+
else np.ones((len(analyzer.unit_ids), analyzer.get_num_channels()), dtype=bool)
|
|
335
|
+
)
|
|
336
|
+
unit_channels = -np.ones((len(analyzer.unit_ids), waveforms.shape[2]), dtype=np.int64)
|
|
337
|
+
for u in range(len(analyzer.unit_ids)):
|
|
338
|
+
channels = np.flatnonzero(mask[u])
|
|
339
|
+
unit_channels[u, : len(channels)] = channels
|
|
340
|
+
np.save(output_dir / "_phy_spikes_subset.spikes.npy", spike_ids.astype(np.int64))
|
|
341
|
+
np.save(output_dir / "_phy_spikes_subset.channels.npy", unit_channels[unit_index])
|
|
342
|
+
np.save(output_dir / "_phy_spikes_subset.waveforms.npy", waveforms)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _update_params_line(phy_dir: Path, key: str, value: str) -> None:
|
|
346
|
+
"""Rewrite the ``key = ...`` line in ``params.py``."""
|
|
347
|
+
params_path = phy_dir / "params.py"
|
|
348
|
+
lines = params_path.read_text().splitlines(keepends=True)
|
|
349
|
+
params_path.write_text("".join(f"{key} = {value}\n" if line.startswith(f"{key} ") else line for line in lines))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _update_params_dat_path(phy_dir: Path, dat_filename: str) -> None:
|
|
353
|
+
"""Rewrite the ``dat_path`` line in ``params.py``."""
|
|
354
|
+
params_path = phy_dir / "params.py"
|
|
355
|
+
lines = params_path.read_text().splitlines(keepends=True)
|
|
356
|
+
new_lines: list[str] = []
|
|
357
|
+
for line in lines:
|
|
358
|
+
if line.startswith("dat_path"):
|
|
359
|
+
new_lines.append(f"dat_path = r'{dat_filename}'\n")
|
|
360
|
+
else:
|
|
361
|
+
new_lines.append(line)
|
|
362
|
+
params_path.write_text("".join(new_lines))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Reconstruct a SpikeInterface preprocessing chain from a SortingAnalyzer.
|
|
2
|
+
|
|
3
|
+
The SortingAnalyzer stores the full recording provenance (preprocessing chain)
|
|
4
|
+
as a nested dictionary in its Zarr store. This module extracts that dict,
|
|
5
|
+
substitutes the raw recording path at the leaf, and reconstructs the full
|
|
6
|
+
recording object including ChannelSlice steps that
|
|
7
|
+
``get_preprocessing_dict_from_analyzer`` intentionally skips.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from spikeinterface.core.base import _load_extractor_from_dict
|
|
17
|
+
from spikeinterface.core.baserecording import BaseRecording
|
|
18
|
+
from spikeinterface.core.zarrextractors import super_zarr_open
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_recording_dict(
|
|
22
|
+
analyzer_path: str,
|
|
23
|
+
storage_options: dict[str, Any] | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Extract the recording provenance dict from a SortingAnalyzer Zarr.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
analyzer_path : str
|
|
30
|
+
Path or S3 URI to the SortingAnalyzer ``.zarr`` directory.
|
|
31
|
+
storage_options : dict or None
|
|
32
|
+
fsspec storage options (e.g. ``{"anon": True}``).
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
dict
|
|
37
|
+
Nested dictionary representing the full preprocessing chain.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If the analyzer Zarr has no ``recording`` dataset.
|
|
43
|
+
"""
|
|
44
|
+
storage_options = storage_options or {}
|
|
45
|
+
zarr_root = super_zarr_open(analyzer_path, mode="r", storage_options=storage_options)
|
|
46
|
+
rec_field = zarr_root.get("recording")
|
|
47
|
+
if rec_field is None:
|
|
48
|
+
raise ValueError(f"No recording dict found in analyzer: {analyzer_path}")
|
|
49
|
+
result: dict[str, Any] = rec_field[0]
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fix_recording_dict(rec_dict: dict[str, Any], raw_path: str) -> dict[str, Any]:
|
|
54
|
+
"""Substitute the raw recording path in a recording provenance dict.
|
|
55
|
+
|
|
56
|
+
Deep-copies the dict and replaces the ``folder_path`` of the innermost
|
|
57
|
+
(leaf) extractor — typically a ``ZarrRecordingExtractor`` — with
|
|
58
|
+
*raw_path*. Also clears ``relative_paths`` flags throughout the chain.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
rec_dict : dict
|
|
63
|
+
Recording provenance dict from :func:`get_recording_dict`.
|
|
64
|
+
raw_path : str
|
|
65
|
+
S3 URI or local path to the raw recording Zarr.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
dict
|
|
70
|
+
A new dict with the leaf path substituted.
|
|
71
|
+
"""
|
|
72
|
+
rec_dict = copy.deepcopy(rec_dict)
|
|
73
|
+
_fix_leaf_path(rec_dict, raw_path)
|
|
74
|
+
_clear_relative_paths(rec_dict)
|
|
75
|
+
return rec_dict
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reconstruct_recording(
|
|
79
|
+
analyzer_path: str,
|
|
80
|
+
raw_path: str,
|
|
81
|
+
storage_options: dict[str, Any] | None = None,
|
|
82
|
+
) -> BaseRecording:
|
|
83
|
+
"""Reconstruct the full preprocessed recording from a SortingAnalyzer.
|
|
84
|
+
|
|
85
|
+
Extracts the recording chain from the analyzer Zarr, substitutes the raw
|
|
86
|
+
recording path, and instantiates the full SpikeInterface preprocessing
|
|
87
|
+
pipeline (including ChannelSlice steps).
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
analyzer_path : str
|
|
92
|
+
Path or S3 URI to the SortingAnalyzer ``.zarr`` directory.
|
|
93
|
+
raw_path : str
|
|
94
|
+
S3 URI or local path to the raw recording Zarr.
|
|
95
|
+
storage_options : dict or None
|
|
96
|
+
fsspec storage options for accessing the analyzer Zarr.
|
|
97
|
+
|
|
98
|
+
Returns
|
|
99
|
+
-------
|
|
100
|
+
BaseRecording
|
|
101
|
+
The reconstructed preprocessed recording.
|
|
102
|
+
"""
|
|
103
|
+
rec_dict = get_recording_dict(analyzer_path, storage_options)
|
|
104
|
+
fixed = fix_recording_dict(rec_dict, raw_path)
|
|
105
|
+
return _load_extractor_from_dict(fixed)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def lazy_recording_dict(
|
|
109
|
+
analyzer_path: str,
|
|
110
|
+
raw_path: str,
|
|
111
|
+
storage_options: dict[str, Any] | None = None,
|
|
112
|
+
raw_storage_options: dict[str, Any] | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Build a recording provenance dict that reads the raw data from *raw_path* when loaded.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
analyzer_path : str
|
|
119
|
+
Path or S3 URI to the SortingAnalyzer ``.zarr`` directory.
|
|
120
|
+
raw_path : str
|
|
121
|
+
S3 URI or local path to the raw recording Zarr.
|
|
122
|
+
storage_options : dict or None
|
|
123
|
+
fsspec storage options for accessing the analyzer Zarr.
|
|
124
|
+
raw_storage_options : dict or None
|
|
125
|
+
fsspec storage options stored with the raw leaf. Defaults to ``{"anon": True}`` for an S3
|
|
126
|
+
*raw_path*, since a stale AWS profile otherwise fails before unsigned access is tried.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
dict
|
|
131
|
+
A JSON-serializable dict that ``spikeinterface.load`` turns into the preprocessed recording.
|
|
132
|
+
"""
|
|
133
|
+
rec_dict = fix_recording_dict(get_recording_dict(analyzer_path, storage_options), raw_path)
|
|
134
|
+
if raw_storage_options is None and raw_path.startswith("s3://"):
|
|
135
|
+
raw_storage_options = {"anon": True}
|
|
136
|
+
if raw_storage_options is not None:
|
|
137
|
+
_leaf(rec_dict)["kwargs"]["storage_options"] = raw_storage_options
|
|
138
|
+
return rec_dict
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- Name matching ---
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def match_analyzer_to_raw(analyzer_name: str) -> str:
|
|
145
|
+
"""Derive the raw recording Zarr name from an analyzer Zarr name.
|
|
146
|
+
|
|
147
|
+
Analyzer names have a ``_recordingN`` suffix that raw names lack:
|
|
148
|
+
|
|
149
|
+
- ``experiment1_...50213-1_recording1.zarr`` → ``experiment1_...50213-1.zarr``
|
|
150
|
+
- ``foo.zarr`` → ``foo.zarr`` (no suffix, returned as-is)
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
analyzer_name : str
|
|
155
|
+
Filename of the postprocessed SortingAnalyzer Zarr.
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
str
|
|
160
|
+
Corresponding raw recording Zarr filename.
|
|
161
|
+
"""
|
|
162
|
+
return re.sub(r"_recording\d+\.zarr$", ".zarr", analyzer_name)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- Internal helpers ---
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fix_leaf_path(d: dict[str, Any], new_path: str) -> None:
|
|
169
|
+
"""Recursively find the leaf extractor and replace its ``folder_path``."""
|
|
170
|
+
for key in ("recording", "parent_recording"):
|
|
171
|
+
child = d.get("kwargs", {}).get(key)
|
|
172
|
+
if isinstance(child, dict) and "class" in child:
|
|
173
|
+
_fix_leaf_path(child, new_path)
|
|
174
|
+
return
|
|
175
|
+
# Leaf node — replace the path
|
|
176
|
+
d["kwargs"]["folder_path"] = new_path
|
|
177
|
+
d["relative_paths"] = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _leaf(d: dict[str, Any]) -> dict[str, Any]:
|
|
181
|
+
"""Return the innermost extractor dict of a recording chain."""
|
|
182
|
+
for key in ("recording", "parent_recording"):
|
|
183
|
+
child = d.get("kwargs", {}).get(key)
|
|
184
|
+
if isinstance(child, dict) and "class" in child:
|
|
185
|
+
return _leaf(child)
|
|
186
|
+
return d
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _clear_relative_paths(d: dict[str, Any]) -> None:
|
|
190
|
+
"""Set ``relative_paths`` to False throughout the chain."""
|
|
191
|
+
if "relative_paths" in d:
|
|
192
|
+
d["relative_paths"] = False
|
|
193
|
+
for key in ("recording", "parent_recording"):
|
|
194
|
+
child = d.get("kwargs", {}).get(key)
|
|
195
|
+
if isinstance(child, dict) and "class" in child:
|
|
196
|
+
_clear_relative_paths(child)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aind-ecephys-s3-to-phy
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Tools to get phy-ready data from AIND ecephys data on S3
|
|
5
|
+
Project-URL: Repository, https://github.com/AllenNeuralDynamics/aind-ecephys-s3-to-phy/
|
|
6
|
+
Project-URL: Issues, https://github.com/AllenNeuralDynamics/aind-ecephys-s3-to-phy/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/AllenNeuralDynamics/aind-ecephys-s3-to-phy/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Allen Institute for Neural Dynamics
|
|
9
|
+
Author-email: Galen Lynch <galen@galenlynch.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: aind-ephys-assets[si]>=0.5
|
|
15
|
+
Requires-Dist: mtscomp
|
|
16
|
+
Requires-Dist: s3fs
|
|
17
|
+
Requires-Dist: spikeinterface[full,streaming-extractors]
|
|
18
|
+
Requires-Dist: tyro
|
|
19
|
+
Requires-Dist: wavpack-numcodecs
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# aind-ecephys-s3-to-phy
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
[](https://pypi.org/project/aind-ecephys-s3-to-phy/)
|
|
26
|
+
[](https://github.com/semantic-release/semantic-release)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
[](https://github.com/astral-sh/ruff)
|
|
29
|
+
[](https://github.com/astral-sh/uv)
|
|
30
|
+
[](https://github.com/copier-org/copier)
|
|
31
|
+
|
|
32
|
+
Convert AIND ecephys sorted recordings from S3 into [phy](https://github.com/cortex-lab/phy) directories for manual curation, with raw trace access via compressed binary ([mtscomp](https://github.com/int-brain-lab/mtscomp)).
|
|
33
|
+
|
|
34
|
+
Two S3 assets are needed per session: a **sorted asset** (postprocessed SortingAnalyzer Zarrs) and its corresponding **raw asset** (compressed Zarr recordings). The tool reconstructs the full SpikeInterface preprocessing chain — including bad-channel removal — from the analyzer, streams traces from S3, and compresses them to `.cbin` for phy's trace viewer.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install aind-ecephys-s3-to-phy
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
SORTED=s3://aind-open-data/ecephys_786867_2025-09-25_12-43-56_sorted_2025-12-24_08-46-14
|
|
46
|
+
RAW=s3://aind-open-data/ecephys_786867_2025-09-25_12-43-56
|
|
47
|
+
|
|
48
|
+
# List available recordings (and matched raw files)
|
|
49
|
+
s3-to-phy list --sorted-uri $SORTED --raw-uri $RAW
|
|
50
|
+
|
|
51
|
+
# Convert all recordings to phy (streams from S3, compresses to .cbin)
|
|
52
|
+
s3-to-phy convert --sorted-uri $SORTED --raw-uri $RAW
|
|
53
|
+
|
|
54
|
+
# Convert only recordings for a specific probe
|
|
55
|
+
s3-to-phy convert --sorted-uri $SORTED --raw-uri $RAW --probe 46101
|
|
56
|
+
|
|
57
|
+
# Convert only a specific experiment
|
|
58
|
+
s3-to-phy convert --sorted-uri $SORTED --raw-uri $RAW --experiment experiment1
|
|
59
|
+
|
|
60
|
+
# Write no traces: phy reads them from the raw asset on S3 when you view them
|
|
61
|
+
s3-to-phy convert --sorted-uri $SORTED --raw-uri $RAW --probe 46101 --lazy
|
|
62
|
+
|
|
63
|
+
# Extract preprocessed traces as flat binary (for non-phy pipelines, e.g. mipmap)
|
|
64
|
+
s3-to-phy extract-recording \
|
|
65
|
+
--analyzer-uri $SORTED/postprocessed/experiment1_...46101_recording1.zarr \
|
|
66
|
+
--raw-uri $RAW/ecephys/ecephys_compressed/experiment1_...46101.zarr \
|
|
67
|
+
--output preprocessed.dat
|
|
68
|
+
|
|
69
|
+
# Compress an existing recording.dat to .cbin
|
|
70
|
+
s3-to-phy compress --phy-dir ./phy_output
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Motion correction
|
|
74
|
+
|
|
75
|
+
`convert` and `extract-recording` correct the traces with the drift estimate Kilosort 4 applied, read through
|
|
76
|
+
`aind-ephys-assets` from the sort's `spikesorted/motion/<recording>/`. Sorts made before sorting capsule
|
|
77
|
+
`cdef667` (2026-04-16), the example asset above among them, did not save it; those export uncorrected, and
|
|
78
|
+
preprocessing's never-applied `dredge_fast` estimate is not used as a substitute. Integer traces stay integer.
|
|
79
|
+
Pass `--no-motion-correct` to skip it.
|
|
80
|
+
|
|
81
|
+
### Lazy traces
|
|
82
|
+
|
|
83
|
+
`--lazy` exports in about a minute per probe instead of hours, with no trace file:
|
|
84
|
+
|
|
85
|
+
- `recording.json` holds the analyzer's preprocessing chain over the raw Zarr on S3, and `params.py`'s
|
|
86
|
+
`dat_path` names it. phylib opens it through SpikeInterface: this needs a phylib with
|
|
87
|
+
`SpikeInterfaceEphysReader` (cortex-lab/phylib#68).
|
|
88
|
+
- Waveforms come from the analyzer's stored subset, written as `_phy_spikes_subset.*` (most of the folder's
|
|
89
|
+
size, a few GB per probe). Phy reads traces only in the trace view, or for a waveform outside the subset
|
|
90
|
+
(about 0.5 s per spike from S3). No PC features are computed.
|
|
91
|
+
- Traces are read on demand: a jump to a new window costs about a second, and scrolling is cached.
|
|
92
|
+
- Traces are not motion-corrected, matching the analyzer's waveforms. SpikeInterface's motion
|
|
93
|
+
interpolation does not survive a round trip through JSON.
|
|
94
|
+
|
|
95
|
+
### Python API
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from aind_ecephys_s3_to_phy.analyzer import load_analyzer, attach_recording, discover_pairs
|
|
99
|
+
from aind_ecephys_s3_to_phy.recording import reconstruct_recording
|
|
100
|
+
from aind_ecephys_s3_to_phy.export import export_phy
|
|
101
|
+
|
|
102
|
+
# Discover and convert
|
|
103
|
+
pairs = discover_pairs(sorted_uri, raw_uri)
|
|
104
|
+
analyzer_name, raw_name = pairs[0]
|
|
105
|
+
|
|
106
|
+
analyzer_zarr = f"{sorted_uri}/postprocessed/{analyzer_name}"
|
|
107
|
+
raw_zarr = f"{raw_uri}/ecephys/ecephys_compressed/{raw_name}"
|
|
108
|
+
|
|
109
|
+
analyzer = load_analyzer(analyzer_zarr)
|
|
110
|
+
recording = reconstruct_recording(analyzer_zarr, raw_zarr)
|
|
111
|
+
attach_recording(analyzer, recording)
|
|
112
|
+
export_phy(analyzer, recording, "./phy_output", compress=True)
|
|
113
|
+
|
|
114
|
+
# Or just get the preprocessed recording for other pipelines
|
|
115
|
+
recording = reconstruct_recording(analyzer_zarr, raw_zarr)
|
|
116
|
+
# → SpikeInterface BaseRecording (filtered, bad channels removed, 373 ch)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### S3 credentials
|
|
120
|
+
|
|
121
|
+
Public buckets (like `aind-open-data`) work without credentials. For private buckets, configure AWS credentials via the standard methods (`~/.aws/credentials`, environment variables, IAM roles). SpikeInterface uses `s3fs`/`fsspec` which follows the standard AWS credential chain.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv sync # Set up environment
|
|
127
|
+
./scripts/run_linters_and_checks.sh -c # Full lint + test suite
|
|
128
|
+
uv run --frozen pytest -m integration # Integration tests (requires S3)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aind_ecephys_s3_to_phy/__init__.py,sha256=fhg7W6K43gwRJAjDtdvoUIhxTGRpEpGp-ssD0lJyA5M,244
|
|
2
|
+
aind_ecephys_s3_to_phy/analyzer.py,sha256=TQ7ev7BkbZcjOS44xB5SdkVDv7cfre0MMC0mPbEaGYE,7257
|
|
3
|
+
aind_ecephys_s3_to_phy/cli.py,sha256=-OuNjFsaTc_92OS7v9t3Wz7-9L2qYUwZY8BYPMAofDo,12999
|
|
4
|
+
aind_ecephys_s3_to_phy/export.py,sha256=kUXmSq6A1vi143kJ9l78DnYwv32GDRfgBgubbErTHMY,13701
|
|
5
|
+
aind_ecephys_s3_to_phy/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
6
|
+
aind_ecephys_s3_to_phy/recording.py,sha256=3UsXBrN6xxQra5mYn8DdWQnF_L3XVL4J44R6UYHifP8,6576
|
|
7
|
+
aind_ecephys_s3_to_phy-0.4.0.dist-info/METADATA,sha256=BoR6UJmo2MOzkjDuxKSm0kV-uAc4QTORg4sbTVlb6iY,6581
|
|
8
|
+
aind_ecephys_s3_to_phy-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
aind_ecephys_s3_to_phy-0.4.0.dist-info/entry_points.txt,sha256=7rptLSo15ORZCbilUz5A3-U0nHzDWSta8ehCIkD2qVY,62
|
|
10
|
+
aind_ecephys_s3_to_phy-0.4.0.dist-info/licenses/LICENSE,sha256=zMZxYbR1i_XxgyJP_rPSWKcxY7QtyxUOzbIpowHNjCs,1093
|
|
11
|
+
aind_ecephys_s3_to_phy-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Allen Institute for Neural Dynamics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|