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.
@@ -0,0 +1,8 @@
1
+ """Tools to get phy-ready data from AIND ecephys data on S3."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aind-ecephys-s3-to-phy")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0.dev0"
@@ -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
+ ![CI](https://github.com/AllenNeuralDynamics/aind-ecephys-s3-to-phy/actions/workflows/ci-call.yml/badge.svg)
25
+ [![PyPI - Version](https://img.shields.io/pypi/v/aind-ecephys-s3-to-phy)](https://pypi.org/project/aind-ecephys-s3-to-phy/)
26
+ [![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)
27
+ [![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE)
28
+ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
29
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
30
+ [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ s3-to-phy = aind_ecephys_s3_to_phy.cli:main
@@ -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
+