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
mesofield/gui/maingui.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Necessary modules for the IPython console
|
|
2
|
+
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
|
3
|
+
from qtconsole.inprocess import QtInProcessKernelManager
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtWidgets import (
|
|
6
|
+
QMainWindow,
|
|
7
|
+
QWidget,
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QVBoxLayout,
|
|
10
|
+
QTabWidget,
|
|
11
|
+
QLayout,
|
|
12
|
+
QToolBar,
|
|
13
|
+
QSizePolicy,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from PyQt6.QtGui import QAction
|
|
17
|
+
from PyQt6.QtCore import QCoreApplication, Qt
|
|
18
|
+
|
|
19
|
+
from mesofield.gui.mdagui import MDA
|
|
20
|
+
from mesofield.gui.controller import ConfigController
|
|
21
|
+
from mesofield.gui.speedplotter import SerialWidget
|
|
22
|
+
from mesofield.gui.config_wizard import ConfigWizard
|
|
23
|
+
from mesofield.config import ExperimentConfig
|
|
24
|
+
from mesofield.protocols import Procedure
|
|
25
|
+
|
|
26
|
+
class MainWindow(QMainWindow):
|
|
27
|
+
def __init__(self, procedure: Procedure):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.procedure = procedure
|
|
30
|
+
self.display_keys = self.procedure.config.display_keys
|
|
31
|
+
self.setWindowTitle("Mesofield")
|
|
32
|
+
|
|
33
|
+
#============================== Always-available widgets =============================#
|
|
34
|
+
self.config_wizard = ConfigWizard(self.procedure)
|
|
35
|
+
self.initialize_console(self.procedure)
|
|
36
|
+
#--------------------------------------------------------------------#
|
|
37
|
+
|
|
38
|
+
#============================== Toolbar ================================#
|
|
39
|
+
self._toolbar = QToolBar("Hardware")
|
|
40
|
+
self._toolbar.setMovable(False)
|
|
41
|
+
# Disable the toolbar's context menu so users cannot hide it via
|
|
42
|
+
# QMainWindow's default "Toolbars" toggle popup.
|
|
43
|
+
self._toolbar.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu)
|
|
44
|
+
self._toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
|
|
45
|
+
self.addToolBar(self._toolbar)
|
|
46
|
+
self._prop_browsers: list = [] # open PropertyBrowser dialogs
|
|
47
|
+
self._prop_actions: list = [] # QAction objects for property browsers
|
|
48
|
+
#--------------------------------------------------------------------#
|
|
49
|
+
|
|
50
|
+
#=========================== Toolbar action ==========================#
|
|
51
|
+
# Place frequently used tools on the main hardware toolbar.
|
|
52
|
+
self._act_tiff_viewer = QAction("TIFF Viewer\u2026", self)
|
|
53
|
+
self._act_tiff_viewer.setToolTip(
|
|
54
|
+
"Open the TIFF ROI viewer (read-only; refuses files in the active recording)."
|
|
55
|
+
)
|
|
56
|
+
self._act_tiff_viewer.triggered.connect(self._open_tiff_viewer)
|
|
57
|
+
self._toolbar.addAction(self._act_tiff_viewer)
|
|
58
|
+
self._tiff_viewer = None # keep a reference so the window isn't GC'd
|
|
59
|
+
#--------------------------------------------------------------------#
|
|
60
|
+
|
|
61
|
+
#============================== Layout ==============================#
|
|
62
|
+
central_widget = QWidget()
|
|
63
|
+
self.main_layout = QVBoxLayout(central_widget)
|
|
64
|
+
self.setCentralWidget(central_widget)
|
|
65
|
+
self.main_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
|
|
66
|
+
|
|
67
|
+
# Build a tab widget (always present). Pin its horizontal size so the
|
|
68
|
+
# ConfigController tab (fixed width) doesn't get stretched when the
|
|
69
|
+
# main window is enlarged — extra horizontal space goes to the MDA
|
|
70
|
+
# acquisition view on the left instead.
|
|
71
|
+
self.right_tabs = QTabWidget()
|
|
72
|
+
self.right_tabs.addTab(self.config_wizard, "⚙ Setup")
|
|
73
|
+
self.right_tabs.addTab(self.console_widget, "Terminal")
|
|
74
|
+
self.right_tabs.setSizePolicy(
|
|
75
|
+
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# The top row layout will hold [acquisition_gui | right_tabs]
|
|
79
|
+
self._top_row = QHBoxLayout()
|
|
80
|
+
self._mda_layout = QVBoxLayout()
|
|
81
|
+
# Give all extra width to the MDA column; right_tabs stays at sizeHint.
|
|
82
|
+
self._top_row.addLayout(self._mda_layout, 1)
|
|
83
|
+
self._top_row.addWidget(self.right_tabs, 0)
|
|
84
|
+
self.main_layout.addLayout(self._top_row)
|
|
85
|
+
#--------------------------------------------------------------------#
|
|
86
|
+
|
|
87
|
+
# Tracking for widgets that get built after config is loaded
|
|
88
|
+
self._acquisition_gui: MDA | None = None
|
|
89
|
+
self._config_controller: ConfigController | None = None
|
|
90
|
+
self._encoder_widget: SerialWidget | None = None
|
|
91
|
+
# Widgets built from `procedure.processors` -- one SerialWidget per
|
|
92
|
+
# FrameProcessor whose `plot_enabled` is True. Tracked here so we
|
|
93
|
+
# can tear them down on a `_build_acquisition_ui` rebuild.
|
|
94
|
+
self._processor_widgets: list[SerialWidget] = []
|
|
95
|
+
|
|
96
|
+
# Connect hot-load signals
|
|
97
|
+
self.config_wizard.configApplied.connect(self._on_config_applied)
|
|
98
|
+
self.config_wizard.hardwareReady.connect(self._build_acquisition_ui)
|
|
99
|
+
self.config_wizard.procedureChanged.connect(self._on_procedure_changed)
|
|
100
|
+
|
|
101
|
+
# If hardware is already configured (e.g. config_path was passed),
|
|
102
|
+
# build the full UI immediately.
|
|
103
|
+
if self.procedure.config.hardware.is_configured:
|
|
104
|
+
self._build_acquisition_ui()
|
|
105
|
+
self._on_config_applied()
|
|
106
|
+
|
|
107
|
+
#============================== Methods =================================#
|
|
108
|
+
def toggle_console(self):
|
|
109
|
+
"""Switch to the Terminal tab."""
|
|
110
|
+
terminal_index = self.right_tabs.indexOf(self.console_widget)
|
|
111
|
+
self.right_tabs.setCurrentIndex(terminal_index)
|
|
112
|
+
|
|
113
|
+
def _open_tiff_viewer(self):
|
|
114
|
+
"""Launch the TIFF ROI viewer pre-pointed at the current experiment dir.
|
|
115
|
+
|
|
116
|
+
The viewer is given a reference to the running ``Procedure`` so it can
|
|
117
|
+
refuse to open any file inside the active recording's output directory
|
|
118
|
+
while a camera is acquiring.
|
|
119
|
+
"""
|
|
120
|
+
from mesofield.gui.tiff_viewer import TiffViewer
|
|
121
|
+
|
|
122
|
+
cfg = self.procedure.config
|
|
123
|
+
initial_dir = (
|
|
124
|
+
getattr(cfg, "bids_dir", None)
|
|
125
|
+
or getattr(cfg, "save_dir", None)
|
|
126
|
+
or ""
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Re-use existing window if still open; otherwise create a new one.
|
|
130
|
+
if self._tiff_viewer is not None and self._tiff_viewer.isVisible():
|
|
131
|
+
self._tiff_viewer.raise_()
|
|
132
|
+
self._tiff_viewer.activateWindow()
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
viewer = TiffViewer(initial_dir=initial_dir, procedure=self.procedure)
|
|
136
|
+
viewer.setWindowFlag(Qt.WindowType.Window, True)
|
|
137
|
+
viewer.resize(1100, 800)
|
|
138
|
+
viewer.show()
|
|
139
|
+
self._tiff_viewer = viewer
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def initialize_console(self, procedure):
|
|
143
|
+
"""Initialize the IPython console and embed it into the application."""
|
|
144
|
+
import mesofield.data as data
|
|
145
|
+
# Create an in-process kernel
|
|
146
|
+
self.kernel_manager = QtInProcessKernelManager()
|
|
147
|
+
self.kernel_manager.start_kernel()
|
|
148
|
+
self.kernel = self.kernel_manager.kernel
|
|
149
|
+
# suppress the kernel’s built-in banner
|
|
150
|
+
self.kernel.shell.banner1 = ""
|
|
151
|
+
self.kernel.shell.banner2 = ""
|
|
152
|
+
self.kernel.gui = 'qt'
|
|
153
|
+
|
|
154
|
+
# Create a kernel client and start channels
|
|
155
|
+
self.kernel_client = self.kernel_manager.client()
|
|
156
|
+
self.kernel_client.start_channels()
|
|
157
|
+
|
|
158
|
+
# Create the console widget
|
|
159
|
+
self.console_widget = RichJupyterWidget()
|
|
160
|
+
self.console_widget.kernel_manager = self.kernel_manager
|
|
161
|
+
self.console_widget.kernel_client = self.kernel_client
|
|
162
|
+
self.console_widget.console_width = 100
|
|
163
|
+
# Expose variables to the console's namespace
|
|
164
|
+
console_namespace = {
|
|
165
|
+
#'mda': self.acquisition_gui.mda,
|
|
166
|
+
'self': self,
|
|
167
|
+
'procedure': procedure,
|
|
168
|
+
'data': data
|
|
169
|
+
# Optional, so you can use 'self' directly in the console
|
|
170
|
+
}
|
|
171
|
+
self.kernel.shell.push(console_namespace)
|
|
172
|
+
|
|
173
|
+
# Register the what_do helper command
|
|
174
|
+
def what_do():
|
|
175
|
+
"""Print a friendly guide on using the Mesofield terminal."""
|
|
176
|
+
print(
|
|
177
|
+
"\n"
|
|
178
|
+
"=== Mesofield Terminal ===\n"
|
|
179
|
+
"\n"
|
|
180
|
+
"This is a live IPython console embedded inside Mesofield.\n"
|
|
181
|
+
"You have direct access to the running experiment and can\n"
|
|
182
|
+
"inspect or control it interactively.\n"
|
|
183
|
+
"\n"
|
|
184
|
+
"Available objects:\n"
|
|
185
|
+
" procedure – the active Procedure (start, stop, pause)\n"
|
|
186
|
+
" procedure.config – current ExperimentConfig (paths, subjects, params)\n"
|
|
187
|
+
" data – mesofield.data module (loading, processing, analysis)\n"
|
|
188
|
+
" self – the MainWindow instance (GUI widgets, layout)\n"
|
|
189
|
+
"\n"
|
|
190
|
+
"Quick examples:\n"
|
|
191
|
+
" procedure.config.subject # current subject ID\n"
|
|
192
|
+
" procedure.config.bids_dir # BIDS output directory\n"
|
|
193
|
+
" data.load.sessions('path') # load session data\n"
|
|
194
|
+
"\n"
|
|
195
|
+
"Tips:\n"
|
|
196
|
+
" • Use tab-completion to explore objects and methods.\n"
|
|
197
|
+
" • Use '?' after a name (e.g. procedure?) for docs.\n"
|
|
198
|
+
" • Any valid Python / IPython syntax works here.\n"
|
|
199
|
+
)
|
|
200
|
+
self.kernel.shell.push({'what_do': what_do})
|
|
201
|
+
|
|
202
|
+
self.console_widget.banner = (
|
|
203
|
+
"Mesofield Terminal — interactive Python console\n"
|
|
204
|
+
"Type what_do() for a guide on available commands and objects.\n"
|
|
205
|
+
)
|
|
206
|
+
from mesofield.gui import theme
|
|
207
|
+
self.console_widget.setStyleSheet(theme.terminal_qss())
|
|
208
|
+
#----------------------------------------------------------------------------#
|
|
209
|
+
|
|
210
|
+
def closeEvent(self, event):
|
|
211
|
+
# 1. Shut down the IPython console
|
|
212
|
+
if hasattr(self, "kernel_client"):
|
|
213
|
+
self.kernel_client.stop_channels()
|
|
214
|
+
if hasattr(self, "kernel_manager"):
|
|
215
|
+
self.kernel_manager.shutdown_kernel()
|
|
216
|
+
if hasattr(self, "console_widget"):
|
|
217
|
+
self.console_widget.close()
|
|
218
|
+
|
|
219
|
+
# 2. shut down all hardware
|
|
220
|
+
try:
|
|
221
|
+
self.procedure.config.hardware.shutdown()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
event.accept()
|
|
226
|
+
QCoreApplication.quit()
|
|
227
|
+
|
|
228
|
+
#============================== Private Methods =============================#
|
|
229
|
+
|
|
230
|
+
def _on_procedure_changed(self, new_procedure) -> None:
|
|
231
|
+
"""The ConfigWizard discovered a different ``Procedure`` subclass.
|
|
232
|
+
|
|
233
|
+
Rebind the live reference so all subsequent UI rebuilds operate on
|
|
234
|
+
the user's custom subclass.
|
|
235
|
+
"""
|
|
236
|
+
self.procedure = new_procedure
|
|
237
|
+
# Refresh the embedded IPython console's namespace so that typing
|
|
238
|
+
# ``procedure`` in the Terminal returns the live, hardware-initialized
|
|
239
|
+
# instance instead of the empty default created at launch.
|
|
240
|
+
if getattr(self, "kernel", None) is not None:
|
|
241
|
+
self.kernel.shell.push({"procedure": new_procedure})
|
|
242
|
+
|
|
243
|
+
def _on_config_applied(self) -> None:
|
|
244
|
+
"""Rebuild config-dependent tabs after the user applies a configuration."""
|
|
245
|
+
self.display_keys = self.procedure.config.display_keys
|
|
246
|
+
|
|
247
|
+
# Rebuild the ConfigController tab
|
|
248
|
+
if self._config_controller is not None:
|
|
249
|
+
idx = self.right_tabs.indexOf(self._config_controller)
|
|
250
|
+
self.right_tabs.removeTab(idx)
|
|
251
|
+
self._config_controller.deleteLater()
|
|
252
|
+
|
|
253
|
+
self._config_controller = ConfigController(
|
|
254
|
+
self.procedure, display_keys=self.display_keys
|
|
255
|
+
)
|
|
256
|
+
# Insert after the Setup tab (index 1) so ordering is:
|
|
257
|
+
# [Setup] [ExperimentConfig] [Terminal]
|
|
258
|
+
self.right_tabs.insertTab(1, self._config_controller, "ExperimentConfig")
|
|
259
|
+
self.right_tabs.setCurrentWidget(self._config_controller)
|
|
260
|
+
|
|
261
|
+
# Pin the right column width to the ConfigController's fixed width
|
|
262
|
+
# (plus a small allowance for tab frame/margins) so the tab area is
|
|
263
|
+
# not stretched by wider tabs (e.g. the Terminal/Setup wizard).
|
|
264
|
+
cc_width = self._config_controller.width() or self._config_controller.sizeHint().width()
|
|
265
|
+
self.right_tabs.setFixedWidth(cc_width + 12)
|
|
266
|
+
|
|
267
|
+
def _build_acquisition_ui(self) -> None:
|
|
268
|
+
"""Build (or rebuild) hardware-dependent widgets: MDA viewer and encoder."""
|
|
269
|
+
# -- MDA / acquisition GUI -------------------------------------------
|
|
270
|
+
if self._acquisition_gui is not None:
|
|
271
|
+
self._mda_layout.removeWidget(self._acquisition_gui)
|
|
272
|
+
self._acquisition_gui.deleteLater()
|
|
273
|
+
|
|
274
|
+
self._acquisition_gui = MDA(self.procedure)
|
|
275
|
+
self._mda_layout.insertWidget(0, self._acquisition_gui)
|
|
276
|
+
|
|
277
|
+
# -- Encoder widget ---------------------------------------------------
|
|
278
|
+
if self.procedure.config.hardware.encoder is not None:
|
|
279
|
+
if self._encoder_widget is not None:
|
|
280
|
+
self.main_layout.removeWidget(self._encoder_widget)
|
|
281
|
+
self._encoder_widget.deleteLater()
|
|
282
|
+
|
|
283
|
+
self._encoder_widget = SerialWidget(
|
|
284
|
+
cfg=self.procedure.config,
|
|
285
|
+
device_attr="encoder",
|
|
286
|
+
signal_name="serialSpeedUpdated",
|
|
287
|
+
label="Encoder",
|
|
288
|
+
value_label="Speed",
|
|
289
|
+
value_units="mm/s",
|
|
290
|
+
)
|
|
291
|
+
self.main_layout.addWidget(self._encoder_widget)
|
|
292
|
+
|
|
293
|
+
# -- Plots for procedure-authored FrameProcessors --------------------
|
|
294
|
+
self._build_processor_plots()
|
|
295
|
+
|
|
296
|
+
# -- Refresh the MM config section in the wizard ---------------------
|
|
297
|
+
self.config_wizard.refresh_mm_section()
|
|
298
|
+
|
|
299
|
+
# -- Property browser toolbar buttons --------------------------------
|
|
300
|
+
self._build_property_browsers()
|
|
301
|
+
|
|
302
|
+
def _build_processor_plots(self) -> None:
|
|
303
|
+
"""Add one SerialWidget per (processor, channel) where the channel
|
|
304
|
+
has an entry in ``processor.plot_config``.
|
|
305
|
+
|
|
306
|
+
The procedure (or the ``@processor`` decorator) is responsible for
|
|
307
|
+
constructing the FrameProcessor and declaring per-channel plot
|
|
308
|
+
styling. We just discover ``procedure.processors`` and render
|
|
309
|
+
anything the user opted in to plot.
|
|
310
|
+
"""
|
|
311
|
+
# Tear down anything we built on the previous pass.
|
|
312
|
+
for widget in self._processor_widgets:
|
|
313
|
+
try:
|
|
314
|
+
self.main_layout.removeWidget(widget)
|
|
315
|
+
widget.deleteLater()
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
self._processor_widgets.clear()
|
|
319
|
+
|
|
320
|
+
cfg = self.procedure.config
|
|
321
|
+
for proc in getattr(self.procedure, "processors", []):
|
|
322
|
+
plot_cfg = getattr(proc, "plot_config", None) or {}
|
|
323
|
+
if not plot_cfg:
|
|
324
|
+
continue
|
|
325
|
+
attr = proc.device_id
|
|
326
|
+
# Expose on cfg.hardware once so SerialWidget's ``device_attr``
|
|
327
|
+
# lookup resolves to this processor for every channel.
|
|
328
|
+
setattr(cfg.hardware, attr, proc)
|
|
329
|
+
for channel in getattr(proc, "channels", ("value",)):
|
|
330
|
+
if channel not in plot_cfg:
|
|
331
|
+
continue
|
|
332
|
+
styling = dict(plot_cfg[channel])
|
|
333
|
+
styling.setdefault(
|
|
334
|
+
"label",
|
|
335
|
+
f"{attr.replace('_', ' ').title()} — {channel}"
|
|
336
|
+
if len(plot_cfg) > 1 else attr.replace("_", " ").title(),
|
|
337
|
+
)
|
|
338
|
+
styling.setdefault("value_label", "Value")
|
|
339
|
+
styling.setdefault("value_units", "")
|
|
340
|
+
styling.setdefault("y_range", (0, 4096))
|
|
341
|
+
styling.setdefault("value_scale", 1.0)
|
|
342
|
+
try:
|
|
343
|
+
widget = SerialWidget(
|
|
344
|
+
cfg=cfg,
|
|
345
|
+
device_attr=attr,
|
|
346
|
+
signal_name=f"{channel}Updated",
|
|
347
|
+
**styling,
|
|
348
|
+
)
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
# Don't let a bad plot config kill the whole UI.
|
|
351
|
+
self._log_exception(
|
|
352
|
+
f"build SerialWidget for {attr}:{channel}", exc
|
|
353
|
+
)
|
|
354
|
+
continue
|
|
355
|
+
self.main_layout.addWidget(widget)
|
|
356
|
+
self._processor_widgets.append(widget)
|
|
357
|
+
|
|
358
|
+
def _log_exception(self, ctx: str, exc: Exception) -> None:
|
|
359
|
+
# Defensive logger — MainWindow has no central logger today.
|
|
360
|
+
try:
|
|
361
|
+
print(f"[MainWindow] {ctx} failed: {exc}")
|
|
362
|
+
except Exception:
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
def _build_property_browsers(self) -> None:
|
|
366
|
+
"""Add a toolbar button per MicroManager camera that opens a PropertyBrowser."""
|
|
367
|
+
# Close any existing browsers and remove only property-browser actions
|
|
368
|
+
for dlg in self._prop_browsers:
|
|
369
|
+
dlg.close()
|
|
370
|
+
dlg.deleteLater()
|
|
371
|
+
self._prop_browsers.clear()
|
|
372
|
+
# Remove previously-added property-browser actions but keep other toolbar actions
|
|
373
|
+
for act in getattr(self, "_prop_actions", []):
|
|
374
|
+
try:
|
|
375
|
+
self._toolbar.removeAction(act)
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
self._prop_actions.clear()
|
|
379
|
+
|
|
380
|
+
cameras = self.procedure.config.hardware.cameras
|
|
381
|
+
mm_cams = [
|
|
382
|
+
cam for cam in cameras
|
|
383
|
+
if cam.backend == "micromanager" and hasattr(cam, "core")
|
|
384
|
+
]
|
|
385
|
+
if not mm_cams:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
from pymmcore_widgets import PropertyBrowser
|
|
390
|
+
except ImportError:
|
|
391
|
+
# pymmcore-widgets not installed – skip toolbar
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
for cam in mm_cams:
|
|
395
|
+
browser = PropertyBrowser(mmcore=cam.core, parent=self)
|
|
396
|
+
browser.setWindowTitle(f"Properties — {cam.name}")
|
|
397
|
+
browser.resize(900, 600)
|
|
398
|
+
self._prop_browsers.append(browser)
|
|
399
|
+
|
|
400
|
+
action = QAction(f"🔬 {cam.name} Properties", self)
|
|
401
|
+
action.setToolTip(
|
|
402
|
+
f"Open the device property browser for {cam.name}"
|
|
403
|
+
)
|
|
404
|
+
# Use a default-argument closure to capture the correct browser
|
|
405
|
+
action.triggered.connect(
|
|
406
|
+
lambda checked, b=browser: self._show_property_browser(b)
|
|
407
|
+
)
|
|
408
|
+
self._toolbar.addAction(action)
|
|
409
|
+
self._prop_actions.append(action)
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _show_property_browser(browser) -> None:
|
|
413
|
+
"""Show (or raise) a PropertyBrowser dialog."""
|
|
414
|
+
browser.show()
|
|
415
|
+
browser.raise_()
|
|
416
|
+
browser.activateWindow()
|
|
417
|
+
|
|
418
|
+
def _on_end(self) -> None:
|
|
419
|
+
"""Called when the MDA is finished."""
|
|
420
|
+
|
|
421
|
+
def _update_config(self, config):
|
|
422
|
+
self.procedure.config = config
|
|
423
|
+
|
|
424
|
+
def _on_pause(self, state: bool) -> None:
|
|
425
|
+
"""Called when the MDA is paused."""
|
|
426
|
+
|
|
427
|
+
|