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.
- docs/_static/custom.css +40 -0
- docs/_static/favicon.png +0 -0
- docs/_static/logo.png +0 -0
- docs/api/index.md +70 -0
- docs/conf.py +200 -0
- docs/developer_guide.md +303 -0
- docs/index.md +25 -0
- docs/tutorial.md +4 -0
- docs/user_guide.md +172 -0
- examples/teensy_pulse_generator.py +320 -0
- experiments/pipeline_demo/experiment.json +24 -0
- experiments/pipeline_demo/hardware.yaml +23 -0
- experiments/pipeline_demo/procedure.py +50 -0
- experiments/two_cam_demo/experiment.json +24 -0
- experiments/two_cam_demo/hardware.yaml +58 -0
- experiments/two_cam_demo/load_dataset.py +213 -0
- experiments/two_cam_demo/procedure.py +87 -0
- external/video-codecs/openh264-1.8.0-win64.dll +0 -0
- mesofield/__init__.py +45 -0
- mesofield/__main__.py +11 -0
- mesofield/_version.py +24 -0
- mesofield/base.py +750 -0
- mesofield/cli/__init__.py +57 -0
- mesofield/cli/_richhelp.py +100 -0
- mesofield/cli/acquire.py +254 -0
- mesofield/cli/datakit.py +165 -0
- mesofield/cli/process.py +376 -0
- mesofield/cli/rig.py +108 -0
- mesofield/cli/tools.py +347 -0
- mesofield/config.py +751 -0
- mesofield/data/__init__.py +23 -0
- mesofield/data/batch.py +633 -0
- mesofield/data/manager.py +388 -0
- mesofield/data/writer.py +289 -0
- mesofield/datakit/__init__.py +44 -0
- mesofield/datakit/__main__.py +35 -0
- mesofield/datakit/_utils/_logger.py +5 -0
- mesofield/datakit/_version.py +141 -0
- mesofield/datakit/config.py +50 -0
- mesofield/datakit/core.py +783 -0
- mesofield/datakit/datamodel.py +200 -0
- mesofield/datakit/discover.py +124 -0
- mesofield/datakit/explore.py +651 -0
- mesofield/datakit/notebooks/pupil_dlc.ipynb +2445 -0
- mesofield/datakit/profile.py +535 -0
- mesofield/datakit/shell.py +83 -0
- mesofield/datakit/sources/__init__.py +65 -0
- mesofield/datakit/sources/analysis/mesomap.py +194 -0
- mesofield/datakit/sources/analysis/mesoscope.py +77 -0
- mesofield/datakit/sources/analysis/pupil.py +246 -0
- mesofield/datakit/sources/behavior/__init__.py +0 -0
- mesofield/datakit/sources/behavior/dataqueue.py +281 -0
- mesofield/datakit/sources/behavior/psychopy.py +364 -0
- mesofield/datakit/sources/behavior/treadmill.py +323 -0
- mesofield/datakit/sources/behavior/wheel.py +277 -0
- mesofield/datakit/sources/camera/mesoscope.py +32 -0
- mesofield/datakit/sources/camera/metadata_json.py +130 -0
- mesofield/datakit/sources/camera/pupil.py +28 -0
- mesofield/datakit/sources/camera/suite2p.py +547 -0
- mesofield/datakit/sources/register.py +204 -0
- mesofield/datakit/sources/session/config.py +130 -0
- mesofield/datakit/sources/session/notes.py +63 -0
- mesofield/datakit/sources/session/timestamps.py +58 -0
- mesofield/datakit/timeline.py +306 -0
- mesofield/devices/__init__.py +42 -0
- mesofield/devices/base.py +498 -0
- mesofield/devices/base_camera.py +295 -0
- mesofield/devices/cameras.py +740 -0
- mesofield/devices/daq.py +151 -0
- mesofield/devices/encoder.py +384 -0
- mesofield/devices/mocks.py +275 -0
- mesofield/devices/psychopy_device.py +455 -0
- mesofield/devices/subprocesses/__init__.py +0 -0
- mesofield/devices/subprocesses/psychopy.py +133 -0
- mesofield/devices/treadmill.py +318 -0
- mesofield/engines.py +380 -0
- mesofield/gui/Mesofield_icon.png +0 -0
- mesofield/gui/__init__.py +76 -0
- mesofield/gui/config_wizard.py +724 -0
- mesofield/gui/controller.py +535 -0
- mesofield/gui/dynamic_controller.py +78 -0
- mesofield/gui/maingui.py +427 -0
- mesofield/gui/mdagui.py +285 -0
- mesofield/gui/qt_device_adapter.py +109 -0
- mesofield/gui/speedplotter.py +152 -0
- mesofield/gui/theme.py +445 -0
- mesofield/gui/tiff_viewer.py +1050 -0
- mesofield/gui/viewer.py +691 -0
- mesofield/hardware.py +549 -0
- mesofield/playback.py +1298 -0
- mesofield/processing/__init__.py +12 -0
- mesofield/processing/runner.py +237 -0
- mesofield/processors/__init__.py +13 -0
- mesofield/processors/base.py +287 -0
- mesofield/processors/frame_mean.py +19 -0
- mesofield/protocols.py +378 -0
- mesofield/scaffold/__init__.py +34 -0
- mesofield/scaffold/experiment.py +400 -0
- mesofield/scaffold/rigs.py +121 -0
- mesofield/signals.py +85 -0
- mesofield/utils/__init__.py +0 -0
- mesofield/utils/_logger.py +156 -0
- mesofield/utils/retrofit.py +309 -0
- mesofield/utils/utils.py +217 -0
- mesofield-0.3.2b0.dist-info/METADATA +178 -0
- mesofield-0.3.2b0.dist-info/RECORD +111 -0
- mesofield-0.3.2b0.dist-info/WHEEL +5 -0
- mesofield-0.3.2b0.dist-info/entry_points.txt +2 -0
- mesofield-0.3.2b0.dist-info/licenses/LICENSE +21 -0
- mesofield-0.3.2b0.dist-info/top_level.txt +6 -0
- 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
|