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,724 @@
1
+ """
2
+ Configuration wizard for hot-loading experiment and hardware configurations.
3
+
4
+ Provides a unified widget for selecting and applying:
5
+ - Experiment JSON config files
6
+ - Hardware YAML config files
7
+ - MicroManager system .cfg files (via pymmcore-widgets ConfigurationWidget)
8
+ - Full pymmcore-widgets Hardware Configuration Wizard (popup)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import json
15
+ import inspect
16
+ import shutil
17
+ from typing import TYPE_CHECKING, Optional, List
18
+
19
+ from PyQt6.QtCore import pyqtSignal, Qt, QSettings
20
+ from PyQt6.QtWidgets import (
21
+ QComboBox,
22
+ QFileDialog,
23
+ QGroupBox,
24
+ QHBoxLayout,
25
+ QLabel,
26
+ QLineEdit,
27
+ QPushButton,
28
+ QVBoxLayout,
29
+ QWidget,
30
+ QMessageBox,
31
+ QFrame,
32
+ )
33
+
34
+ from mesofield.gui import theme
35
+
36
+ if TYPE_CHECKING:
37
+ from pymmcore_plus import CMMCorePlus
38
+ from mesofield.base import Procedure
39
+ from mesofield.devices.cameras import MMCamera
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Dark-theme fix for pymmcore-widgets ConfigWizard on Windows
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _is_dark_palette(widget: QWidget) -> bool:
47
+ """Return True if the widget's palette suggests a dark theme."""
48
+ bg = widget.palette().color(widget.backgroundRole())
49
+ # Use perceived luminance; a value < 128 indicates a dark background
50
+ return bg.lightness() < 128
51
+
52
+
53
+ _DARK_WIZARD_QSS = """
54
+ /* ---- top-level wizard and every nested widget ---- */
55
+ QWizard, QWizard > QWidget {
56
+ background-color: #2b2b2b;
57
+ color: #e0e0e0;
58
+ }
59
+
60
+ /* ---- wizard-page content area ---- */
61
+ QWizardPage, QWizardPage > QWidget, QWizardPage QFrame {
62
+ background-color: #2b2b2b;
63
+ color: #e0e0e0;
64
+ }
65
+
66
+ /* ---- Modern-style header (title / subtitle banner) ---- */
67
+ QWizard QWidget#qt_wizard_header {
68
+ background-color: #333333;
69
+ border-bottom: 1px solid #555;
70
+ }
71
+
72
+ /* Side widget (step labels panel) */
73
+ QWizard QWidget#qt_wizard_sidebar {
74
+ background-color: #252525;
75
+ }
76
+
77
+ QLabel {
78
+ color: #e0e0e0;
79
+ background: transparent;
80
+ }
81
+ QComboBox {
82
+ background-color: #3c3c3c;
83
+ color: #e0e0e0;
84
+ border: 1px solid #555;
85
+ padding: 4px;
86
+ }
87
+ QComboBox QAbstractItemView {
88
+ background-color: #3c3c3c;
89
+ color: #e0e0e0;
90
+ selection-background-color: #0078d4;
91
+ selection-color: #ffffff;
92
+ }
93
+ QComboBox::drop-down {
94
+ border-left: 1px solid #555;
95
+ }
96
+ QLineEdit {
97
+ background-color: #3c3c3c;
98
+ color: #e0e0e0;
99
+ border: 1px solid #555;
100
+ padding: 4px;
101
+ }
102
+ QCheckBox {
103
+ color: #e0e0e0;
104
+ }
105
+ QCheckBox::indicator {
106
+ border: 1px solid #888;
107
+ }
108
+ QRadioButton {
109
+ color: #e0e0e0;
110
+ }
111
+ QPushButton {
112
+ background-color: #3c3c3c;
113
+ color: #e0e0e0;
114
+ border: 1px solid #555;
115
+ padding: 4px 12px;
116
+ }
117
+ QPushButton:hover {
118
+ background-color: #4a4a4a;
119
+ }
120
+ QPushButton:pressed {
121
+ background-color: #555;
122
+ }
123
+ QTableWidget, QTableView, QTreeView {
124
+ background-color: #2b2b2b;
125
+ alternate-background-color: #323232;
126
+ color: #e0e0e0;
127
+ gridline-color: #555;
128
+ }
129
+ QHeaderView::section {
130
+ background-color: #3c3c3c;
131
+ color: #e0e0e0;
132
+ border: 1px solid #555;
133
+ padding: 4px;
134
+ }
135
+ QTableWidget::item, QTableView::item, QTreeView::item {
136
+ color: #e0e0e0;
137
+ }
138
+ QGroupBox {
139
+ color: #e0e0e0;
140
+ border: 1px solid #555;
141
+ margin-top: 8px;
142
+ padding-top: 8px;
143
+ }
144
+ QGroupBox::title {
145
+ color: #e0e0e0;
146
+ }
147
+ QSplitter::handle {
148
+ background-color: #555;
149
+ }
150
+ QFormLayout {
151
+ background: transparent;
152
+ }
153
+ """
154
+
155
+
156
+ def _apply_dark_fix(wizard: QWidget) -> None:
157
+ """Apply a dark-theme stylesheet to the wizard if the system palette is dark.
158
+
159
+ The pymmcore-widgets ConfigWizard was designed for light themes. On
160
+ Windows 11 with dark mode, Qt applies dark backgrounds to native controls
161
+ (QComboBox, etc.) but leaves the text colour dark ⇒ invisible text.
162
+ This function detects a dark palette and overlays a comprehensive QSS fix.
163
+ """
164
+ if _is_dark_palette(wizard):
165
+ wizard.setStyleSheet(_DARK_WIZARD_QSS)
166
+
167
+ class _FilePickerRow(QWidget):
168
+ """A single row: [QLineEdit] [Browse…] [Load]."""
169
+
170
+ fileLoaded = pyqtSignal(str) # emits the chosen path after Load is pressed
171
+
172
+ def __init__(
173
+ self,
174
+ label: str = "",
175
+ file_filter: str = "All Files (*)",
176
+ placeholder: str = "",
177
+ parent: QWidget | None = None,
178
+ ):
179
+ super().__init__(parent)
180
+ self._file_filter = file_filter
181
+
182
+ layout = QHBoxLayout(self)
183
+ layout.setContentsMargins(0, 0, 0, 0)
184
+
185
+ if label:
186
+ layout.addWidget(QLabel(label))
187
+
188
+ self.line_edit = QLineEdit()
189
+ self.line_edit.setPlaceholderText(placeholder)
190
+ layout.addWidget(self.line_edit)
191
+
192
+ browse_btn = QPushButton("Browse…")
193
+ browse_btn.setFixedWidth(80)
194
+ browse_btn.clicked.connect(self._browse)
195
+ layout.addWidget(browse_btn)
196
+
197
+ self.load_btn = QPushButton("Load")
198
+ self.load_btn.setFixedWidth(60)
199
+ self.load_btn.clicked.connect(self._load)
200
+ layout.addWidget(self.load_btn)
201
+
202
+ # -- helpers -------------------------------------------------------------
203
+
204
+ def _browse(self) -> None:
205
+ path, _ = QFileDialog.getOpenFileName(
206
+ self, "Select file", "", self._file_filter
207
+ )
208
+ if path:
209
+ self.line_edit.setText(path)
210
+
211
+ def _load(self) -> None:
212
+ path = self.line_edit.text().strip()
213
+ if path and os.path.isfile(path):
214
+ self.fileLoaded.emit(path)
215
+ elif path:
216
+ QMessageBox.warning(self, "File not found", f"Cannot find:\n{path}")
217
+
218
+ def text(self) -> str:
219
+ return self.line_edit.text().strip()
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Per-camera config card
224
+ # ---------------------------------------------------------------------------
225
+
226
+ class _CameraConfigCard(QFrame):
227
+ """Displays the current .cfg status for a single MicroManager camera
228
+ and provides controls to load a different .cfg or launch the Hardware Wizard."""
229
+
230
+ cfgChanged = pyqtSignal() # emitted after a new .cfg is loaded
231
+
232
+ def __init__(
233
+ self,
234
+ cam: MMCamera,
235
+ core: CMMCorePlus,
236
+ index: int,
237
+ parent: QWidget | None = None,
238
+ ):
239
+ super().__init__(parent)
240
+ self._cam = cam
241
+ self._core = core
242
+ self.setFrameShape(QFrame.Shape.StyledPanel)
243
+
244
+ layout = QVBoxLayout(self)
245
+ layout.setContentsMargins(8, 6, 8, 6)
246
+
247
+ # Header
248
+ header = QLabel(f"<b>Camera {index + 1}:</b> {cam.name} "
249
+ f"<span style='color:gray'>({cam.id} / {cam.backend})</span>")
250
+ layout.addWidget(header)
251
+
252
+ # Status label showing which .cfg is loaded
253
+ self._status = QLabel()
254
+ layout.addWidget(self._status)
255
+
256
+ # File picker + buttons row
257
+ action_row = QHBoxLayout()
258
+ self._cfg_edit = QLineEdit()
259
+ self._cfg_edit.setPlaceholderText("Select a .cfg file…")
260
+ action_row.addWidget(self._cfg_edit)
261
+
262
+ browse_btn = QPushButton("Browse…")
263
+ browse_btn.setFixedWidth(80)
264
+ browse_btn.clicked.connect(self._browse_cfg)
265
+ action_row.addWidget(browse_btn)
266
+
267
+ load_btn = QPushButton("Load .cfg")
268
+ load_btn.setFixedWidth(80)
269
+ load_btn.clicked.connect(self._load_cfg)
270
+ action_row.addWidget(load_btn)
271
+
272
+ wizard_btn = QPushButton("🔧 Hardware Wizard…")
273
+ wizard_btn.setToolTip(
274
+ "Open the pymmcore-widgets Hardware Configuration Wizard\n"
275
+ "to inspect/edit devices, roles, delays, and labels."
276
+ )
277
+ wizard_btn.clicked.connect(self._open_hw_wizard)
278
+ action_row.addWidget(wizard_btn)
279
+
280
+ layout.addLayout(action_row)
281
+
282
+ # Initialise status from the core's current state
283
+ self._refresh_status()
284
+
285
+ # -- public --------------------------------------------------------------
286
+
287
+ def _refresh_status(self) -> None:
288
+ """Update the status label from the core's loaded config file."""
289
+ cfg_file = self._core.systemConfigurationFile() or ""
290
+ yaml_cfg_path = self._cam.cfg.get("configuration_path", "")
291
+
292
+ if yaml_cfg_path:
293
+ self._cfg_edit.setText(yaml_cfg_path)
294
+
295
+ if cfg_file:
296
+ display = os.path.basename(cfg_file)
297
+ if "MMConfig_demo" in cfg_file or cfg_file.endswith("MMConfig_demo.cfg"):
298
+ self._status.setText(
299
+ f"✔ Loaded: <b>{display}</b> "
300
+ "<span style='color:#888'>(pymmcore-plus demo default)</span>"
301
+ )
302
+ self._status.setStyleSheet(f"color: {theme.ACCENT};")
303
+ else:
304
+ self._status.setText(f"✔ Loaded: <b>{display}</b>")
305
+ self._status.setStyleSheet(f"color: {theme.ACCENT};")
306
+ else:
307
+ self._status.setText("⚠ No system configuration loaded")
308
+ self._status.setStyleSheet(f"color: {theme.WARN};")
309
+
310
+ # -- slots ---------------------------------------------------------------
311
+
312
+ def _browse_cfg(self) -> None:
313
+ path, _ = QFileDialog.getOpenFileName(
314
+ self, "Select MicroManager Configuration", "",
315
+ "MicroManager Config (*.cfg);;All Files (*)",
316
+ )
317
+ if path:
318
+ self._cfg_edit.setText(path)
319
+ self._load_cfg() # auto-load after selection
320
+
321
+ def _load_cfg(self) -> None:
322
+ path = self._cfg_edit.text().strip()
323
+ if not path:
324
+ QMessageBox.information(self, "No file", "Please select a .cfg file first.")
325
+ return
326
+ if not os.path.isfile(path):
327
+ QMessageBox.warning(self, "File not found", f"Cannot find:\n{path}")
328
+ return
329
+ try:
330
+ self._core.loadSystemConfiguration(path)
331
+ except Exception as exc:
332
+ QMessageBox.critical(
333
+ self, "Load Error",
334
+ f"Failed to load .cfg:\n\n{exc}",
335
+ )
336
+ return
337
+ self._refresh_status()
338
+ self.cfgChanged.emit()
339
+
340
+ def _open_hw_wizard(self) -> None:
341
+ """Launch the pymmcore-widgets Hardware Configuration Wizard as a popup dialog."""
342
+ try:
343
+ from pymmcore_widgets import ConfigWizard as _MMConfigWizard
344
+ except ImportError:
345
+ QMessageBox.information(
346
+ self,
347
+ "pymmcore-widgets not available",
348
+ "The Hardware Configuration Wizard requires the\n"
349
+ "pymmcore-widgets package.\n\n"
350
+ "Install it with:\n pip install pymmcore-widgets",
351
+ )
352
+ return
353
+
354
+ current_cfg = self._cfg_edit.text().strip() or ""
355
+ wizard = _MMConfigWizard(
356
+ config_file=current_cfg,
357
+ core=self._core,
358
+ parent=self.window(),
359
+ )
360
+ wizard.setWindowModality(Qt.WindowModality.ApplicationModal)
361
+ _apply_dark_fix(wizard)
362
+ result = wizard.exec() # blocks until closed
363
+
364
+ if result:
365
+ # Wizard accepted – grab the saved .cfg path and auto-load it
366
+ saved_path = wizard.field("dest_config") or ""
367
+ if saved_path and os.path.isfile(saved_path):
368
+ self._cfg_edit.setText(saved_path)
369
+ try:
370
+ self._core.loadSystemConfiguration(saved_path)
371
+ except Exception:
372
+ pass # refresh_status will show current state
373
+
374
+ # Refresh status regardless of accept/reject (wizard may have changed state)
375
+ self._refresh_status()
376
+ if result:
377
+ self.cfgChanged.emit()
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # MicroManager config section (container for camera cards)
382
+ # ---------------------------------------------------------------------------
383
+
384
+ class _MMConfigSection(QGroupBox):
385
+ """Holds a :class:`_CameraConfigCard` for each MicroManager camera.
386
+
387
+ Shows a placeholder when no cameras have been initialised yet.
388
+ """
389
+
390
+ cfgChanged = pyqtSignal()
391
+
392
+ def __init__(self, parent: QWidget | None = None):
393
+ super().__init__("MicroManager System Config (.cfg)", parent)
394
+ self._layout = QVBoxLayout(self)
395
+ self._cards: List[_CameraConfigCard] = []
396
+
397
+ self._placeholder = QLabel(
398
+ "<i>Load a hardware config first to enable MicroManager .cfg loading.</i>"
399
+ )
400
+ self._layout.addWidget(self._placeholder)
401
+
402
+ def set_cameras(self, cameras) -> None:
403
+ """Populate the section with one card per MicroManager camera."""
404
+ # Clear existing content
405
+ while self._layout.count():
406
+ item = self._layout.takeAt(0)
407
+ if item is not None:
408
+ w = item.widget()
409
+ if w is not None:
410
+ w.deleteLater()
411
+ self._cards.clear()
412
+
413
+ mm_cams = [
414
+ cam for cam in cameras
415
+ if cam.backend == "micromanager" and hasattr(cam, "core")
416
+ ]
417
+
418
+ if not mm_cams:
419
+ self._placeholder = QLabel(
420
+ "<i>No MicroManager cameras detected in hardware config.</i>"
421
+ )
422
+ self._layout.addWidget(self._placeholder)
423
+ return
424
+
425
+ for i, cam in enumerate(mm_cams):
426
+ card = _CameraConfigCard(cam, cam.core, index=i)
427
+ card.cfgChanged.connect(self.cfgChanged.emit)
428
+ self._cards.append(card)
429
+ self._layout.addWidget(card)
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Main ConfigWizard
434
+ # ---------------------------------------------------------------------------
435
+
436
+ class ConfigWizard(QWidget):
437
+ """Configuration wizard for loading experiment and hardware configs.
438
+
439
+ Signals
440
+ -------
441
+ configApplied
442
+ Emitted **after** the experiment JSON (and optionally hardware YAML)
443
+ have been successfully applied to the running :class:`Procedure`.
444
+ hardwareReady
445
+ Emitted after hardware has been initialised (cameras available).
446
+ """
447
+
448
+ configApplied = pyqtSignal()
449
+ hardwareReady = pyqtSignal()
450
+ procedureChanged = pyqtSignal(object) # emitted when a JSON declares a different Procedure subclass
451
+
452
+ _SETTINGS_KEY_JSON = "ConfigWizard/last_json"
453
+ _SETTINGS_KEY_YAML = "ConfigWizard/last_yaml"
454
+
455
+ def __init__(self, procedure: Procedure, parent: QWidget | None = None):
456
+ super().__init__(parent)
457
+ self.procedure = procedure
458
+ self._settings = QSettings("Mesofield", "Mesofield")
459
+ self._build_ui()
460
+ self._restore_recent_paths()
461
+
462
+ # If hardware is already configured, pre-populate the MM section
463
+ if self.procedure.config.hardware.is_configured:
464
+ self._mm_section.set_cameras(self.procedure.config.hardware.cameras)
465
+
466
+ # -- public API ----------------------------------------------------------
467
+
468
+ def refresh_mm_section(self) -> None:
469
+ """Re-populate the MicroManager config section from current hardware."""
470
+ cameras = self.procedure.config.hardware.cameras
471
+ self._mm_section.set_cameras(cameras)
472
+
473
+ # -- UI ------------------------------------------------------------------
474
+
475
+ def _build_ui(self) -> None:
476
+ layout = QVBoxLayout(self)
477
+
478
+ # -- Title -----------------------------------------------------------
479
+ title = QLabel("<h3>⚙ Configuration Wizard</h3>")
480
+ layout.addWidget(title)
481
+
482
+ help_text = QLabel(
483
+ "Select your experiment config (.json) to get started.\n"
484
+ "The adjacent hardware.yaml will be discovered automatically.\n"
485
+ "You can also specify a hardware YAML or MicroManager .cfg manually."
486
+ )
487
+ help_text.setWordWrap(True)
488
+ layout.addWidget(help_text)
489
+
490
+ # -- Experiment JSON -------------------------------------------------
491
+ json_group = QGroupBox("Experiment Config (.json)")
492
+ json_layout = QVBoxLayout(json_group)
493
+ self._json_picker = _FilePickerRow(
494
+ file_filter="JSON Config (*.json)",
495
+ placeholder="devcfg.json",
496
+ )
497
+ json_layout.addWidget(self._json_picker)
498
+ layout.addWidget(json_group)
499
+
500
+ # -- Hardware YAML ---------------------------------------------------
501
+ yaml_group = QGroupBox("Hardware Config (.yaml)")
502
+ yaml_layout = QVBoxLayout(yaml_group)
503
+ self._yaml_picker = _FilePickerRow(
504
+ file_filter="YAML Config (*.yaml *.yml)",
505
+ placeholder="hardware.yaml (auto-detected from JSON directory)",
506
+ )
507
+ yaml_layout.addWidget(self._yaml_picker)
508
+
509
+ # -- Rig picker: copy a canonical hardware.yaml from the rig store ---
510
+ rig_row = QHBoxLayout()
511
+ rig_row.addWidget(QLabel("Rig:"))
512
+ self._rig_combo = QComboBox()
513
+ self._rig_combo.setToolTip(
514
+ "Copy a canonical hardware.yaml from this machine's rig store "
515
+ "into the experiment directory"
516
+ )
517
+ self._populate_rig_combo()
518
+ self._rig_combo.currentTextChanged.connect(self._on_rig_selected)
519
+ rig_row.addWidget(self._rig_combo, 1)
520
+ yaml_layout.addLayout(rig_row)
521
+
522
+ self._yaml_status = QLabel("")
523
+ yaml_layout.addWidget(self._yaml_status)
524
+ layout.addWidget(yaml_group)
525
+
526
+ # -- Apply button ----------------------------------------------------
527
+ self._apply_btn = QPushButton("▶ Apply Configuration")
528
+ self._apply_btn.setStyleSheet(
529
+ "QPushButton { padding: 8px 16px; font-weight: bold; }"
530
+ )
531
+ self._apply_btn.clicked.connect(self._apply)
532
+ layout.addWidget(self._apply_btn)
533
+
534
+ # -- MicroManager .cfg -----------------------------------------------
535
+ self._mm_section = _MMConfigSection()
536
+ layout.addWidget(self._mm_section)
537
+
538
+ # -- Spacer ----------------------------------------------------------
539
+ layout.addStretch()
540
+
541
+ # -- Auto-populate YAML when JSON is picked --------------------------
542
+ self._json_picker.line_edit.textChanged.connect(self._on_json_path_changed)
543
+
544
+ # -- Recent paths persistence ---------------------------------------------
545
+
546
+ def _restore_recent_paths(self) -> None:
547
+ """Fill pickers from QSettings if the files still exist."""
548
+ last_json = self._settings.value(self._SETTINGS_KEY_JSON, "", type=str)
549
+ last_yaml = self._settings.value(self._SETTINGS_KEY_YAML, "", type=str)
550
+ if last_json and os.path.isfile(last_json):
551
+ self._json_picker.line_edit.setText(last_json)
552
+ if last_yaml and os.path.isfile(last_yaml):
553
+ self._yaml_picker.line_edit.setText(last_yaml)
554
+
555
+ def _save_recent_paths(self) -> None:
556
+ """Persist current picker values to QSettings."""
557
+ json_path = self._json_picker.text()
558
+ yaml_path = self._yaml_picker.text()
559
+ if json_path:
560
+ self._settings.setValue(self._SETTINGS_KEY_JSON, json_path)
561
+ if yaml_path:
562
+ self._settings.setValue(self._SETTINGS_KEY_YAML, yaml_path)
563
+
564
+ # -- Slots ---------------------------------------------------------------
565
+
566
+ def _populate_rig_combo(self) -> None:
567
+ """Fill the rig dropdown from the machine's rig store."""
568
+ from mesofield.scaffold import rigs
569
+
570
+ self._rig_combo.blockSignals(True)
571
+ self._rig_combo.clear()
572
+ self._rig_combo.addItem("— select rig —")
573
+ for name in rigs.list_rigs():
574
+ self._rig_combo.addItem(name)
575
+ self._rig_combo.addItem("dev (mock devices)")
576
+ # Needs a JSON destination dir before it can copy anything.
577
+ self._rig_combo.setEnabled(bool(self._json_picker.text()))
578
+ self._rig_combo.blockSignals(False)
579
+
580
+ def _on_rig_selected(self, label: str) -> None:
581
+ """Copy the chosen canonical rig into the experiment's directory."""
582
+ if not label or label.startswith("—"):
583
+ return
584
+ json_path = self._json_picker.text()
585
+ if not json_path:
586
+ self._yaml_status.setText("⚠ pick an experiment JSON first")
587
+ self._yaml_status.setStyleSheet(f"color: {theme.WARN};")
588
+ return
589
+ dest = os.path.join(os.path.dirname(os.path.abspath(json_path)), "hardware.yaml")
590
+ if os.path.exists(dest):
591
+ reply = QMessageBox.question(
592
+ self, "Overwrite hardware.yaml?",
593
+ f"{dest}\nalready exists. Overwrite it with rig '{label}'?",
594
+ )
595
+ if reply != QMessageBox.StandardButton.Yes:
596
+ self._rig_combo.setCurrentIndex(0)
597
+ return
598
+ from mesofield.scaffold import rigs
599
+ from mesofield.scaffold.experiment import _hardware_yaml_mock
600
+
601
+ try:
602
+ if label.startswith("dev"):
603
+ with open(dest, "w", encoding="utf-8") as fh:
604
+ fh.write(_hardware_yaml_mock())
605
+ else:
606
+ shutil.copyfile(rigs.rig_path(label), dest)
607
+ except Exception as exc:
608
+ QMessageBox.warning(self, "Rig copy failed", str(exc))
609
+ return
610
+ self._yaml_picker.line_edit.setText(dest)
611
+ self._yaml_status.setText(f"✔ copied rig '{label}' → hardware.yaml")
612
+ self._yaml_status.setStyleSheet(f"color: {theme.ACCENT};")
613
+
614
+ def _on_json_path_changed(self, text: str) -> None:
615
+ """When the JSON line-edit changes, try to auto-detect hardware.yaml."""
616
+ self._rig_combo.setEnabled(bool(text))
617
+ if not text:
618
+ return
619
+ candidate = os.path.join(os.path.dirname(text), "hardware.yaml")
620
+ if os.path.isfile(candidate):
621
+ self._yaml_picker.line_edit.setText(candidate)
622
+ self._yaml_status.setText("✔ hardware.yaml auto-detected")
623
+ self._yaml_status.setStyleSheet(f"color: {theme.ACCENT};")
624
+ else:
625
+ self._yaml_status.setText("⚠ hardware.yaml not found in JSON directory")
626
+ self._yaml_status.setStyleSheet(f"color: {theme.WARN};")
627
+
628
+ def _apply(self) -> None:
629
+ """Apply the selected configuration files to the Procedure."""
630
+ json_path = self._json_picker.text() or None
631
+ yaml_path = self._yaml_picker.text() or None
632
+
633
+ if not json_path and not yaml_path:
634
+ QMessageBox.information(
635
+ self,
636
+ "Nothing to load",
637
+ "Please select at least an experiment JSON or hardware YAML file.",
638
+ )
639
+ return
640
+
641
+ try:
642
+ # If the JSON declares a custom Procedure subclass that differs
643
+ # from the currently active class, instantiate the new one and
644
+ # propagate to the parent (MainWindow). Otherwise just hot-load
645
+ # the new config in place.
646
+ from mesofield.base import load_procedure_from_config
647
+
648
+ if json_path and os.path.isfile(json_path):
649
+ with open(json_path, "r", encoding="utf-8") as fh:
650
+ cfg = json.load(fh)
651
+
652
+ proc_file = cfg.get("procedure_file") if isinstance(cfg, dict) else None
653
+ proc_class = cfg.get("procedure_class") if isinstance(cfg, dict) else None
654
+
655
+ should_switch_procedure = False
656
+ if proc_file and proc_class:
657
+ json_dir = os.path.dirname(os.path.abspath(json_path))
658
+ declared_file = proc_file
659
+ if not os.path.isabs(declared_file):
660
+ declared_file = os.path.join(json_dir, declared_file)
661
+ declared_file = os.path.abspath(declared_file)
662
+
663
+ current_cls = type(self.procedure)
664
+ current_file = inspect.getsourcefile(current_cls) or inspect.getfile(current_cls) or ""
665
+ current_file = os.path.abspath(current_file) if current_file else ""
666
+
667
+ same_class_name = current_cls.__name__ == proc_class
668
+ same_file = (
669
+ bool(current_file)
670
+ and os.path.normcase(os.path.normpath(current_file))
671
+ == os.path.normcase(os.path.normpath(declared_file))
672
+ )
673
+ should_switch_procedure = not (same_class_name and same_file)
674
+
675
+ if should_switch_procedure:
676
+ candidate = load_procedure_from_config(json_path)
677
+ # The candidate's __init__ already loaded the JSON and
678
+ # initialized hardware from <json_dir>/hardware.yaml. Only
679
+ # reload if the user explicitly picked a *different* YAML;
680
+ # otherwise we'd orphan the live HardwareManager (devices
681
+ # still hold the cameras) and loop on re-initialization.
682
+ if yaml_path:
683
+ existing_yaml = getattr(
684
+ candidate.config, "_hardware_yaml_path", None
685
+ )
686
+ picked_yaml = os.path.abspath(yaml_path)
687
+ if not existing_yaml or picked_yaml != os.path.abspath(existing_yaml):
688
+ candidate.load_config(hardware_yaml_path=yaml_path)
689
+ self.procedure = candidate
690
+ self.procedureChanged.emit(candidate)
691
+ else:
692
+ self.procedure.load_config(
693
+ json_path=json_path,
694
+ hardware_yaml_path=yaml_path,
695
+ )
696
+ else:
697
+ self.procedure.load_config(
698
+ json_path=json_path,
699
+ hardware_yaml_path=yaml_path,
700
+ )
701
+ except Exception as exc:
702
+ QMessageBox.critical(
703
+ self,
704
+ "Configuration Error",
705
+ f"Failed to apply configuration:\n\n{exc}",
706
+ )
707
+ return
708
+
709
+ # Persist the selected paths for next launch
710
+ self._save_recent_paths()
711
+
712
+ # Refresh the MM config section now that cameras are available
713
+ cameras = self.procedure.config.hardware.cameras
714
+ self._mm_section.set_cameras(cameras)
715
+
716
+ self.configApplied.emit()
717
+
718
+ if self.procedure.config.hardware.is_configured:
719
+ self.hardwareReady.emit()
720
+
721
+ self._apply_btn.setText("✔ Configuration Applied")
722
+ self._apply_btn.setStyleSheet(
723
+ "QPushButton { padding: 8px 16px; font-weight: bold; color: green; }"
724
+ )