mesofield 0.3.2b0__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 (111) hide show
  1. docs/_static/custom.css +40 -0
  2. docs/_static/favicon.png +0 -0
  3. docs/_static/logo.png +0 -0
  4. docs/api/index.md +70 -0
  5. docs/conf.py +200 -0
  6. docs/developer_guide.md +303 -0
  7. docs/index.md +25 -0
  8. docs/tutorial.md +4 -0
  9. docs/user_guide.md +172 -0
  10. examples/teensy_pulse_generator.py +320 -0
  11. experiments/pipeline_demo/experiment.json +24 -0
  12. experiments/pipeline_demo/hardware.yaml +23 -0
  13. experiments/pipeline_demo/procedure.py +50 -0
  14. experiments/two_cam_demo/experiment.json +24 -0
  15. experiments/two_cam_demo/hardware.yaml +58 -0
  16. experiments/two_cam_demo/load_dataset.py +213 -0
  17. experiments/two_cam_demo/procedure.py +87 -0
  18. external/video-codecs/openh264-1.8.0-win64.dll +0 -0
  19. mesofield/__init__.py +45 -0
  20. mesofield/__main__.py +11 -0
  21. mesofield/_version.py +24 -0
  22. mesofield/base.py +750 -0
  23. mesofield/cli/__init__.py +57 -0
  24. mesofield/cli/_richhelp.py +100 -0
  25. mesofield/cli/acquire.py +254 -0
  26. mesofield/cli/datakit.py +165 -0
  27. mesofield/cli/process.py +376 -0
  28. mesofield/cli/rig.py +108 -0
  29. mesofield/cli/tools.py +347 -0
  30. mesofield/config.py +751 -0
  31. mesofield/data/__init__.py +23 -0
  32. mesofield/data/batch.py +633 -0
  33. mesofield/data/manager.py +388 -0
  34. mesofield/data/writer.py +289 -0
  35. mesofield/datakit/__init__.py +44 -0
  36. mesofield/datakit/__main__.py +35 -0
  37. mesofield/datakit/_utils/_logger.py +5 -0
  38. mesofield/datakit/_version.py +141 -0
  39. mesofield/datakit/config.py +50 -0
  40. mesofield/datakit/core.py +783 -0
  41. mesofield/datakit/datamodel.py +200 -0
  42. mesofield/datakit/discover.py +124 -0
  43. mesofield/datakit/explore.py +651 -0
  44. mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
  45. mesofield/datakit/profile.py +535 -0
  46. mesofield/datakit/shell.py +83 -0
  47. mesofield/datakit/sources/__init__.py +65 -0
  48. mesofield/datakit/sources/analysis/mesomap.py +194 -0
  49. mesofield/datakit/sources/analysis/mesoscope.py +77 -0
  50. mesofield/datakit/sources/analysis/pupil.py +246 -0
  51. mesofield/datakit/sources/behavior/__init__.py +0 -0
  52. mesofield/datakit/sources/behavior/dataqueue.py +281 -0
  53. mesofield/datakit/sources/behavior/psychopy.py +364 -0
  54. mesofield/datakit/sources/behavior/treadmill.py +323 -0
  55. mesofield/datakit/sources/behavior/wheel.py +277 -0
  56. mesofield/datakit/sources/camera/mesoscope.py +32 -0
  57. mesofield/datakit/sources/camera/metadata_json.py +130 -0
  58. mesofield/datakit/sources/camera/pupil.py +28 -0
  59. mesofield/datakit/sources/camera/suite2p.py +547 -0
  60. mesofield/datakit/sources/register.py +204 -0
  61. mesofield/datakit/sources/session/config.py +130 -0
  62. mesofield/datakit/sources/session/notes.py +63 -0
  63. mesofield/datakit/sources/session/timestamps.py +58 -0
  64. mesofield/datakit/timeline.py +306 -0
  65. mesofield/devices/__init__.py +42 -0
  66. mesofield/devices/base.py +498 -0
  67. mesofield/devices/base_camera.py +295 -0
  68. mesofield/devices/cameras.py +740 -0
  69. mesofield/devices/daq.py +151 -0
  70. mesofield/devices/encoder.py +384 -0
  71. mesofield/devices/mocks.py +275 -0
  72. mesofield/devices/psychopy_device.py +455 -0
  73. mesofield/devices/subprocesses/__init__.py +0 -0
  74. mesofield/devices/subprocesses/psychopy.py +133 -0
  75. mesofield/devices/treadmill.py +318 -0
  76. mesofield/engines.py +380 -0
  77. mesofield/gui/Mesofield_icon.png +0 -0
  78. mesofield/gui/__init__.py +76 -0
  79. mesofield/gui/config_wizard.py +724 -0
  80. mesofield/gui/controller.py +535 -0
  81. mesofield/gui/dynamic_controller.py +78 -0
  82. mesofield/gui/maingui.py +427 -0
  83. mesofield/gui/mdagui.py +285 -0
  84. mesofield/gui/qt_device_adapter.py +109 -0
  85. mesofield/gui/speedplotter.py +152 -0
  86. mesofield/gui/theme.py +445 -0
  87. mesofield/gui/tiff_viewer.py +1050 -0
  88. mesofield/gui/viewer.py +691 -0
  89. mesofield/hardware.py +549 -0
  90. mesofield/playback.py +1298 -0
  91. mesofield/processing/__init__.py +12 -0
  92. mesofield/processing/runner.py +237 -0
  93. mesofield/processors/__init__.py +13 -0
  94. mesofield/processors/base.py +287 -0
  95. mesofield/processors/frame_mean.py +19 -0
  96. mesofield/protocols.py +378 -0
  97. mesofield/scaffold/__init__.py +34 -0
  98. mesofield/scaffold/experiment.py +400 -0
  99. mesofield/scaffold/rigs.py +121 -0
  100. mesofield/signals.py +85 -0
  101. mesofield/utils/__init__.py +0 -0
  102. mesofield/utils/_logger.py +156 -0
  103. mesofield/utils/retrofit.py +309 -0
  104. mesofield/utils/utils.py +217 -0
  105. mesofield-0.3.2b0.dist-info/METADATA +178 -0
  106. mesofield-0.3.2b0.dist-info/RECORD +111 -0
  107. mesofield-0.3.2b0.dist-info/WHEEL +5 -0
  108. mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
  109. mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
  110. mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
  111. scripts/bench_frame_processor.py +103 -0
