Camera window: T3R and BBD202 jog controls beside the image

Focusing the T-axis and framing the sample on the XY stage are both done
by eye, but the controls were in the main window and the T3R panel, so
the operator had to look away from the video to move anything.

Adds gui/jog_panel.py with two panels, laid out in a column to the right
of the camera image:

  T3RJogPanel   per-axis enable, hold-to-jog ◀/▶, live position, and a
                per-channel microstep combo (SET_MICROSTEP is per channel
                on this controller).  Jog velocity and acceleration are
                shared by the four axes.
  BBDJogPanel   an X/Y jog pad, step size, and velocity/acceleration.
                The stage runs closed-loop servos, so there is no
                microstepping to set — the panel says so rather than
                offering a control that does nothing.

The T3R's JOG is a continuous velocity move, so the button holds it and
the release stops it; the BBD has no such command, so a held button
repeats a short relative move the way the main window already does.
Only axes this panel started are ever stopped — closing the window or
hitting "Stop jogging" can't cut a scan's rotation short.

Both panels take the driver and worker the main window already owns, so
a jog here is the same command as a jog there.  The BBD202 worker grows
a set_velocity command, and its jog now carries the step with it instead
of the caller writing _jog_step onto the worker from the GUI thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-04 08:48:29 -05:00
parent c588be523e
commit f823e2eb42
4 changed files with 670 additions and 16 deletions
+61 -16
View File
@@ -40,6 +40,10 @@ from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header,
)
from gui.jog_panel import (
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
BBDJogPanel, T3RJogPanel,
)
from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector
@@ -54,7 +58,6 @@ from t3r_control_panel import T3RControlPanel
DEFAULTS = ScanDefaults.load()
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
# A SAW check is written beside the scan it belongs to, under the same prefix.
# The suffix keeps it from overwriting the scan itself, which is the one file
# in the directory that cost hours to acquire.
@@ -136,9 +139,6 @@ class DCBiasImageWidget(FigureCanvas):
self.draw_idle()
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(PollingQueueWorker):
@@ -152,13 +152,14 @@ class BBD202Worker(PollingQueueWorker):
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self.controller: ThorlabsServoDriver | None = None
self.scanning_active = False # pause polling during a scan
self._jog_step = BBD_DEFAULT_JOG_MM
self._jog_step = BBD_JOG_STEP_MM
self._last_pos: tuple[float, float] | None = None
self._last_homed: tuple[bool, bool] | None = None
self._handlers.update({
"connect": self._do_connect,
"disconnect": self._do_disconnect,
"jog": self._do_jog,
"set_velocity": self._do_set_velocity,
"home_all": self._do_home_all,
"enable_all": self._do_enable_all,
})
@@ -200,15 +201,23 @@ class BBD202Worker(PollingQueueWorker):
self.is_connected = False
self.disconnected.emit()
def _do_jog(self, axis: str, direction: int):
def _do_jog(self, axis: str, direction: int, step_mm: float | None = None):
if not self.controller:
return
dest = AXIS_X if axis == "x" else AXIS_Y
step = self._jog_step if step_mm is None else step_mm
try:
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
self.controller.move_axis_relative(dest, step * direction, timeout=2.0)
except TimeoutError:
pass
def _do_set_velocity(self, max_velocity: float, acceleration: float):
if not self.controller:
return
for dest in (AXIS_X, AXIS_Y):
self.controller.set_velocity_params(dest, max_velocity=max_velocity,
acceleration=acceleration)
def _do_home_all(self):
if not self.controller:
return
@@ -250,8 +259,12 @@ class BBD202Worker(PollingQueueWorker):
def queue_disconnect(self):
self._enqueue("disconnect")
def queue_jog(self, axis: str, direction: int):
self._enqueue("jog", axis=axis, direction=direction)
def queue_jog(self, axis: str, direction: int, step_mm: float | None = None):
self._enqueue("jog", axis=axis, direction=direction, step_mm=step_mm)
def queue_set_velocity(self, max_velocity: float, acceleration: float):
self._enqueue("set_velocity", max_velocity=max_velocity,
acceleration=acceleration)
def queue_home_all(self):
self._enqueue("home_all")
@@ -426,11 +439,18 @@ class HeliosWorker(PollingQueueWorker):
# ── Camera window popup ───────────────────────────────────────────────────────
class CameraWindow(QWidget):
"""Camera display popup — auto-connects on show, auto-disconnects on close."""
"""Camera display popup — auto-connects on show, auto-disconnects on close.
Focusing and framing are done by eye, so the T3R and BBD202 jog controls
live beside the image. Both take the objects the main window already
owns; passing neither leaves the window as a plain viewer.
"""
closed = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
bbd_worker: BBD202Worker | None = None,
parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
self.setWindowTitle("uC480 Camera")
@@ -468,14 +488,37 @@ class CameraWindow(QWidget):
self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close)
self.t3r_jog_panel = self.bbd_jog_panel = None
self._build_jog_column(t3r_driver, bbd_worker)
self._update_controls()
def _build_jog_column(self, t3r_driver, bbd_worker):
"""Add the stage controls to the right of the image."""
if t3r_driver is None and bbd_worker is None:
return
column = QVBoxLayout()
column.setContentsMargins(0, 0, 0, 0)
if t3r_driver is not None:
self.t3r_jog_panel = T3RJogPanel(t3r_driver, self)
column.addWidget(self.t3r_jog_panel)
if bbd_worker is not None:
self.bbd_jog_panel = BBDJogPanel(bbd_worker, self)
column.addWidget(self.bbd_jog_panel)
column.addStretch()
self.horizontalLayout.addLayout(column)
def showEvent(self, event):
super().showEvent(event)
self._connect_camera()
self._start_stream()
def closeEvent(self, event):
# A jog whose button-release lands after the window is gone would
# otherwise leave an axis running.
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
if panel is not None:
panel.stop_jogs()
self._stop_stream()
self._disconnect_camera()
super().closeEvent(event)
@@ -980,7 +1023,7 @@ class MainWindow(QMainWindow):
self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow()
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker)
self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow()
@@ -1022,7 +1065,7 @@ class MainWindow(QMainWindow):
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
self.lineEdit_10.setText(str(BBD_JOG_STEP_MM))
self.x_start_edit.setText("10.000")
self.y_start_edit.setText("10.000")
@@ -1188,10 +1231,12 @@ class MainWindow(QMainWindow):
return
axis, direction = self._bbd_active_jog
try:
self._bbd_worker._jog_step = float(self.lineEdit_10.text())
step = float(self.lineEdit_10.text())
except ValueError:
self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM
self._bbd_worker.queue_jog(axis, direction)
step = BBD_JOG_STEP_MM
# The step rides along with the command: writing it onto the worker
# from here would be a cross-thread poke at a field the worker reads.
self._bbd_worker.queue_jog(axis, direction, step_mm=step)
def _stop_bbd_jog(self):
self._bbd_jog_timer.stop()