unitrefine 0.2.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.
Files changed (40) hide show
  1. unitrefine/__init__.py +75 -0
  2. unitrefine/apply_models.py +111 -0
  3. unitrefine/cli.py +251 -0
  4. unitrefine/evaluation.py +457 -0
  5. unitrefine/figures.py +1458 -0
  6. unitrefine/import_labels.py +176 -0
  7. unitrefine/launch_sigui.py +138 -0
  8. unitrefine/launch_sigui_validate.py +243 -0
  9. unitrefine/launcher.py +239 -0
  10. unitrefine/main.py +2395 -0
  11. unitrefine/manage_models.py +193 -0
  12. unitrefine/metrics.py +146 -0
  13. unitrefine/model_selection.py +159 -0
  14. unitrefine/models_hub.py +118 -0
  15. unitrefine/phy_curation.py +306 -0
  16. unitrefine/phy_import.py +428 -0
  17. unitrefine/predictions.py +507 -0
  18. unitrefine/resources/curation_gui.jpg +0 -0
  19. unitrefine/resources/fonts/Inter-Bold.ttf +0 -0
  20. unitrefine/resources/fonts/Inter-LICENSE.txt +92 -0
  21. unitrefine/resources/fonts/Inter-Regular.ttf +0 -0
  22. unitrefine/resources/logo.png +0 -0
  23. unitrefine/resources/model_training_example.jpg +0 -0
  24. unitrefine/resources/predict-gui.JPG +0 -0
  25. unitrefine/resources/re-label.JPG +0 -0
  26. unitrefine/resources/si-gui.JPG +0 -0
  27. unitrefine/resources/train_and_load_gui.JPG +0 -0
  28. unitrefine/resources/unit_types_example.jpg +0 -0
  29. unitrefine/resources/unitrefine_gui.JPG +0 -0
  30. unitrefine/retraining_history.py +220 -0
  31. unitrefine/sigui_progress.py +157 -0
  32. unitrefine/theme.py +254 -0
  33. unitrefine/train.py +622 -0
  34. unitrefine/widgets.py +961 -0
  35. unitrefine-0.2.0.dist-info/METADATA +153 -0
  36. unitrefine-0.2.0.dist-info/RECORD +40 -0
  37. unitrefine-0.2.0.dist-info/WHEEL +5 -0
  38. unitrefine-0.2.0.dist-info/entry_points.txt +4 -0
  39. unitrefine-0.2.0.dist-info/licenses/LICENSE +21 -0
  40. unitrefine-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,428 @@
