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,535 @@
1
+ import os
2
+ from datetime import datetime
3
+ from contextlib import suppress
4
+ import threading
5
+
6
+ from qtpy.QtCore import Qt
7
+ from PyQt6.QtCore import pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QHBoxLayout,
10
+ QLabel,
11
+ QVBoxLayout,
12
+ QWidget,
13
+ QLineEdit,
14
+ QPushButton,
15
+ QComboBox,
16
+ QMessageBox,
17
+ QInputDialog,
18
+ QStyle,
19
+ QFormLayout,
20
+ QSpinBox,
21
+ QCheckBox,
22
+ )
23
+ from PyQt6.QtGui import QIcon
24
+ from qtpy.QtGui import QDesktopServices
25
+ from qtpy.QtCore import QUrl
26
+
27
+ from typing import TYPE_CHECKING
28
+ if TYPE_CHECKING:
29
+ from mesofield.config import ExperimentConfig
30
+ from mesofield.protocols import Procedure
31
+
32
+ from .dynamic_controller import DynamicController
33
+
34
+ class ConfigFormWidget(QWidget):
35
+ """Map each config key to an appropriate editor in a form layout."""
36
+
37
+ @staticmethod
38
+ def _text_for_editor(key: str, value) -> str:
39
+ if value is None:
40
+ return ""
41
+ if key == "led_pattern" and isinstance(value, list):
42
+ return "".join(str(v) for v in value)
43
+ return str(value)
44
+
45
+ def _commit_text_editor(self, key: str, editor: QLineEdit) -> None:
46
+ text = editor.text().strip() if key == "led_pattern" else editor.text()
47
+ try:
48
+ self._registry.set(key, text)
49
+ except (TypeError, ValueError) as e:
50
+ QMessageBox.warning(self, "Invalid value", f"{key}: {e}")
51
+ editor.setText(self._text_for_editor(key, self._registry.get(key)))
52
+
53
+ def __init__(self, registry, keys=None):
54
+ super().__init__()
55
+ self._registry = registry
56
+ form = QFormLayout(self)
57
+ if keys is None:
58
+ keys = self._registry.keys()
59
+ self._keys = list(keys)
60
+ # create editor per config key with initial values and two-way binding
61
+ for key in self._keys:
62
+ type_hint = self._registry.get_metadata(key).get("type")
63
+ value = self._registry.get(key)
64
+ choices = self._registry.get_choices(key)
65
+ if key == "session" and not choices:
66
+ # Session is a zero-padded BIDS string (e.g. "02"). Edit it as
67
+ # a stepper while preserving the "%02d" string format on commit.
68
+ # When choices are registered (playback mode lists the recorded
69
+ # sessions on disk) fall through to the dropdown branch below.
70
+ editor = QSpinBox()
71
+ editor.setRange(0, 999)
72
+ try:
73
+ editor.setValue(int(value))
74
+ except (TypeError, ValueError):
75
+ editor.setValue(0)
76
+ editor.valueChanged.connect(
77
+ lambda val, k=key: self._registry.set(k, f"{val:02d}")
78
+ )
79
+ elif choices and key != "led_pattern":
80
+ # Key has registered choices — render a dropdown
81
+ editor = QComboBox()
82
+ editor.addItems([str(c) for c in choices])
83
+ current = str(value) if value is not None else ""
84
+ idx = editor.findText(current)
85
+ if idx >= 0:
86
+ editor.setCurrentIndex(idx)
87
+ editor.currentTextChanged.connect(lambda text, k=key: self._registry.set(k, text))
88
+ elif type_hint is int:
89
+ editor = QSpinBox()
90
+ editor.setRange(-1_000_000, 1_000_000)
91
+ editor.setValue(int(value or 0))
92
+ editor.valueChanged.connect(lambda val, k=key: self._registry.set(k, val))
93
+ elif type_hint is bool:
94
+ editor = QCheckBox()
95
+ editor.setChecked(bool(value))
96
+ editor.toggled.connect(lambda checked, k=key: self._registry.set(k, checked))
97
+ else:
98
+ editor = QLineEdit()
99
+ editor.setText(self._text_for_editor(key, value))
100
+ if key == "led_pattern":
101
+ editor.setPlaceholderText("e.g. 422222442 or [\"4\",\"2\",\"2\"]")
102
+ editor.editingFinished.connect(lambda k=key, e=editor: self._commit_text_editor(k, e))
103
+ form.addRow(key, editor)
104
+ self.config_hint_text = QLabel("<i>Values persist upon Procedure completion or with the \"Save\" button</i>")
105
+ form.addRow(self.config_hint_text)
106
+
107
+
108
+ @property
109
+ def keys(self):
110
+ """Return the list of registry keys displayed in the form."""
111
+ return list(self._keys)
112
+
113
+
114
+ class ConfigController(QWidget):
115
+ """
116
+ The ConfigController widget displays subject parameters loaded from the
117
+ experiment JSON and allows selection of the current subject.
118
+
119
+ The object connects to the Micro-Manager Core object instances and the Config object.
120
+
121
+ The ConfigController widget emits signals to notify other widgets when the configuration is updated
122
+ and when the record button is pressed.
123
+
124
+ Public Methods:
125
+ ----------------
126
+ record():
127
+ triggers the MDA sequence with the configuration parameters
128
+
129
+
130
+ Private Methods:
131
+ ----------------
132
+ _load_subject():
133
+ updates the configuration form for the selected subject
134
+ _test_led():
135
+ tests the LED pattern by sending a test sequence to the Arduino-Switch device
136
+ _stop_led():
137
+ stops the LED pattern by sending a stop sequence to the Arduino-Switch device
138
+ _add_note():
139
+ opens a dialog to get a note from the user and save it to the ExperimentConfig.notes list
140
+
141
+ """
142
+ # ==================================== Signals ===================================== #
143
+ configUpdated = pyqtSignal(object)
144
+ recordStarted = pyqtSignal(str)
145
+ # ------------------------------------------------------------------------------------- #
146
+ def __init__(self, procedure: 'Procedure', display_keys=None):
147
+ super().__init__()
148
+ self.config: ExperimentConfig = procedure.config
149
+ self.procedure = procedure
150
+ if display_keys is None and hasattr(self.config, "display_keys"):
151
+ display_keys = self.config.display_keys
152
+ self.display_keys = list(display_keys) if display_keys is not None else None
153
+
154
+ # Create main layout
155
+ layout = QVBoxLayout(self)
156
+ self.setFixedWidth(500)
157
+
158
+ # ==================================== GUI Widgets ===================================== #
159
+ # Button to open the BIDS directory in the system file explorer
160
+ self.open_bids_button = QPushButton("Open BIDS Directory")
161
+ layout.addWidget(self.open_bids_button)
162
+ self.open_bids_button.setToolTip("Open the procedure.config.bids_dir in your file explorer")
163
+
164
+ # subject selection dropdown
165
+ self.subject_dropdown_label = QLabel('Select Subject:')
166
+ self.subject_dropdown = QComboBox()
167
+ self.add_subject_button = QPushButton("+ Subject")
168
+ self.add_subject_button.setToolTip("Add a new subject to experiment.json")
169
+ self.add_parameter_button = QPushButton("+ Parameter")
170
+ self.add_parameter_button.setToolTip(
171
+ "Add a new parameter applied to every subject and made editable in DisplayKeys"
172
+ )
173
+ sub_layout = QHBoxLayout()
174
+ sub_layout.addWidget(self.subject_dropdown_label)
175
+ sub_layout.addWidget(self.subject_dropdown)
176
+ sub_layout.addWidget(self.add_subject_button)
177
+ sub_layout.addWidget(self.add_parameter_button)
178
+ layout.addLayout(sub_layout)
179
+
180
+ # BIDS filename preview — updates live as subject/session/task change
181
+ self.filename_preview_label = QLabel()
182
+ self.filename_preview_label.setWordWrap(True)
183
+ self.filename_preview_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
184
+ from mesofield.gui import theme
185
+ self.filename_preview_label.setStyleSheet(
186
+ f"color: {theme.TEXT_DIM}; font-family: {theme.MONO_FONT};"
187
+ )
188
+ layout.addWidget(self.filename_preview_label)
189
+
190
+ self.config_model = ConfigFormWidget(self.procedure.config, keys=self.display_keys)
191
+
192
+ self._populate_subjects()
193
+ self._change_subject(0)
194
+
195
+ # Register live updates for the filename preview. Callbacks fire from
196
+ # ConfigRegister.set() — which is what ConfigFormWidget editors call.
197
+ for key in ("subject", "session", "task"):
198
+ self.config.register_callback(key, lambda _k, _v: self._update_filename_preview())
199
+ self._update_filename_preview()
200
+
201
+ # 4. Record button to start the MDA sequence
202
+ self.record_button = QPushButton("Record")
203
+
204
+ # Tint the standard play icon red
205
+ play_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)
206
+ pix = play_icon.pixmap(24, 24)
207
+ mask = pix.createMaskFromColor(Qt.transparent)
208
+ pix.fill(Qt.GlobalColor.red)
209
+ pix.setMask(mask)
210
+ self.record_button.setIcon(QIcon(pix))
211
+
212
+ from mesofield.gui import theme
213
+ self.record_button.setStyleSheet(theme.record_button_qss())
214
+ self.record_button.setToolTip("Start Recording (MDA Sequence)")
215
+ self.record_button.setShortcut("Ctrl+R") # Set shortcut for recording
216
+
217
+ # 5. Abort button to safely stop a running Procedure
218
+ self.abort_button = QPushButton("Abort")
219
+ stop_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)
220
+ self.abort_button.setIcon(stop_icon)
221
+ self.abort_button.setToolTip("Abort the running Procedure (safe stop + save)")
222
+ self.abort_button.setEnabled(False)
223
+
224
+ # 6. Add Note button to add a note to the configuration
225
+ self.add_note_button = QPushButton("Add Note")
226
+
227
+ # 7. Save button to persist edits to experiment.json on demand
228
+ self.save_button = QPushButton("Save")
229
+ save_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton)
230
+ self.save_button.setIcon(save_icon)
231
+ self.save_button.setToolTip("Save edits to experiment.json now")
232
+
233
+ # Group the Record, Abort, Add Note and Save buttons horizontally
234
+ self._action_buttons_layout = QHBoxLayout()
235
+ self._action_buttons_layout.addWidget(self.record_button)
236
+ self._action_buttons_layout.addWidget(self.abort_button)
237
+ self._action_buttons_layout.addWidget(self.add_note_button)
238
+ self._action_buttons_layout.addWidget(self.save_button)
239
+ layout.addLayout(self._action_buttons_layout)
240
+
241
+ # Absorb extra vertical space here so the form + primary action buttons
242
+ # stay anchored to the top, and hardware-specific controls stay pinned
243
+ # to the bottom edge regardless of window height.
244
+ layout.addStretch(1)
245
+
246
+ # Dynamic hardware-specific controls (pinned to bottom of the panel)
247
+ self.dynamic_controller = DynamicController(self.procedure.config, parent=self)
248
+ layout.addWidget(self.dynamic_controller)
249
+ # ------------------------------------------------------------------------------------- #
250
+
251
+ # ============ Callback connections between widget values and functions ================ #
252
+
253
+ self.subject_dropdown.currentIndexChanged.connect(self._change_subject) # When the subject is changed, update the config form
254
+ self.record_button.clicked.connect(self.record)
255
+ self.abort_button.clicked.connect(self._abort)
256
+ self.add_note_button.clicked.connect(self._add_note)
257
+ self.add_subject_button.clicked.connect(self._add_subject)
258
+ self.add_parameter_button.clicked.connect(self._add_parameter)
259
+ self.save_button.clicked.connect(self._save_config)
260
+ self.open_bids_button.clicked.connect(self._open_bids_directory)
261
+
262
+ # Toggle Record/Abort availability from the live procedure lifecycle.
263
+ # Bound-method connections auto-disconnect when this widget (a
264
+ # QObject) is destroyed on a config reload.
265
+ events = getattr(self.procedure, "events", None)
266
+ if events is not None:
267
+ events.procedure_started.connect(self._on_run_started)
268
+ events.procedure_finished.connect(self._on_run_finished)
269
+ events.procedure_error.connect(self._on_run_finished)
270
+
271
+ # Connect dynamic controls using constants defined in DynamicController.
272
+ # Snap and PsychoPy launch are handled elsewhere (CameraButtons in
273
+ # mdagui.py, PsychoPyDevice.arm() respectively).
274
+ dynamic_buttons = [
275
+ (DynamicController.LED_TEST_BTN, self._test_led),
276
+ (DynamicController.STOP_BTN, self._stop_led),
277
+ (DynamicController.NIDAQ_BTN, self._test_nidaq),
278
+ ]
279
+ for btn_attr, handler in dynamic_buttons:
280
+ if hasattr(self.dynamic_controller, btn_attr):
281
+ getattr(self.dynamic_controller, btn_attr).clicked.connect(handler)
282
+
283
+ # ------------------------------------------------------------------------------------- #
284
+ # ------------------------------- Introspection Helpers --------------------------- #
285
+ def displayed_values(self) -> dict:
286
+ """Return the configuration values currently shown in the form widget."""
287
+ if not hasattr(self, "config_model"):
288
+ return {}
289
+ return {k: self.config.get(k) for k in self.config_model.keys}
290
+
291
+ def set_display_keys(self, keys=None):
292
+ """Set which configuration keys should be displayed in the form."""
293
+ self.display_keys = list(keys) if keys is not None else None
294
+ old_form = getattr(self, 'config_model', None)
295
+ if old_form:
296
+ new_form = ConfigFormWidget(self.config, keys=self.display_keys)
297
+ self.config_model = new_form
298
+ layout = self.layout()
299
+ idx = layout.indexOf(old_form)
300
+ layout.insertWidget(idx, new_form)
301
+ layout.removeWidget(old_form)
302
+ old_form.deleteLater()
303
+
304
+
305
+ # ============================== Public Class Methods ============================================ #
306
+
307
+ def record(self):
308
+ """Run the experimental procedure or fallback to legacy MDA sequence."""
309
+ self._stop_live_streams()
310
+ # If a procedure is available, use it for the experimental workflow
311
+ if self.procedure is not None:
312
+ try:
313
+ # Run the procedure in a separate thread to avoid blocking the GUI
314
+ self.procedure_thread = threading.Thread(target=self.procedure.run())
315
+ self.procedure_thread.start()
316
+
317
+ # Signal that recording has started
318
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
319
+ self.recordStarted.emit(timestamp)
320
+ return
321
+ except Exception as e:
322
+ QMessageBox.critical(self, "Procedure Error", f"Failed to run procedure: {str(e)}")
323
+ return
324
+
325
+ def _abort(self):
326
+ """Safely stop the running Procedure (stops hardware, saves data)."""
327
+ if self.procedure is None:
328
+ return
329
+ self.abort_button.setEnabled(False)
330
+ try:
331
+ self.procedure.cleanup()
332
+ except Exception as e:
333
+ QMessageBox.critical(self, "Abort Error", f"Failed to abort procedure: {e}")
334
+
335
+ def _on_run_started(self, *_args) -> None:
336
+ self.record_button.setEnabled(False)
337
+ self.abort_button.setEnabled(True)
338
+
339
+ def _on_run_finished(self, *_args) -> None:
340
+ self.record_button.setEnabled(True)
341
+ self.abort_button.setEnabled(False)
342
+
343
+ def _stop_live_streams(self) -> None:
344
+ """Ensure any live/sequence streams are halted before starting acquisition."""
345
+ cores = getattr(self.config, "_cores", ())
346
+ for core in cores:
347
+ with suppress(Exception):
348
+ if hasattr(core, "isSequenceRunning") and core.isSequenceRunning():
349
+ core.stopSequenceAcquisition()
350
+
351
+ #-----------------------------------------------------------------------------------------------#
352
+
353
+ #============================== Private Class Methods ==========================================#
354
+
355
+ def _open_bids_directory(self):
356
+ """Open the BIDS directory in the system file explorer."""
357
+ path = self.config.bids_dir
358
+ if not path or not os.path.isdir(path):
359
+ QMessageBox.warning(self, "Warning", "No BIDS directory selected or it does not exist.")
360
+ return
361
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
362
+
363
+ def _populate_subjects(self):
364
+ self.subject_dropdown.clear()
365
+ for sub in self.config.subjects.keys():
366
+ self.subject_dropdown.addItem(sub)
367
+
368
+ def _change_subject(self, index):
369
+ subject_id = self.subject_dropdown.currentText()
370
+ if not subject_id:
371
+ return
372
+ try:
373
+ self.config.select_subject(subject_id)
374
+ except Exception as e:
375
+ print(e)
376
+ return
377
+
378
+ old_form = getattr(self, 'config_model', None)
379
+ new_form = ConfigFormWidget(self.config, keys=self.display_keys)
380
+ self.config_model = new_form
381
+ if old_form:
382
+ layout = self.layout()
383
+ idx = layout.indexOf(old_form)
384
+ layout.insertWidget(idx, new_form)
385
+ layout.removeWidget(old_form)
386
+ old_form.deleteLater()
387
+ self._update_filename_preview()
388
+
389
+ def _update_filename_preview(self):
390
+ """Render the BIDS filename template for the currently selected subject."""
391
+ if not hasattr(self, "filename_preview_label"):
392
+ return
393
+ subject = self.config.get("subject") or "?"
394
+ session = self.config.get("session") or "?"
395
+ task = self.config.get("task") or "?"
396
+ template = (
397
+ f"Filename template: "
398
+ f"YYYYMMDD_HHMMSS_sub-{subject}_ses-{session}_task-{task}_<suffix>.<ext>"
399
+ )
400
+ self.filename_preview_label.setText(template)
401
+
402
+ def _add_subject(self):
403
+ """Prompt for a new subject ID, add it to the config, and select it."""
404
+ subject_id, ok = QInputDialog.getText(self, "Add Subject", "Subject ID:")
405
+ if not ok or not subject_id.strip():
406
+ return
407
+ try:
408
+ self.config.add_subject(subject_id.strip())
409
+ except ValueError as e:
410
+ QMessageBox.warning(self, "Add Subject", str(e))
411
+ return
412
+
413
+ self.subject_dropdown.blockSignals(True)
414
+ self._populate_subjects()
415
+ self.subject_dropdown.blockSignals(False)
416
+ idx = self.subject_dropdown.findText(subject_id.strip())
417
+ if idx >= 0:
418
+ self.subject_dropdown.setCurrentIndex(idx)
419
+ else:
420
+ self._change_subject(0)
421
+
422
+ def _add_parameter(self):
423
+ """Prompt for a new parameter (name, type, default) and apply to all subjects."""
424
+ name, ok = QInputDialog.getText(self, "Add Parameter", "Parameter name:")
425
+ if not ok or not name.strip():
426
+ return
427
+ name = name.strip()
428
+
429
+ type_label, ok = QInputDialog.getItem(
430
+ self, "Add Parameter", f"Type for '{name}':", ["str", "int", "bool"], 0, False
431
+ )
432
+ if not ok:
433
+ return
434
+
435
+ if type_label == "int":
436
+ value, ok = QInputDialog.getInt(self, "Add Parameter", f"Default value for '{name}':", 0)
437
+ if not ok:
438
+ return
439
+ default = int(value)
440
+ type_hint = int
441
+ elif type_label == "bool":
442
+ choice, ok = QInputDialog.getItem(
443
+ self, "Add Parameter", f"Default value for '{name}':", ["False", "True"], 0, False
444
+ )
445
+ if not ok:
446
+ return
447
+ default = choice == "True"
448
+ type_hint = bool
449
+ else:
450
+ text, ok = QInputDialog.getText(self, "Add Parameter", f"Default value for '{name}':")
451
+ if not ok:
452
+ return
453
+ default = text
454
+ type_hint = str
455
+
456
+ try:
457
+ self.config.add_parameter(name, default, type_hint)
458
+ except ValueError as e:
459
+ QMessageBox.warning(self, "Add Parameter", str(e))
460
+ return
461
+
462
+ self.set_display_keys(self.config.display_keys)
463
+
464
+ def _save_config(self):
465
+ """Persist current displayed values to experiment.json on demand."""
466
+ path = getattr(self.config, "_json_file_path", "")
467
+ if not path or not os.path.isfile(path):
468
+ QMessageBox.warning(self, "Save", "No experiment.json is loaded.")
469
+ return
470
+ try:
471
+ self.config.save_json()
472
+ except Exception as e:
473
+ QMessageBox.critical(self, "Save", f"Failed to save: {e}")
474
+ return
475
+ self.save_button.setToolTip(
476
+ f"Saved {datetime.now().strftime('%H:%M:%S')} — {path}"
477
+ )
478
+
479
+ def _test_led(self):
480
+ """
481
+ Test the LED pattern by sending a test sequence to the Arduino-Switch device.
482
+ """
483
+ try:
484
+ led_pattern = self.config.led_pattern
485
+ cam = self.config.hardware.Dhyana
486
+ if hasattr(cam, "start_led_sequence"):
487
+ cam.start_led_sequence(led_pattern)
488
+ else:
489
+ cam.core.getPropertyObject('Arduino-Switch', 'State').loadSequence(led_pattern)
490
+ cam.core.getPropertyObject('Arduino-Switch', 'State').setValue(4) # seems essential to initiate serial communication
491
+ cam.core.getPropertyObject('Arduino-Switch', 'State').startSequence()
492
+ print("LED test pattern sent successfully.")
493
+ except Exception as e:
494
+ print(f"Error testing LED pattern: {e}")
495
+
496
+ def _stop_led(self):
497
+ """
498
+ Stop the LED pattern by sending a stop sequence to the Arduino-Switch device.
499
+ """
500
+ try:
501
+ cam = self.config.hardware.Dhyana
502
+ if hasattr(cam, "stop_led_sequence"):
503
+ cam.stop_led_sequence()
504
+ else:
505
+ cam.core.getPropertyObject('Arduino-Switch', 'State').stopSequence()
506
+ print("LED test pattern stopped successfully.")
507
+ except Exception as e:
508
+ print(f"Error stopping LED pattern: {e}")
509
+
510
+ def _add_note(self):
511
+ """
512
+ Open a dialog to get a note from the user, save it to the
513
+ ExperimentConfig.notes list, and push it onto the live DataQueue so it
514
+ is timestamped into dataqueue.csv.
515
+ """
516
+ now = datetime.now()
517
+ time = now.strftime("%Y-%m-%d %H:%M:%S")
518
+ text, ok = QInputDialog.getText(self, 'Add Note', 'Enter your note:')
519
+ if ok and text:
520
+ note_with_timestamp = f"{time}: {text}"
521
+ self.config.notes.append(note_with_timestamp)
522
+ data_manager = getattr(self.procedure, "data", None)
523
+ if data_manager is not None and getattr(data_manager, "queue", None) is not None:
524
+ data_manager.queue.push("notes", text, timestamp=now)
525
+
526
+ def _test_nidaq(self):
527
+ """
528
+ PUlse the nidaq device to test its functionality.
529
+ """
530
+ self.procedure.config.hardware.get_device('nidaq').start()
531
+ # ----------------------------------------------------------------------------------------------- #
532
+
533
+
534
+
535
+
@@ -0,0 +1,78 @@
1
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton
2
+
3
+ class DynamicController(QWidget):
4
+ # TODO: For now, widget attribute name constants for easier refactoring
5
+ # Should be moved to a separate file or class or made global
6
+ LED_TEST_BTN = 'led_test_button'
7
+ STOP_BTN = 'stop_btn'
8
+ SNAP_BTN = 'snap_btn'
9
+ PSYCHOPY_BTN = 'psychopy_btn'
10
+ NIDAQ_BTN = 'nidaq_btn'
11
+ """
12
+ A dynamic controller that loads and arranges GUI components based on hardware features.
13
+ """
14
+ def __init__(self, config, parent=None):
15
+ super().__init__(parent)
16
+ self.config = config
17
+ self.main_layout = QVBoxLayout(self)
18
+
19
+ # Initialize device-specific component registry:
20
+ # maps feature key -> (factory method, layout section)
21
+ self._component_registry = {
22
+ 'led_control': (self._create_led_controls, 'buttons'),
23
+ 'camera_snap': (self._create_snap_control, 'buttons'),
24
+ 'psychopy': (self._create_psychopy_controls, 'buttons'),
25
+ 'nidaq_test': (self._create_nidaq_controls, 'buttons'),
26
+ # more mappings as needed are added here
27
+ }
28
+
29
+ self._sections = {
30
+ 'buttons': QHBoxLayout(),
31
+ # examples:
32
+ # 'dropdowns': QVBoxLayout(),
33
+ # 'tables': QVBoxLayout(), etc.
34
+ }
35
+ for section_layout in self._sections.values():
36
+ self.main_layout.addLayout(section_layout)
37
+
38
+ self._load_components()
39
+
40
+ def _load_components(self):
41
+ """
42
+ Instantiate and place components based on the aggregated widget list
43
+ provided by HardwareManager.
44
+ """
45
+ # Flattened canonical list of widget keys
46
+ for widget in getattr(self.config.hardware, 'widgets', []):
47
+ if widget in self._component_registry:
48
+ fn, section = self._component_registry[widget]
49
+ fn(self._sections[section])
50
+
51
+ def _create_led_controls(self, layout):
52
+ """Create LED test and stop buttons."""
53
+ test_btn = QPushButton("Test LED")
54
+ stop_btn = QPushButton("Stop LED")
55
+ # Expose buttons via constants
56
+ setattr(self, self.LED_TEST_BTN, test_btn)
57
+ setattr(self, self.STOP_BTN, stop_btn)
58
+ layout.addWidget(test_btn)
59
+ layout.addWidget(stop_btn)
60
+
61
+ def _create_snap_control(self, layout):
62
+ """Create camera snap button."""
63
+ snap_btn = QPushButton("Snap Image")
64
+ setattr(self, self.SNAP_BTN, snap_btn)
65
+ layout.addWidget(snap_btn)
66
+
67
+ def _create_psychopy_controls(self, layout):
68
+ """Create PsychoPy launch button."""
69
+ psychopy_btn = QPushButton("Launch PsychoPy")
70
+ setattr(self, self.PSYCHOPY_BTN, psychopy_btn)
71
+ layout.addWidget(psychopy_btn)
72
+
73
+ def _create_nidaq_controls(self, layout):
74
+ """Create NIDAQ control button."""
75
+ nidaq_btn = QPushButton("NIDAQ Digital Pulse")
76
+ setattr(self, self.NIDAQ_BTN, nidaq_btn)
77
+ layout.addWidget(nidaq_btn)
78
+ # Additional factory methods can be added here