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,285 @@
1
+ from pymmcore_plus import CMMCorePlus
2
+
3
+ from PyQt6.QtWidgets import (
4
+ QCheckBox,
5
+ QGroupBox,
6
+ QHBoxLayout,
7
+ QPushButton,
8
+ QVBoxLayout,
9
+ QWidget,
10
+ QSizePolicy,
11
+ )
12
+
13
+ from pymmcore_widgets import MDAWidget
14
+
15
+ from mesofield.data.writer import CustomWriter
16
+ from mesofield.gui.viewer import ImagePreview, InteractivePreview
17
+ from mesofield.utils._logger import get_logger
18
+
19
+
20
+ _logger = get_logger(__name__)
21
+
22
+
23
+ class CameraButtons(QWidget):
24
+ """Snap + Live toggle wired through the BaseCamera contract.
25
+
26
+ Replaces pymmcore-widgets' ``SnapButton`` / ``LiveButton`` (which only
27
+ worked for mmcore-backed cameras) with two buttons that call
28
+ ``cam.snap()`` / ``cam.start_live()`` / ``cam.stop_live()``. Those
29
+ methods are defined on :class:`mesofield.devices.base_camera.BaseCamera`
30
+ and implemented by every camera subclass, so the GUI no longer cares
31
+ whether the underlying backend is Micro-Manager, OpenCV, or the
32
+ synthetic :class:`MockFrameProducer`.
33
+
34
+ The widget delegates frame display to the camera's existing signal
35
+ plumbing:
36
+
37
+ - MMCamera: ``cam.snap()`` calls ``mmcore.snap()`` which fires the
38
+ ``imageSnapped`` event the :class:`ImagePreview` is already wired
39
+ to; ``cam.start_live()`` triggers ``startContinuousSequenceAcquisition``
40
+ and frames flow through the MDA events.
41
+ - OpenCVCamera / MockFrameProducer: ``snap()`` and the live loop emit
42
+ ``cam.image_ready`` (a ``pyqtSignal(np.ndarray)``) which the
43
+ ``ImagePreview`` subscribes to via ``image_payload``.
44
+ """
45
+
46
+ def __init__(self, cam, preview=None) -> None:
47
+ super().__init__()
48
+ self.cam = cam
49
+ self.preview = preview
50
+ layout = QHBoxLayout()
51
+ layout.setContentsMargins(0, 0, 0, 0)
52
+ self.setLayout(layout)
53
+
54
+ self.snap_btn = QPushButton("Snap")
55
+ self.snap_btn.setToolTip(f"cam.snap() [{cam.backend} backend]")
56
+ self.snap_btn.clicked.connect(self._on_snap)
57
+ layout.addWidget(self.snap_btn)
58
+
59
+ self.live_btn = QPushButton("Live")
60
+ self.live_btn.setCheckable(True)
61
+ self.live_btn.setToolTip(
62
+ f"toggle cam.start_live() / cam.stop_live() [{cam.backend} backend]"
63
+ )
64
+ self.live_btn.toggled.connect(self._on_live_toggled)
65
+ layout.addWidget(self.live_btn)
66
+
67
+ # Runtime auto-contrast toggle for the live viewer. The "off" state
68
+ # restores a viewer-appropriate manual range (8-bit static preview
69
+ # vs 16-bit interactive preview).
70
+ self._manual_clims = (
71
+ (0, 65535) if isinstance(preview, InteractivePreview) else (0, 255)
72
+ )
73
+ self.auto_contrast_cb = QCheckBox("Auto-contrast")
74
+ self.auto_contrast_cb.setChecked(bool(getattr(cam, "auto_contrast", True)))
75
+ self.auto_contrast_cb.setToolTip("Toggle live-viewer auto-contrast")
76
+ self.auto_contrast_cb.toggled.connect(self._on_auto_contrast_toggled)
77
+ layout.addWidget(self.auto_contrast_cb)
78
+ # Sync the viewer's contrast mode to the checkbox's initial state.
79
+ self._on_auto_contrast_toggled(self.auto_contrast_cb.isChecked())
80
+
81
+ def set_enabled(self, enabled: bool) -> None:
82
+ """Enable/disable the snap + live controls (used during acquisition)."""
83
+ self.snap_btn.setEnabled(enabled)
84
+ self.live_btn.setEnabled(enabled)
85
+
86
+ def _on_auto_contrast_toggled(self, checked: bool) -> None:
87
+ if self.preview is None:
88
+ return
89
+ try:
90
+ self.preview.clims = "auto" if checked else self._manual_clims
91
+ except Exception as exc:
92
+ _logger.warning(
93
+ "auto-contrast toggle failed on %s: %s", self.cam.device_id, exc
94
+ )
95
+
96
+ def _on_snap(self) -> None:
97
+ try:
98
+ self.cam.snap()
99
+ except Exception as exc:
100
+ _logger.warning("snap failed on %s: %s", self.cam.device_id, exc)
101
+
102
+ def _on_live_toggled(self, checked: bool) -> None:
103
+ try:
104
+ if checked:
105
+ self.cam.start_live()
106
+ self.live_btn.setText("Stop Live")
107
+ else:
108
+ self.cam.stop_live()
109
+ self.live_btn.setText("Live")
110
+ except Exception as exc:
111
+ _logger.warning(
112
+ "live toggle failed on %s: %s", self.cam.device_id, exc
113
+ )
114
+ # Reset the toggle state if start/stop failed.
115
+ self.live_btn.blockSignals(True)
116
+ self.live_btn.setChecked(not checked)
117
+ self.live_btn.setText("Live")
118
+ self.live_btn.blockSignals(False)
119
+
120
+ class CustomMDAWidget(MDAWidget):
121
+ def run_mda(self) -> None:
122
+ """Run the MDA sequence experiment."""
123
+ # in case the user does not press enter after editing the save name.
124
+ self.save_info.save_name.editingFinished.emit()
125
+
126
+ sequence = self.value()
127
+
128
+ # technically, this is in the metadata as well, but isChecked is more direct
129
+ if self.save_info.isChecked():
130
+ save_path = self._update_save_path_from_metadata(
131
+ sequence, update_metadata=True
132
+ )
133
+ else:
134
+ save_path = None
135
+
136
+ # run the MDA experiment asynchronously
137
+ self._mmc.run_mda(sequence, output=CustomWriter(save_path))
138
+
139
+ class MDA(QWidget):
140
+ """
141
+ The `MDAWidget` provides a GUI to construct a `useq.MDASequence` object.
142
+ This object describes a full multi-dimensional acquisition;
143
+ In this example, we set the `MDAWidget` parameter `include_run_button` to `True`,
144
+ meaning that a `run` button is added to the GUI. When pressed, a `useq.MDASequence`
145
+ is first built depending on the GUI values and is then passed to the
146
+ `CMMCorePlus.run_mda` to actually execute the acquisition.
147
+ For details of the corresponding schema and methods, see
148
+ https://github.com/pymmcore-plus/useq-schema and
149
+ https://github.com/pymmcore-plus/pymmcore-plus.
150
+
151
+ """
152
+
153
+ def __init__(self, procedure) -> None:
154
+ """
155
+
156
+ The layout adapts the viewer based on the number of cores:
157
+
158
+ Single Core Layout:
159
+
160
+ +----------------------------------------+
161
+ | Live Viewer |
162
+ | +-----------------+-----------------+ |
163
+ | | [Snap Button] | [Live Button} | |
164
+ | +-----------------+-----------------+ |
165
+ | | | |
166
+ | | Image Preview | |
167
+ | | | |
168
+ | +-----------------------------------+ |
169
+ +----------------------------------------+
170
+
171
+ Dual Core Layout:
172
+
173
+ +-----------------------------------------------+
174
+ | Live Viewer |
175
+ | +---------------------+-------------------+ |
176
+ | | Core 1 | Core 2 | |
177
+ | +---------------------+-------------------+ |
178
+ | | [Buttons] | [Buttons] | |
179
+ | +---------------------+-------------------+ |
180
+ | | | | |
181
+ | | Image Preview 1 | Image Preview 2 | |
182
+ | | | | |
183
+ | +---------------------+-------------------+ |
184
+ +-----------------------------------------------+
185
+
186
+ """
187
+ super().__init__()
188
+ # get the CMMCore instance and load the default config
189
+ self.procedure = procedure
190
+ config = procedure.config
191
+ self.cameras = config.hardware.cameras
192
+ self.mmcores = tuple(cam.core for cam in self.cameras)
193
+ self._viewer_type = config.hardware._viewer
194
+ # instantiate the MDAWidget
195
+ #self.mda = MDAWidget(mmcore=self.mmcores[0])
196
+ # ----------------------------------Auto-set MDASequence and save_info----------------------------------#
197
+ #self.mda.setValue(config.pupil_sequence)
198
+ #self.mda.save_info.setValue({'save_dir': r'C:/dev', 'save_name': 'file', 'format': 'ome-tiff', 'should_save': True})
199
+ # -------------------------------------------------------------------------------------------------------#
200
+ self.setLayout(QHBoxLayout())
201
+
202
+ live_viewer = QGroupBox()
203
+ live_viewer.setLayout(QVBoxLayout())
204
+ live_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
205
+ buttons = QGroupBox()
206
+ buttons.setLayout(QHBoxLayout())
207
+
208
+ cores_groupbox = QGroupBox(f"{self.__module__}.{self.__class__.__name__}: Live Viewer")
209
+ cores_groupbox.setLayout(QHBoxLayout())
210
+
211
+ # Track the per-camera button widgets so acquisition can lock them out.
212
+ self._camera_buttons: list[CameraButtons] = []
213
+
214
+ for cam in self.cameras:
215
+ # Per-core container
216
+ core_box = QGroupBox(title=str(cam.name))
217
+ core_box.setLayout(QVBoxLayout())
218
+ core_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
219
+ auto_contrast = getattr(cam, "auto_contrast", True)
220
+
221
+ # Preview widget: ImagePreview drives display.
222
+ # - mmcore-backed cameras wire the preview to mmcore events;
223
+ # - everyone else feeds it via the camera's `image_ready` Qt
224
+ # signal (an attribute on every BaseCamera subclass).
225
+ if cam.viewer == "static":
226
+ if isinstance(cam.core, CMMCorePlus):
227
+ preview = ImagePreview(
228
+ mmcore=cam.core,
229
+ _clims='auto' if auto_contrast else (0, 255),
230
+ )
231
+ else:
232
+ image_signal = getattr(cam, "image_ready", None)
233
+ if image_signal is None and cam.core is not None:
234
+ image_signal = getattr(cam.core, "image_ready", None)
235
+ preview = ImagePreview(
236
+ mmcore=None,
237
+ image_payload=image_signal,
238
+ progress_payload=getattr(cam, "progress", None),
239
+ _clims='auto' if auto_contrast else (0, 255),
240
+ )
241
+ else:
242
+ # Interactive / pyqtgraph viewer.
243
+ image_signal = getattr(cam, "image_ready", None)
244
+ if image_signal is None and cam.core is not None:
245
+ image_signal = getattr(cam.core, "image_ready", None)
246
+ preview = InteractivePreview(image_payload=image_signal)
247
+
248
+ # Unified snap + live buttons -- driven by BaseCamera methods.
249
+ # Works for MMCamera (via mmcore), OpenCVCamera (capture thread),
250
+ # and MockFrameProducer (synthetic frames). Non-mmcore cameras
251
+ # used to auto-start on widget creation; now the user clicks
252
+ # "Live" to start, matching mmcore camera UX.
253
+ cam_buttons = CameraButtons(cam, preview)
254
+ self._camera_buttons.append(cam_buttons)
255
+ core_box.layout().addWidget(cam_buttons)
256
+ core_box.layout().addWidget(preview)
257
+ cores_groupbox.layout().addWidget(core_box)
258
+
259
+ # Add the cores_groupbox once, not once per camera (the old code
260
+ # added it inside the loop, producing duplicate top-level widgets).
261
+ self.layout().addWidget(cores_groupbox)
262
+
263
+ # Lock the Snap/Live buttons for the duration of a Procedure run.
264
+ # Bound-method connections auto-disconnect when this QWidget is
265
+ # destroyed (the widget is rebuilt on every hardware reload).
266
+ events = getattr(procedure, "events", None)
267
+ if events is not None:
268
+ events.procedure_started.connect(self._on_acquisition_started)
269
+ events.procedure_finished.connect(self._on_acquisition_finished)
270
+ events.procedure_error.connect(self._on_acquisition_finished)
271
+
272
+ def _on_acquisition_started(self, *_args) -> None:
273
+ self.set_acquisition_active(True)
274
+
275
+ def _on_acquisition_finished(self, *_args) -> None:
276
+ self.set_acquisition_active(False)
277
+
278
+ def set_acquisition_active(self, active: bool) -> None:
279
+ """Lock out the Snap/Live buttons while a Procedure run is in progress."""
280
+ for cam_buttons in self._camera_buttons:
281
+ cam_buttons.set_enabled(not active)
282
+
283
+
284
+
285
+
@@ -0,0 +1,109 @@
1
+ """Qt adapter that bridges pure-Python device signals into ``pyqtSignal``s.
2
+
3
+ GUI code (e.g. live plotting, image previews) needs Qt signals so that
4
+ emissions are delivered on the main thread with QueuedConnection
5
+ semantics. Devices built on
6
+ :class:`mesofield.devices.base.BaseDataProducer` use ``psygnal`` and
7
+ remain Qt-free. This module is the seam: attach an adapter to a
8
+ device, expose the adapter's ``pyqtSignal`` as an attribute on the
9
+ device, and the GUI reads that attribute.
10
+
11
+ Two adapters live here:
12
+
13
+ - :class:`QtDeviceAdapter` — for serial-style devices. Bridges
14
+ ``signals.data`` into ``serialDataReceived`` / ``serialSpeedUpdated``.
15
+ - :class:`QtImageAdapter` — for camera-shaped devices. Provides an
16
+ ``image_ready(np.ndarray)`` pyqtSignal that the MDA viewer subscribes
17
+ to. The device pushes frames into the adapter via
18
+ ``adapter.emit_frame(frame)``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Optional
24
+
25
+ import numpy as np
26
+ from PyQt6.QtCore import QObject, pyqtSignal
27
+
28
+
29
+ class QtDeviceAdapter(QObject):
30
+ """Bridges ``device.signals.data`` into Qt-friendly emissions.
31
+
32
+ Subscribes to the device's ``signals.data`` and re-emits:
33
+
34
+ - ``serialDataReceived(object)`` — the raw payload.
35
+ - ``serialSpeedUpdated(float, float)`` — ``(time_s, speed)`` whenever
36
+ the payload is a ``dict`` carrying a ``"speed"`` key. ``time_s``
37
+ defaults to the device timestamp; if absent, the queue timestamp.
38
+ """
39
+
40
+ serialDataReceived = pyqtSignal(object)
41
+ serialSpeedUpdated = pyqtSignal(float, float)
42
+
43
+ def __init__(self, device: Any) -> None:
44
+ super().__init__()
45
+ self._device = device
46
+ signals = getattr(device, "signals", None)
47
+ data_sig = getattr(signals, "data", None) if signals is not None else None
48
+ if data_sig is not None and hasattr(data_sig, "connect"):
49
+ try:
50
+ data_sig.connect(self._on_data)
51
+ except Exception:
52
+ pass
53
+
54
+ def _on_data(self, payload: Any, ts: Any = None) -> None:
55
+ try:
56
+ self.serialDataReceived.emit(payload)
57
+ except Exception:
58
+ pass
59
+
60
+ # Pull a scalar "value" out of the payload for the live trace.
61
+ value: Optional[float] = None
62
+ t: Any = None
63
+ if isinstance(payload, dict):
64
+ if "speed" in payload:
65
+ value = payload["speed"]
66
+ t = payload.get("device_us")
67
+ elif isinstance(payload, (int, float)):
68
+ value = payload
69
+
70
+ if value is None:
71
+ return
72
+ if t is None:
73
+ t = ts if ts is not None else 0.0
74
+ try:
75
+ self.serialSpeedUpdated.emit(float(t), float(value))
76
+ except (TypeError, ValueError):
77
+ pass
78
+
79
+
80
+ class QtImageAdapter(QObject):
81
+ """Bridges per-frame ndarray emissions into a Qt ``image_ready`` signal.
82
+
83
+ The MDA gui's static-viewer branch subscribes via
84
+ ``preview = ImagePreview(image_payload=cam.image_ready, ...)``, which
85
+ calls ``image_payload.connect(cb, type=QueuedConnection)`` -- so
86
+ ``image_ready`` MUST be a real ``pyqtSignal``. Devices built on
87
+ :class:`BaseDataProducer` are non-Qt; they hold an instance of this
88
+ adapter and expose its ``image_ready`` attribute as their own.
89
+
90
+ Usage::
91
+
92
+ class MyCam(BaseDataProducer):
93
+ def __init__(self, cfg=None, **kwargs):
94
+ super().__init__(cfg, **kwargs)
95
+ self._qt_image_adapter = QtImageAdapter()
96
+ self.image_ready = self._qt_image_adapter.image_ready
97
+
98
+ def _run_loop(self):
99
+ ...
100
+ self._qt_image_adapter.emit_frame(frame)
101
+ """
102
+
103
+ image_ready = pyqtSignal(np.ndarray)
104
+
105
+ def emit_frame(self, frame: np.ndarray) -> None:
106
+ try:
107
+ self.image_ready.emit(frame)
108
+ except Exception:
109
+ pass
@@ -0,0 +1,152 @@
1
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
2
+ import pyqtgraph as pg
3
+
4
+
5
+ class SerialWidget(QWidget):
6
+ """
7
+ A live-plotting widget for any serial device that emits a
8
+ pyqtSignal(float, float) representing (time, value).
9
+
10
+ Parameters
11
+ ----------
12
+ cfg : ExperimentConfig
13
+ The experiment configuration object. The device is resolved from
14
+ ``cfg.hardware.<device_attr>``. If that attribute is ``None`` or
15
+ missing the widget renders in a safe *disconnected* state.
16
+ device_attr : str
17
+ Attribute name on ``cfg.hardware`` to look up (default ``"encoder"``).
18
+ signal_name : str
19
+ Name of the pyqtSignal attribute on the device to connect to.
20
+ label : str
21
+ Human-readable name shown in the info label.
22
+ value_label : str
23
+ Y-axis label for the plot.
24
+ value_units : str
25
+ Y-axis units.
26
+ y_range : tuple[float, float]
27
+ Initial Y-axis range.
28
+ value_scale : float
29
+ Multiplicative factor applied to incoming values before plotting.
30
+ max_points : int
31
+ Number of data points to keep visible.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ cfg,
37
+ device_attr: str = "encoder",
38
+ signal_name: str = "serialSpeedUpdated",
39
+ label: str = "Serial Device",
40
+ value_label: str = "Value",
41
+ value_units: str = "",
42
+ y_range: tuple = (-1, 1),
43
+ value_scale: float = 0.01,
44
+ max_points: int = 100,
45
+ ):
46
+ super().__init__()
47
+ self.device = getattr(cfg.hardware, device_attr, None)
48
+ self.signal_name = signal_name
49
+ self.label = label
50
+ self.value_label = value_label
51
+ self.value_units = value_units
52
+ self.y_range = y_range
53
+ self.value_scale = value_scale
54
+ self.max_points = max_points
55
+ self.connected = self.device is not None
56
+
57
+ self._signal = getattr(self.device, self.signal_name, None) if self.connected else None
58
+
59
+ self.init_ui()
60
+ self.init_data()
61
+ self.setFixedHeight(300)
62
+
63
+ # ---- UI ----------------------------------------------------------------
64
+
65
+ def init_ui(self):
66
+ self.layout = QVBoxLayout()
67
+
68
+ if self.connected:
69
+ port = getattr(self.device, 'port', '?')
70
+ baud = getattr(self.device, 'baudrate', '?')
71
+ status_text = "Click 'Start Live View' to begin."
72
+ info_text = f'{self.label} on Port: {port} | Baud: {baud}'
73
+ else:
74
+ status_text = "No device connected."
75
+ info_text = f"{self.label}: not available"
76
+
77
+ self.status_label = QLabel(status_text)
78
+ self.info_label = QLabel(info_text)
79
+ self.start_button = QPushButton("Start Live View")
80
+ self.start_button.setCheckable(True)
81
+ self.start_button.setEnabled(self.connected)
82
+ self.plot_widget = pg.PlotWidget()
83
+
84
+ self.start_button.clicked.connect(self.toggle_serial_thread)
85
+
86
+ self.layout.addWidget(self.status_label)
87
+ self.layout.addWidget(self.info_label)
88
+ self.layout.addWidget(self.start_button)
89
+ self.layout.addWidget(self.plot_widget)
90
+ self.setLayout(self.layout)
91
+
92
+ units_str = self.value_units or None
93
+ self.plot_widget.setTitle('Serial Trace')
94
+ self.plot_widget.setLabel('left', self.value_label, units=units_str)
95
+ self.plot_widget.setLabel('bottom', 'Time', units='s')
96
+ self.data_curve = self.plot_widget.plot(pen='y')
97
+
98
+ self.plot_widget.setYRange(*self.y_range)
99
+ self.plot_widget.showGrid(x=True, y=True)
100
+
101
+ if self._signal is not None:
102
+ self._signal.connect(self.receive_data)
103
+
104
+ # ---- Data --------------------------------------------------------------
105
+
106
+ def init_data(self):
107
+ self.times = []
108
+ self.values = []
109
+ self.start_time = None
110
+ self.timer = None
111
+ self.previous_time = 0
112
+
113
+ def toggle_serial_thread(self):
114
+ if not self.connected:
115
+ return
116
+ if self.start_button.isChecked():
117
+ self._signal.connect(self.receive_data)
118
+ self.device.start()
119
+ self.status_label.setText("Serial thread started.")
120
+ else:
121
+ self.stop_serial_thread()
122
+ self.status_label.setText("Serial thread stopped.")
123
+
124
+ def stop_serial_thread(self):
125
+ if self._signal is not None:
126
+ self._signal.disconnect()
127
+
128
+ def receive_data(self, time, value):
129
+ self.times.append(time)
130
+ self.values.append(value * self.value_scale)
131
+ self.times = self.times[-self.max_points:]
132
+ self.values = self.values[-self.max_points:]
133
+ self.update_plot()
134
+ unit_suffix = f" {self.value_units}" if self.value_units else ""
135
+ self.status_label.setText(
136
+ f"{self.value_label}: {value:.2f}{unit_suffix}"
137
+ )
138
+
139
+ def update_plot(self):
140
+ try:
141
+ if self.times and self.values:
142
+ self.data_curve.setData(self.times, self.values)
143
+ self.plot_widget.setXRange(self.times[0], self.times[-1], padding=0)
144
+ else:
145
+ self.plot_widget.clear()
146
+ self.plot_widget.setTitle('No data received.')
147
+ except Exception as e:
148
+ print(f"Exception in update_plot: {e}")
149
+
150
+
151
+ # Backwards-compatible alias
152
+ EncoderWidget = SerialWidget