@@ -0,0 +1,376 @@
1
+ """``mesofield process`` — batch processing & conversion of recorded data.
2
+
3
+ Operates on BIDS-formatted experiment trees that have already been acquired:
4
+ fluorescence traces, frame montages, video conversion, and manifest
5
+ retrofitting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from ._richhelp import RichGroup
16
+
17
+
18
+ @click.group('process', cls=RichGroup)
19
+ def process():
20
+ """Batch-process and convert recorded experiment data."""
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Datakit-based meso tiff discovery helpers
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def _discover_meso_entries(experiment_dir, subject_filter=None):
29
+ """Return ManifestEntry objects for mesoscope tiff files.
30
+
31
+ Uses datakit's ``discover_manifest`` to scan the BIDS hierarchy,
32
+ filters for ``meso_metadata`` entries (the JSON sidecars), then
33
+ rewrites each entry's path to point at the tiff itself.
34
+ """
35
+ from mesofield.datakit.discover import discover_manifest
36
+ from mesofield.datakit.datamodel import ManifestEntry
37
+
38
+ manifest = discover_manifest(Path(experiment_dir))
39
+ tiff_entries = []
40
+ for entry in manifest.entries:
41
+ if entry.tag != "meso_metadata":
42
+ continue
43
+ if subject_filter and entry.subject != subject_filter:
44
+ continue
45
+ # Derive the tiff path from the metadata sidecar path:
46
+ # ...mesoscope.ome.tiff_frame_metadata.json → ...mesoscope.ome.tiff
47
+ tiff_path = entry.path.replace("_frame_metadata.json", "")
48
+ tiff_entries.append(ManifestEntry(
49
+ tag="meso_tiff",
50
+ path=tiff_path,
51
+ origin=entry.origin,
52
+ subject=entry.subject,
53
+ session=entry.session,
54
+ task=entry.task,
55
+ ))
56
+ return tiff_entries
57
+
58
+
59
+ def _discover_meso_tiffs(experiment_dir, subject_filter=None):
60
+ """Return a list of absolute tiff paths and the experiment root."""
61
+ root = Path(experiment_dir).resolve()
62
+ entries = _discover_meso_entries(experiment_dir, subject_filter)
63
+ paths = [str(root / e.path) for e in entries]
64
+ return paths, root
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # trace-meso
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @process.command('trace-meso')
73
+ @click.option('--path', help='Path to a single tiff file (bypass discovery)')
74
+ @click.option('--dir', help='Experiment directory to discover mesoscope tiffs')
75
+ @click.option('--sub', default=None, help='Subject ID to filter (default: all)')
76
+ def trace_meso(path, dir, sub):
77
+ """Compute mean fluorescence traces from mesoscope OME-TIFF stacks.
78
+
79
+ Uses datakit discovery to find mesoscope tiffs in a BIDS hierarchy,
80
+ or accepts a single --path for quick one-off analysis.
81
+ """
82
+ import pandas as pd
83
+ import mesofield.data.batch as batch
84
+
85
+ if path:
86
+ tiff_paths = [path]
87
+ experiment_dir = Path(path).parents[4]
88
+ outdir = Path(experiment_dir) / "processed"
89
+ elif dir:
90
+ tiff_paths, experiment_dir = _discover_meso_tiffs(dir, sub)
91
+ outdir = Path(dir) / "processed" / sub if sub else Path(dir) / "processed"
92
+ else:
93
+ raise click.UsageError("Provide --path or --dir")
94
+
95
+ if not tiff_paths:
96
+ click.secho("No mesoscope tiffs found.", fg="yellow")
97
+ return
98
+
99
+ os.makedirs(outdir, exist_ok=True)
100
+ click.echo(f"Computing mean traces for {len(tiff_paths)} tiff(s)...")
101
+ results = batch.mean_trace_from_tiff(tiff_paths)
102
+
103
+ for tiff_path, trace in results.items():
104
+ df = pd.DataFrame({"Slice": range(len(trace)), "Mean": trace})
105
+ base_name = os.path.splitext(os.path.basename(tiff_path))[0]
106
+ filename = f"{base_name}_meso-mean-trace.csv"
107
+ save_path = os.path.join(outdir, filename)
108
+ df.to_csv(save_path, index=False)
109
+ click.echo(f" {filename} ({len(trace)} frames)")
110
+
111
+ click.secho(f"Saved {len(results)} trace(s) to {outdir}", fg="green")
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # montage-meso
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @process.command('montage-meso')
120
+ @click.option('--dir', required=True, help='Experiment directory containing BIDS formatted /data hierarchy')
121
+ @click.option('--sub', default=None, help='Single subject ID to process (default: all subjects)')
122
+ @click.option('--frame', default=1, show_default=True, help='0-based frame index to extract from each tiff')
123
+ @click.option('--rotate', default=0, show_default=True, type=int, help='Rotate each frame by N degrees (positive=clockwise, negative=counter-clockwise)')
124
+ @click.option('--filter', 'ses_filter', default=None, help='Session range START:END (1-based, exclusive end). E.g. 1:10 keeps sessions 1-9.')
125
+ def montage_meso(dir, sub, frame, rotate, ses_filter):
126
+ """Extract a single frame from each session's widefield tiff and save a per-subject montage.
127
+
128
+ Uses datakit to discover mesoscope tiffs in the BIDS hierarchy.
129
+ Each task within a session becomes its own row. Columns are sessions,
130
+ so the output image is a grid of (tasks x sessions) panels.
131
+ """
132
+ import numpy as np
133
+ import tifffile
134
+ from PIL import Image, ImageDraw, ImageFont
135
+
136
+ # --- Discover tiffs via datakit ---
137
+ entries = _discover_meso_entries(dir, sub)
138
+ if not entries:
139
+ click.secho("No mesoscope tiffs found.", fg="yellow")
140
+ return
141
+
142
+ # Build a nested dict: subject -> session -> task -> tiff_path
143
+ tree: dict[str, dict[str, dict[str, str]]] = {}
144
+ root = Path(dir).resolve()
145
+ for entry in entries:
146
+ s, ses, task = entry.subject, entry.session, entry.task or "default"
147
+ tree.setdefault(s, {}).setdefault(ses, {})[task] = str(root / entry.path)
148
+
149
+ subjects = [sub] if sub else sorted(tree.keys())
150
+
151
+ outdir = os.path.join(dir, "processed")
152
+ os.makedirs(outdir, exist_ok=True)
153
+
154
+ try:
155
+ font = ImageFont.truetype("arial.ttf", 18)
156
+ except OSError:
157
+ font = ImageFont.load_default()
158
+
159
+ label_height = 30
160
+
161
+ for subject in subjects:
162
+ if subject not in tree:
163
+ click.echo(f"WARNING: sub-{subject} not found, skipping")
164
+ continue
165
+ sessions = tree[subject]
166
+ ses_keys = sorted(sessions.keys())
167
+
168
+ # Apply session range filter
169
+ if ses_filter:
170
+ parts = ses_filter.split(':')
171
+ start = int(parts[0]) if parts[0] else 1
172
+ end = int(parts[1]) if len(parts) > 1 and parts[1] else None
173
+ ses_keys = [k for k in ses_keys if int(k) >= start and (end is None or int(k) < end)]
174
+
175
+ # Collect the superset of task names across all sessions (preserving order)
176
+ all_tasks: list[str] = []
177
+ for sk in ses_keys:
178
+ for tk in sessions[sk]:
179
+ if tk not in all_tasks:
180
+ all_tasks.append(tk)
181
+
182
+ # grid[task][ses_key] = normalised 8-bit numpy image
183
+ grid: dict[str, dict[str, np.ndarray]] = {t: {} for t in all_tasks}
184
+
185
+ for ses_key in ses_keys:
186
+ session = sessions[ses_key]
187
+ for task in all_tasks:
188
+ if task not in session:
189
+ click.echo(f"WARNING: sub-{subject} ses-{ses_key} task-{task} missing, skipping")
190
+ continue
191
+
192
+ tiff_path = session[task]
193
+ try:
194
+ tiff_array = tifffile.memmap(tiff_path)
195
+ if tiff_array.shape[0] <= frame:
196
+ click.echo(f"WARNING: {tiff_path} has only {tiff_array.shape[0]} frames, skipping")
197
+ continue
198
+ img = np.array(tiff_array[frame])
199
+ except Exception as e:
200
+ click.echo(f"ERROR reading {tiff_path}: {e}")
201
+ continue
202
+
203
+ img_min, img_max = img.min(), img.max()
204
+ if img_max > img_min:
205
+ img_norm = ((img - img_min) / (img_max - img_min) * 255).astype(np.uint8)
206
+ else:
207
+ img_norm = np.zeros_like(img, dtype=np.uint8)
208
+
209
+ if rotate:
210
+ img_norm = np.array(Image.fromarray(img_norm).rotate(-rotate, expand=True))
211
+
212
+ grid[task][ses_key] = img_norm
213
+
214
+ if not any(grid[t] for t in all_tasks):
215
+ click.echo(f"WARNING: No frames found for sub-{subject}, skipping")
216
+ continue
217
+
218
+ all_imgs = [img for task_imgs in grid.values() for img in task_imgs.values()]
219
+ cell_h = max(img.shape[0] for img in all_imgs)
220
+ cell_w = max(img.shape[1] for img in all_imgs)
221
+
222
+ def _resize(img):
223
+ if img.shape[0] == cell_h and img.shape[1] == cell_w:
224
+ return img
225
+ return np.array(Image.fromarray(img).resize((cell_w, cell_h), Image.LANCZOS))
226
+
227
+ def _blank():
228
+ return np.zeros((cell_h, cell_w), dtype=np.uint8)
229
+
230
+ def _label_header(text, width):
231
+ header = Image.new('L', (width, label_height), color=0)
232
+ draw = ImageDraw.Draw(header)
233
+ bbox = draw.textbbox((0, 0), text, font=font)
234
+ text_w = bbox[2] - bbox[0]
235
+ draw.text(((width - text_w) // 2, 4), text, fill=255, font=font)
236
+ return np.array(header)
237
+
238
+ def _vertical_text(text, height, strip_w=40):
239
+ tmp = Image.new('L', (height, strip_w), color=0)
240
+ draw = ImageDraw.Draw(tmp)
241
+ bbox = draw.textbbox((0, 0), text, font=font)
242
+ tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
243
+ draw.text(((height - tw) // 2, (strip_w - th) // 2), text, fill=255, font=font)
244
+ return tmp
245
+
246
+ side_strip_w = 40
247
+ grid_height = label_height + cell_h * len(all_tasks)
248
+
249
+ subject_label = f"sub-{subject}"
250
+ left_pil = _vertical_text(subject_label, grid_height, side_strip_w)
251
+ left_strip = np.array(left_pil.rotate(90, expand=True))
252
+
253
+ right_pieces = [np.zeros((label_height, side_strip_w), dtype=np.uint8)]
254
+ for task in all_tasks:
255
+ task_label = task if task else "default"
256
+ tmp = _vertical_text(task_label, cell_h, side_strip_w)
257
+ right_pieces.append(np.array(tmp.rotate(-90, expand=True)))
258
+ right_strip = np.vstack(right_pieces)
259
+
260
+ columns = []
261
+ for ses_key in ses_keys:
262
+ col_header = _label_header(f"ses-{ses_key}", cell_w)
263
+ panels = [col_header]
264
+ for task in all_tasks:
265
+ img = grid[task].get(ses_key)
266
+ panels.append(_resize(img) if img is not None else _blank())
267
+ columns.append(np.vstack(panels))
268
+
269
+ montage = np.hstack([left_strip] + columns + [right_strip])
270
+ montage_img = Image.fromarray(montage)
271
+
272
+ filename = f"sub-{subject}_frame-{frame}_montage.png"
273
+ save_path = os.path.join(outdir, filename)
274
+ montage_img.save(save_path)
275
+ n_cells = sum(len(v) for v in grid.values())
276
+ click.echo(f"Saved: {save_path} ({len(all_tasks)} tasks x {len(ses_keys)} sessions, {n_cells} images)")
277
+
278
+ click.secho("Done.", fg="green")
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # pupil-mp4
283
+ # ---------------------------------------------------------------------------
284
+
285
+
286
+ @process.command('pupil-mp4')
287
+ @click.option('--dir', help='Directory containing the BIDS formatted /data hierarchy')
288
+ def pupil_mp4(dir):
289
+ """Convert the pupil videos to mp4 format."""
290
+ from mesofield.data.batch import tiff_to_mp4
291
+
292
+ tiff_to_mp4(
293
+ parent_directory=dir,
294
+ fps=30,
295
+ output_format="mp4",
296
+ use_color=False
297
+ )
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # convert-h264
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ @process.command('convert-h264')
306
+ @click.option('--dir', required=True, help='Directory containing video files to convert')
307
+ @click.option('--pattern', default='*.mp4', help='Glob pattern to match files (e.g., "*.mp4", "pupil*.mp4")')
308
+ def convert_h264(dir, pattern):
309
+ """Convert video files to H264 format for better compatibility."""
310
+ from mesofield.data.batch import batch_convert_to_h264
311
+
312
+ batch_convert_to_h264(
313
+ parent_directory=dir,
314
+ pattern=pattern
315
+ )
316
+
317
+
318
+ # ---------------------------------------------------------------------------
319
+ # retrofit-manifest
320
+ # ---------------------------------------------------------------------------
321
+
322
+
323
+ @process.command('retrofit-manifest')
324
+ @click.argument('path', type=click.Path(exists=True, file_okay=False, dir_okay=True))
325
+ @click.option('--force', is_flag=True,
326
+ help='Overwrite existing manifest.json files (default: skip).')
327
+ @click.option('--dry-run', is_flag=True,
328
+ help='Print what would be written without touching disk.')
329
+ def retrofit_manifest(path, force, dry_run):
330
+ """Reconstruct mesokit-schema AcquisitionManifests for legacy sessions.
331
+
332
+ PATH is either a single session directory (``data/sub-X/ses-Y/``) or an
333
+ experiment root that contains many sessions under ``data/``. Hardware
334
+ calibration is not present in legacy acquisitions; producers get an
335
+ empty ``calibration`` dict. Frame-metadata sidecars (``*_frame_metadata.json``)
336
+ are attached to their tiffs. Multi-task sessions write one manifest
337
+ per task as ``manifest_task-<T>.json``.
338
+ """
339
+ from mesofield.utils.retrofit import (
340
+ discover_sessions,
341
+ manifest_filename,
342
+ synthesize_manifests,
343
+ )
344
+
345
+ sessions = list(discover_sessions(Path(path)))
346
+ if not sessions:
347
+ click.secho(f"No BIDS sessions found under {path}", fg="yellow")
348
+ raise SystemExit(1)
349
+
350
+ written = skipped = empty = 0
351
+ for session in sessions:
352
+ by_task = synthesize_manifests(session)
353
+ if not by_task:
354
+ click.echo(f"empty {session} (no producer files)")
355
+ empty += 1
356
+ continue
357
+ multi_task = len(by_task) > 1
358
+ for task, manifest in by_task.items():
359
+ out_path = session / manifest_filename(task, multi_task)
360
+ if out_path.exists() and not force:
361
+ click.echo(f"skip {out_path} (exists; use --force to overwrite)")
362
+ skipped += 1
363
+ continue
364
+ verb = "would write" if dry_run else "wrote"
365
+ click.echo(
366
+ f"{verb} {out_path} ({len(manifest.producers)} producers, task={task})"
367
+ )
368
+ if not dry_run:
369
+ manifest.write(out_path)
370
+ written += 1
371
+
372
+ summary = (
373
+ f"\n{'(dry-run) ' if dry_run else ''}"
374
+ f"sessions={len(sessions)} written={written} skipped={skipped} empty={empty}"
375
+ )
376
+ click.echo(summary)
mesofield/cli/rig.py ADDED
@@ -0,0 +1,108 @@
1
+ """``mesofield rig`` — manage this machine's canonical hardware.yaml configs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ._richhelp import RichGroup
10
+
11
+
12
+ @click.group('rig', cls=RichGroup)
13
+ def rig():
14
+ """Manage this machine's canonical hardware.yaml configurations.
15
+
16
+ A rig is a named hardware.yaml stored in this computer's OS config
17
+ directory. `mesofield init` copies a rig into each new experiment so
18
+ experiment folders stay self-contained.
19
+ """
20
+
21
+
22
+ @rig.command('list')
23
+ def rig_list():
24
+ """List the canonical rigs registered on this machine."""
25
+ from mesofield.scaffold import rigs
26
+
27
+ names = rigs.list_rigs()
28
+ if not names:
29
+ click.echo(f"No rigs registered. Store: {rigs.rigs_dir()}")
30
+ click.echo("Add one with 'mesofield rig add' or 'mesofield rig new'.")
31
+ return
32
+ click.echo(f"Rigs in {rigs.rigs_dir()}:")
33
+ for name in names:
34
+ click.secho(f" {name}", fg="cyan")
35
+ try:
36
+ devices = rigs.rig_devices(name)
37
+ except Exception as exc:
38
+ click.secho(f" (could not read devices: {exc})", fg="red")
39
+ continue
40
+ if not devices:
41
+ click.echo(" (no devices declared)")
42
+ for dev_name, dev_type in devices:
43
+ click.echo(f" - {dev_name} (type: {dev_type})")
44
+
45
+
46
+ @rig.command('add')
47
+ @click.argument('name')
48
+ @click.argument('path', type=click.Path(exists=True, dir_okay=False))
49
+ @click.option('--force', is_flag=True, help='Overwrite an existing rig.')
50
+ def rig_add(name, path, force):
51
+ """Copy an existing hardware.yaml at PATH into the store as NAME."""
52
+ from mesofield.scaffold import rigs
53
+
54
+ try:
55
+ dst = rigs.add_rig(name, Path(path), force=force)
56
+ except FileExistsError as exc:
57
+ click.secho(str(exc), fg="red")
58
+ raise SystemExit(1)
59
+ except Exception as exc:
60
+ click.secho(f"Failed to add rig: {exc}", fg="red")
61
+ raise SystemExit(1)
62
+ click.secho(f"Registered rig {name!r} at {dst}", fg="green")
63
+
64
+
65
+ @rig.command('new')
66
+ @click.argument('name')
67
+ @click.option('--force', is_flag=True, help='Overwrite an existing rig.')
68
+ def rig_new(name, force):
69
+ """Scaffold a blank fill-out hardware template in the store as NAME."""
70
+ from mesofield.scaffold import rigs
71
+
72
+ try:
73
+ dst = rigs.new_rig(name, force=force)
74
+ except FileExistsError as exc:
75
+ click.secho(str(exc), fg="red")
76
+ raise SystemExit(1)
77
+ click.secho(f"Created rig template at {dst}", fg="green")
78
+ click.echo("Edit it to declare this machine's real devices, then use it")
79
+ click.echo(f"with 'mesofield init <dir> --rig {name}'.")
80
+
81
+
82
+ @rig.command('show')
83
+ @click.argument('name')
84
+ def rig_show(name):
85
+ """Print the path and contents of rig NAME."""
86
+ from mesofield.scaffold import rigs
87
+
88
+ try:
89
+ path = rigs._resolve_existing(name)
90
+ except FileNotFoundError as exc:
91
+ click.secho(str(exc), fg="red")
92
+ raise SystemExit(1)
93
+ click.echo(f"# {path}")
94
+ click.echo(path.read_text(encoding="utf-8"))
95
+
96
+
97
+ @rig.command('remove')
98
+ @click.argument('name')
99
+ def rig_remove(name):
100
+ """Delete rig NAME from the store."""
101
+ from mesofield.scaffold import rigs
102
+
103
+ try:
104
+ rigs.remove_rig(name)
105
+ except FileNotFoundError as exc:
106
+ click.secho(str(exc), fg="red")
107
+ raise SystemExit(1)
108
+ click.secho(f"Removed rig {name!r}", fg="green")