1
+ """
2
+ Using a Phy folder in UnitRefine: makes a SortingAnalyzer from it, and saves Phy's labels. There are two kinds:
3
+
4
+ - **Kilosort output**, curated in Phy. There is no analyzer yet, so one is made from Phy's clusters and the
5
+ recording Kilosort sorted (found from params.py), with all the metrics the models use.
6
+ - **A Phy folder exported from an analyzer** with SpikeInterface's export_to_phy (it has cluster_si_unit_ids.tsv).
7
+ Phy's merges and splits are applied to that analyzer (see phy_curation.py). If Phy only changed labels, the
8
+ analyzer is used as it is.
9
+
10
+ Either way, Phy's labels (good, mua, noise; unsorted is skipped) are saved as a csv with unit_id and quality
11
+ columns, which UnitRefine imports like any other labels.
12
+
13
+ Runs without the GUI too, e.g. on a computing cluster:
14
+
15
+ python -m unitrefine.phy_import <phy folder> [--analyzer <analyzer exported to Phy>] [--output <folder>]
16
+ """
17
+
18
+ import argparse
19
+ import ast
20
+ import json
21
+ import sys
22
+ import time
23
+ import traceback
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+
30
+ # lines printed for the GUI, which runs this module in its own process
31
+ PHY_STEP_MARKER = "UNITREFINE_PHY_STEP:"
32
+ PHY_RESULT_MARKER = "UNITREFINE_PHY_RESULT:"
33
+
34
+ # written next to the analyzer, so it is known where it came from
35
+ MADE_FROM_PHY_FILE = "made_from_phy.json"
36
+
37
+ KILOSORT_STEPS = [
38
+ ("start", "Starting Python and SpikeInterface"),
39
+ ("reading", "Reading the Phy folder and the recording"),
40
+ ("waveforms", "Extracting waveforms (the longest step)"),
41
+ ("extensions", "Computing templates, amplitudes, locations and correlograms"),
42
+ ("pca", "Computing principal components"),
43
+ ("metrics", "Computing quality and template metrics"),
44
+ ("labels", "Saving Phy's labels"),
45
+ ]
46
+ EXPORTED_STEPS = [
47
+ ("start", "Starting Python and SpikeInterface"),
48
+ ("reading", "Reading the Phy folder and the analyzer"),
49
+ ("curation", "Applying Phy's merges and splits"),
50
+ ("saving", "Saving the curated analyzer"),
51
+ ("labels", "Saving Phy's labels"),
52
+ ]
53
+
54
+ # the extensions computed for an analyzer made from Kilosort output: what the models, the figures and the
55
+ # SpikeInterface-GUI use, as for an analyzer made in SpikeInterface
56
+ EXTENSION_STEPS = [
57
+ ("waveforms", ["random_spikes", "waveforms"]),
58
+ ("extensions", ["templates", "noise_levels", "spike_amplitudes", "spike_locations", "unit_locations", "correlograms",
59
+ "template_similarity"]),
60
+ ("pca", ["principal_components"]),
61
+ ("metrics", ["quality_metrics", "template_metrics"]),
62
+ ]
63
+ # the models on the Hugging Face Hub also use the multi-channel template metrics (e.g. spread) and the drift metrics
64
+ # (from spike_locations)
65
+ EXTENSION_PARAMS = {"template_metrics": dict(include_multi_channel_metrics=True)}
66
+
67
+ # from the SpikeGLX documentation: µV per bit of the AP band, for the usual probes
68
+ GAIN_HINT = "e.g. 2.34 for Neuropixels 1.0 (gain 500), 0.763 for Neuropixels 2.0"
69
+
70
+
71
+ class NeedsGain(ValueError):
72
+ """The recording is a plain binary file, so its gain (µV per bit) must be given."""
73
+
74
+
75
+ class RecordingNotFound(FileNotFoundError):
76
+ """The recording Kilosort sorted is not where params.py says."""
77
+
78
+
79
+ def report_step(step):
80
+ print(f"{PHY_STEP_MARKER} {step}", flush=True)
81
+
82
+
83
+ # --------------------------------------------------------------------------------------------------
84
+ # what is in a Phy folder
85
+ # --------------------------------------------------------------------------------------------------
86
+
87
+ def read_params(phy_folder):
88
+ """The settings in a Phy folder's params.py, as a dict (read without running the file)."""
89
+ params = {}
90
+ tree = ast.parse((Path(phy_folder) / "params.py").read_text())
91
+ for statement in tree.body:
92
+ if isinstance(statement, ast.Assign) and len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name):
93
+ try:
94
+ params[statement.targets[0].id] = ast.literal_eval(statement.value)
95
+ except ValueError:
96
+ pass
97
+ return params
98
+
99
+
100
+ def phy_folder_kind(phy_folder):
101
+ """
102
+ "exported" for a Phy folder made with SpikeInterface's export_to_phy, "kilosort" for Kilosort output.
103
+ Raises ValueError if the folder is not a Phy folder.
104
+ """
105
+ phy_folder = Path(phy_folder)
106
+ missing = [name for name in ["params.py", "spike_times.npy", "spike_templates.npy"] if not (phy_folder / name).is_file()]
107
+ if len(missing) > 0:
108
+ raise ValueError(f"{phy_folder} is not a Phy folder: it has no {', '.join(missing)}. Choose the folder with "
109
+ "params.py, which Kilosort writes and Phy opens.")
110
+ return "exported" if (phy_folder / "cluster_si_unit_ids.tsv").is_file() else "kilosort"
111
+
112
+
113
+ def default_output_folder(phy_folder):
114
+ """Where the analyzer made from a Phy folder is saved: next to it."""
115
+ phy_folder = Path(phy_folder)
116
+ return phy_folder.parent / f"{phy_folder.name}_analyzer"
117
+
118
+
119
+ def made_from_phy(analyzer_folder):
120
+ """What an analyzer was made from (the contents of made_from_phy.json), or None."""
121
+ path = Path(analyzer_folder) / MADE_FROM_PHY_FILE
122
+ if not path.is_file():
123
+ return None
124
+ with open(path) as f:
125
+ return json.load(f)
126
+
127
+
128
+ def recording_file(phy_folder, params=None):
129
+ """The recording file Kilosort sorted, from params.py, or None if it is not found."""
130
+ phy_folder = Path(phy_folder)
131
+ params = read_params(phy_folder) if params is None else params
132
+ dat_paths = params.get("dat_path")
133
+ if dat_paths is None:
134
+ return None
135
+ if isinstance(dat_paths, (list, tuple)):
136
+ if len(dat_paths) != 1:
137
+ return None
138
+ dat_paths = dat_paths[0]
139
+ path = Path(dat_paths)
140
+ # relative paths are relative to the Phy folder; the folder may also have been moved with its recording
141
+ for candidate in [path if path.is_absolute() else phy_folder / path, phy_folder / path.name, phy_folder.parent / path.name]:
142
+ if candidate.is_file():
143
+ return candidate
144
+ return None
145
+
146
+
147
+ def recording_needs_gain(path):
148
+ """True for plain binary files, whose gain is not saved with them (SpikeGLX and IBL files have it)."""
149
+ path = Path(path)
150
+ return not (path.with_suffix(".meta").is_file() or path.suffix == ".cbin")
151
+
152
+
153
+ def matches_phy_folder(analyzer_or_sorting, phy_folder):
154
+ """True if the Phy folder was exported from this analyzer (same units, same spike counts)."""
155
+ from unitrefine.phy_curation import phy_to_curation
156
+
157
+ try:
158
+ phy_to_curation(phy_folder, analyzer_or_sorting)
159
+ return True
160
+ except ValueError:
161
+ return False
162
+
163
+
164
+ def phy_has_edits(phy_folder):
165
+ """True if clusters were merged or split in Phy (spike_clusters differs from the exported units)."""
166
+ phy_folder = Path(phy_folder)
167
+ clusters_path = phy_folder / "spike_clusters.npy"
168
+ if not clusters_path.is_file():
169
+ return False
170
+ return not np.array_equal(np.load(clusters_path).ravel(), np.load(phy_folder / "spike_templates.npy").ravel())
171
+
172
+
173
+ # --------------------------------------------------------------------------------------------------
174
+ # reading the recording of Kilosort output
175
+ # --------------------------------------------------------------------------------------------------
176
+
177
+ def load_kilosort_recording(phy_folder, path=None, gain_to_uV=None):
178
+ """
179
+ The recording Kilosort sorted, with the channels Kilosort used, filtered as for spike sorting (unless
180
+ params.py says it already is): high-pass filtered and common median referenced, and phase shifted for
181
+ Neuropixels recordings from SpikeGLX. Returns (recording, description of how it was read).
182
+ """
183
+ import spikeinterface.full as si
184
+ from probeinterface import Probe
185
+
186
+ phy_folder = Path(phy_folder)
187
+ params = read_params(phy_folder)
188
+ path = recording_file(phy_folder, params) if path is None else Path(path)
189
+ if path is None or not path.is_file():
190
+ raise RecordingNotFound(
191
+ f"The recording Kilosort sorted was not found (params.py: dat_path = {params.get('dat_path')!r}). "
192
+ "Choose the recording file (e.g. the .ap.bin file from SpikeGLX)."
193
+ )
194
+
195
+ steps = []
196
+ if path.with_suffix(".meta").is_file():
197
+ # SpikeGLX: e.g. run_g0_t0.imec0.ap.bin is the stream imec0.ap; the gains are in the .meta file
198
+ stream_name = ".".join(path.name.split(".")[-3:-1])
199
+ recording = si.read_spikeglx(path.parent, stream_name=stream_name)
200
+ steps.append(f"SpikeGLX stream {stream_name}")
201
+ elif path.suffix == ".cbin":
202
+ recording = si.read_cbin_ibl(cbin_file_path=path)
203
+ steps.append("IBL compressed binary (.cbin)")
204
+ else:
205
+ if gain_to_uV is None:
206
+ raise NeedsGain(f"{path.name} is a binary file which doesn't say how to convert its values to µV. "
207
+ f"Give its gain in µV per bit ({GAIN_HINT}).")
208
+ recording = si.read_binary(
209
+ path, sampling_frequency=float(params["sample_rate"]), dtype=params.get("dtype", "int16"),
210
+ num_channels=int(params["n_channels_dat"]), file_offset=int(params.get("offset", 0)), gain_to_uV=float(gain_to_uV),
211
+ offset_to_uV=0.0,
212
+ )
213
+ steps.append(f"binary file, gain {gain_to_uV} µV per bit")
214
+
215
+ # the channels Kilosort used (e.g. without the sync channel), in its order
216
+ channel_map = np.load(phy_folder / "channel_map.npy").ravel() if (phy_folder / "channel_map.npy").is_file() else np.arange(recording.get_num_channels())
217
+ recording = recording.select_channels(recording.channel_ids[channel_map])
218
+ if not recording.has_probe():
219
+ positions = np.load(phy_folder / "channel_positions.npy")
220
+ probe = Probe(ndim=2, si_units="um")
221
+ probe.set_contacts(positions=positions, shapes="square", shape_params={"width": 12})
222
+ probe.set_device_channel_indices(np.arange(len(positions)))
223
+ # in place (SpikeInterface 0.106 removes the in_place argument)
224
+ recording.set_probe(probe)
225
+
226
+ if not params.get("hp_filtered", False):
227
+ if "SpikeGLX" in steps[0]:
228
+ recording = si.phase_shift(recording)
229
+ steps.append("phase shift")
230
+ recording = si.highpass_filter(recording, freq_min=300.0)
231
+ recording = si.common_reference(recording, reference="global", operator="median")
232
+ steps += ["high-pass filter at 300 Hz", "common median reference"]
233
+ return recording, ", ".join(steps)
234
+
235
+
236
+ # --------------------------------------------------------------------------------------------------
237
+ # making the analyzer
238
+ # --------------------------------------------------------------------------------------------------
239
+
240
+ def phy_labels(sorting):
241
+ """Phy's labels as a table with unit_id and quality columns: good, MUA, noise (other labels are left out)."""
242
+ from unitrefine.phy_curation import QUALITY_LABELS
243
+
244
+ if "quality" not in sorting.get_property_keys():
245
+ return pd.DataFrame(columns=["unit_id", "quality"])
246
+ labels = [QUALITY_LABELS.get(str(label).strip().lower()) for label in sorting.get_property("quality")]
247
+ table = pd.DataFrame({"unit_id": sorting.unit_ids, "quality": labels})
248
+ return table.dropna(subset=["quality"]).reset_index(drop=True)
249
+
250
+
251
+ def kilosort_sorting(phy_folder):
252
+ """
253
+ Phy's clusters (after its merges and splits) as a sorting, with the cluster ids as unit ids. Made from the
254
+ spikes, not with read_phy, which leaves out clusters missing from the cluster_*.tsv files.
255
+ """
256
+ import spikeinterface.full as si
257
+
258
+ phy_folder = Path(phy_folder)
259
+ spike_times = np.load(phy_folder / "spike_times.npy").ravel().astype("int64")
260
+ clusters_path = phy_folder / "spike_clusters.npy"
261
+ spike_clusters = np.load(clusters_path if clusters_path.is_file() else phy_folder / "spike_templates.npy").ravel().astype("int64")
262
+ order = np.argsort(spike_times, kind="stable")
263
+ return si.NumpySorting.from_samples_and_labels([spike_times[order]], [spike_clusters[order]],
264
+ float(read_params(phy_folder)["sample_rate"]))
265
+
266
+
267
+ def kilosort_phy_labels(phy_folder, unit_ids):
268
+ """
269
+ The labels set in Phy (cluster_group.tsv), as a table with unit_id and quality columns. Kilosort writes its
270
+ own automatic labels to cluster_group.tsv too: if they are all the same as cluster_KSLabel.tsv, the folder
271
+ was not labelled in Phy, so no labels are returned.
272
+ """
273
+ from unitrefine.phy_curation import QUALITY_LABELS
274
+
275
+ phy_folder = Path(phy_folder)
276
+ group_file = phy_folder / "cluster_group.tsv"
277
+ if not group_file.is_file():
278
+ print("No cluster_group.tsv: no units were labelled in Phy.", flush=True)
279
+ return pd.DataFrame(columns=["unit_id", "quality"])
280
+ groups = pd.read_csv(group_file, sep=" ")
281
+ groups = dict(zip(groups["cluster_id"].astype("int64"), groups[[column for column in groups.columns if column != "cluster_id"][0]]))
282
+ ks_file = phy_folder / "cluster_KSLabel.tsv"
283
+ if ks_file.is_file():
284
+ ks_labels = pd.read_csv(ks_file, sep=" ")
285
+ ks_labels = dict(zip(ks_labels["cluster_id"].astype("int64"), ks_labels["KSLabel"]))
286
+ if all(ks_labels.get(cluster_id) == label for cluster_id, label in groups.items()):
287
+ print("The labels in cluster_group.tsv are Kilosort's automatic labels (the same as cluster_KSLabel.tsv), "
288
+ "not labels set in Phy, so they are not used.", flush=True)
289
+ return pd.DataFrame(columns=["unit_id", "quality"])
290
+ unit_ids = set(int(unit_id) for unit_id in unit_ids)
291
+ rows = [(cluster_id, QUALITY_LABELS.get(str(label).strip().lower())) for cluster_id, label in groups.items() if cluster_id in unit_ids]
292
+ return pd.DataFrame(rows, columns=["unit_id", "quality"]).dropna(subset=["quality"]).reset_index(drop=True)
293
+
294
+
295
+ def analyzer_from_kilosort(phy_folder, output_folder, recording_path=None, gain_to_uV=None, job_kwargs=None):
296
+ """Makes an analyzer from Kilosort output curated in Phy, saved to `output_folder`. Returns (analyzer, labels, how the recording was read)."""
297
+ import spikeinterface.full as si
298
+
299
+ phy_folder = Path(phy_folder)
300
+ report_step("reading")
301
+ sorting = kilosort_sorting(phy_folder)
302
+ recording, how_read = load_kilosort_recording(phy_folder, recording_path, gain_to_uV)
303
+ print(f"Recording: {how_read}. {sorting.get_num_units()} units from Phy.", flush=True)
304
+
305
+ analyzer = si.create_sorting_analyzer(sorting, recording, format="binary_folder", folder=output_folder, sparse=True,
306
+ overwrite=True)
307
+ for step, extensions in EXTENSION_STEPS:
308
+ report_step(step)
309
+ params = {name: EXTENSION_PARAMS[name] for name in extensions if name in EXTENSION_PARAMS}
310
+ analyzer.compute(extensions, extension_params=params, **(job_kwargs or {}))
311
+ return analyzer, kilosort_phy_labels(phy_folder, sorting.unit_ids), how_read
312
+
313
+
314
+ def analyzer_from_exported(phy_folder, analyzer_path, output_folder, allow_hard_merge=False, job_kwargs=None):
315
+ """
316
+ Applies Phy's curation to the analyzer exported to Phy. Returns (analyzer path, labels): the analyzer itself
317
+ if Phy only changed labels, else the curated analyzer saved to `output_folder`.
318
+ """
319
+ import spikeinterface.full as si
320
+ from unitrefine.phy_curation import apply_phy_curation
321
+
322
+ report_step("reading")
323
+ analyzer = si.load_sorting_analyzer(analyzer_path)
324
+ report_step("curation")
325
+ curated = apply_phy_curation(analyzer, phy_folder, allow_hard_merge=allow_hard_merge, **(job_kwargs or {}))
326
+ labels = phy_labels(curated.sorting)
327
+ if not phy_has_edits(phy_folder):
328
+ print("Phy only changed labels, so the analyzer is used as it is.", flush=True)
329
+ return Path(analyzer_path), labels, False
330
+ report_step("saving")
331
+ curated.save_as(format="binary_folder", folder=output_folder)
332
+ return Path(output_folder), labels, True
333
+
334
+
335
+ def import_phy_folder(phy_folder, output_folder=None, labels_path=None, analyzer_path=None, recording_path=None,
336
+ gain_to_uV=None, allow_hard_merge=False, job_kwargs=None, reuse=False):
337
+ """
338
+ Makes the analyzer for a Phy folder (see the module description) and saves Phy's labels to `labels_path`
339
+ (default: next to the analyzer, as <analyzer>_phy_labels.csv). Returns a dict with the `analyzer` path,
340
+ the `labels` path and their number, the `kind` of Phy folder, and whether a `new_analyzer` was saved.
341
+ With `reuse`, the analyzer saved earlier in `output_folder` is used, and only the labels are read again.
342
+ """
343
+ phy_folder = Path(phy_folder)
344
+ kind = phy_folder_kind(phy_folder)
345
+ output_folder = default_output_folder(phy_folder) if output_folder is None else Path(output_folder)
346
+ start_time = time.time()
347
+
348
+ if reuse:
349
+ import spikeinterface.full as si
350
+
351
+ report_step("reading")
352
+ sorting = si.load_sorting_analyzer(output_folder, load_extensions=False).sorting
353
+ labels = kilosort_phy_labels(phy_folder, sorting.unit_ids) if kind == "kilosort" else phy_labels(sorting)
354
+ analyzer_folder, new_analyzer = output_folder, False
355
+ print(f"Using the analyzer made earlier: {output_folder}", flush=True)
356
+ elif kind == "kilosort":
357
+ _, labels, how_read = analyzer_from_kilosort(phy_folder, output_folder, recording_path, gain_to_uV, job_kwargs)
358
+ analyzer_folder, new_analyzer = output_folder, True
359
+ details = dict(recording=str(recording_path or recording_file(phy_folder)), recording_read_as=how_read)
360
+ else:
361
+ if analyzer_path is None:
362
+ raise ValueError(f"{phy_folder} was exported from a SpikeInterface analyzer: give that analyzer (--analyzer).")
363
+ analyzer_folder, labels, new_analyzer = analyzer_from_exported(phy_folder, analyzer_path, output_folder,
364
+ allow_hard_merge, job_kwargs)
365
+ details = dict(exported_from=str(analyzer_path))
366
+
367
+ if new_analyzer:
368
+ with open(output_folder / MADE_FROM_PHY_FILE, "w") as f:
369
+ json.dump(dict(phy_folder=str(phy_folder), kind=kind, date=datetime.now().isoformat(timespec="seconds"),
370
+ **details), f, indent=4)
371
+
372
+ report_step("labels")
373
+ labels_path = Path(labels_path) if labels_path is not None else output_folder.parent / f"{output_folder.name}_phy_labels.csv"
374
+ labels_path.parent.mkdir(parents=True, exist_ok=True)
375
+ labels.to_csv(labels_path, index=False)
376
+ counts = {label: int(count) for label, count in labels['quality'].value_counts().items()}
377
+ print(f"{len(labels)} units have a Phy label {counts}. Saved in {labels_path}.", flush=True)
378
+ print(f"Done in {time.time() - start_time:.0f} s. Analyzer: {analyzer_folder}", flush=True)
379
+ return dict(kind=kind, analyzer=str(analyzer_folder), new_analyzer=new_analyzer, labels=str(labels_path),
380
+ num_labels=len(labels))
381
+
382
+
383
+ # --------------------------------------------------------------------------------------------------
384
+ # command line
385
+ # --------------------------------------------------------------------------------------------------
386
+
387
+ def main(argv=None):
388
+ from unitrefine.phy_curation import HardMergeRequired
389
+
390
+ parser = argparse.ArgumentParser(prog="python -m unitrefine.phy_import", description=__doc__.split("\n\n")[0],
391
+ formatter_class=argparse.RawDescriptionHelpFormatter)
392
+ parser.add_argument("phy_folder", help="The Phy folder (with params.py)")
393
+ parser.add_argument("--output", default=None, help="Where to save the analyzer (default: next to the Phy folder)")
394
+ parser.add_argument("--labels", default=None, help="Where to save Phy's labels (csv)")
395
+ parser.add_argument("--analyzer", default=None, help="For a Phy folder exported from an analyzer: that analyzer")
396
+ parser.add_argument("--recording", default=None, help="For Kilosort output: the recording, if params.py doesn't find it")
397
+ parser.add_argument("--gain_to_uv", type=float, default=None, help=f"For a plain binary recording: µV per bit ({GAIN_HINT})")
398
+ parser.add_argument("--allow_hard_merge", action="store_true", help="Do merges which need the raw recording")
399
+ parser.add_argument("--reuse", action="store_true", help="Use the analyzer made earlier in --output; only read the labels again")
400
+ parser.add_argument("--n_jobs", type=float, default=-1, help="Processes to use (-1: all cores)")
401
+ args = parser.parse_args(argv)
402
+
403
+ report_step("start")
404
+ try:
405
+ result = import_phy_folder(
406
+ args.phy_folder, output_folder=args.output, labels_path=args.labels, analyzer_path=args.analyzer,
407
+ recording_path=args.recording, gain_to_uV=args.gain_to_uv, allow_hard_merge=args.allow_hard_merge,
408
+ job_kwargs=dict(n_jobs=int(args.n_jobs) if args.n_jobs >= 1 or args.n_jobs == -1 else args.n_jobs),
409
+ reuse=args.reuse,
410
+ )
411
+ result["status"] = "done"
412
+ except HardMergeRequired as e:
413
+ result = dict(status="hard_merge_required", message=str(e))
414
+ except NeedsGain as e:
415
+ result = dict(status="needs_gain", message=str(e))
416
+ except RecordingNotFound as e:
417
+ result = dict(status="needs_recording", message=str(e))
418
+ except Exception as e:
419
+ traceback.print_exc()
420
+ result = dict(status="error", message=f"{type(e).__name__}: {e}")
421
+ if result["status"] != "done":
422
+ print(result["message"], flush=True)
423
+ print(f"{PHY_RESULT_MARKER} {json.dumps(result)}", flush=True)
424
+ return 0 if result["status"] == "done" else 1
425
+
426
+
427
+ if __name__ == "__main__":
428
+ sys.exit(main())