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
+1
View File
@@ -84,6 +84,7 @@ scanengine-3/
│ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect │ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect
│ ├── qt_t3r.py # Qt adapter over the T3R driver │ ├── qt_t3r.py # Qt adapter over the T3R driver
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases │ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
│ ├── jog_panel.py # T3R + BBD202 jog controls (camera window)
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector… │ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
│ │
├── sc3_aui_app.py # Main acquisition application ├── sc3_aui_app.py # Main acquisition application
+392
View File
@@ -0,0 +1,392 @@
"""Jog controls for the T3R axes and the BBD202 stage.
These sit beside the camera image. Focusing the T-axis and framing the
sample on the XY stage are both done by eye, so the controls have to be
reachable without looking away from the video. Both panels drive the same
driver/worker the main window uses, so a jog here is the same command as a
jog there.
The T3R panel jogs while the button is held (the controller's JOG command is
a continuous velocity move, ended by STOP). The BBD202 has no such command,
so a held button repeats a short relative move, the way the main window
already does it.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox,
QLabel, QPushButton, QSpinBox,
)
import hardware.t3r_protocol as proto
from hardware.t3r_driver import T3RDriver
# BBD202 jog defaults, shared with the main window's worker.
BBD_JOG_STEP_MM = 0.5 # default jog step
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# T3R jog defaults, matching the T3R control panel's own spin boxes.
T3R_JOG_VELOCITY = 8000 # steps/s
T3R_JOG_ACCEL = 4000 # steps/s²
T3R_MICROSTEPS = 16 # shown until the device reports its own
# A held BBD button repeats a relative move at this interval; the move itself
# is short, so a faster repeat would just queue up moves the stage can't
# finish (the main window uses the same 200 ms).
BBD_JOG_REPEAT_MS = 200
# Applying velocity on every spin-box click would flood the command queue, so
# the write is debounced until the operator stops adjusting.
BBD_VELOCITY_DEBOUNCE_MS = 300
def _mono(size: int = 11) -> QFont:
f = QFont("Menlo")
f.setStyleHint(QFont.StyleHint.Monospace)
f.setPointSize(size)
return f
def _hline() -> QFrame:
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
return line
def _jog_button(text: str, tip: str) -> QPushButton:
btn = QPushButton(text)
btn.setToolTip(tip)
btn.setFixedWidth(38)
btn.setAutoRepeat(False) # the repeat is ours, on a timer
return btn
# ── T3R ───────────────────────────────────────────────────────────────────────
class T3RJogPanel(QGroupBox):
"""Enable, microstep and jog controls for the four T3R axes.
Jog velocity and acceleration are shared by every axis; microstepping is
per-channel, because that is how the controller stores it.
"""
def __init__(self, driver, parent=None):
super().__init__("T3R Axes", parent)
self._driver = driver
self._jogging: set[int] = set()
# Microsteps the operator picked but the device hasn't confirmed yet.
# Without this, an info poll already in flight carrying the old value
# would snap the combo back and look like the click was ignored.
self._pending_micro: dict[int, int] = {}
self._enable_chks: dict[int, QCheckBox] = {}
self._micro_combos: dict[int, QComboBox] = {}
self._pos_lbls: dict[int, QLabel] = {}
self._jog_btns: dict[tuple[int, int], QPushButton] = {}
self._build()
driver.handshake_ok.connect(lambda *_: self._set_online(True))
driver.disconnected.connect(lambda *_: self._set_online(False))
driver.info_updated.connect(self._on_info)
self._set_online(driver.is_open)
# ── Construction ──────────────────────────────────────────────────────────
def _build(self):
grid = QGridLayout(self)
grid.setVerticalSpacing(4)
grid.setHorizontalSpacing(6)
row = 0
grid.addWidget(QLabel("Velocity"), row, 0)
self.vel_spin = QSpinBox()
self.vel_spin.setRange(1, proto.MAX_VELOCITY)
self.vel_spin.setValue(T3R_JOG_VELOCITY)
self.vel_spin.setGroupSeparatorShown(True)
self.vel_spin.setSuffix(" steps/s")
grid.addWidget(self.vel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Accel"), row, 0)
self.accel_spin = QSpinBox()
self.accel_spin.setRange(0, proto.MAX_ACCEL)
self.accel_spin.setValue(T3R_JOG_ACCEL)
self.accel_spin.setGroupSeparatorShown(True)
self.accel_spin.setSuffix(" steps/s²")
grid.addWidget(self.accel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(_hline(), row, 0, 1, 5)
row += 1
hdr = QLabel("hold a jog button to move")
hdr.setStyleSheet("color: gray;")
grid.addWidget(hdr, row, 0, 1, 3)
grid.addWidget(QLabel("µsteps"), row, 3)
grid.addWidget(QLabel("pos"), row, 4)
row += 1
for ch, name in enumerate(T3RDriver.CHANNEL_NAMES):
chk = QCheckBox(name)
chk.setToolTip(f"Energise axis {ch} — a disabled axis ignores jogs")
chk.toggled.connect(lambda on, c=ch: self._on_enable_toggled(c, on))
grid.addWidget(chk, row, 0)
self._enable_chks[ch] = chk
for col, (text, direction, way) in enumerate(
(("◀", -1, "negative"), ("▶", +1, "positive")), start=1):
btn = _jog_button(text, f"Jog {way} while held")
btn.pressed.connect(
lambda c=ch, d=direction: self._start_jog(c, d))
btn.released.connect(lambda c=ch: self._stop_jog(c))
grid.addWidget(btn, row, col)
self._jog_btns[(ch, direction)] = btn
combo = QComboBox()
for m in proto.MICROSTEPS:
combo.addItem(str(m), m)
combo.setCurrentIndex(combo.findData(T3R_MICROSTEPS))
combo.setToolTip("SET_MICROSTEP — applied immediately, axis must be idle")
combo.activated.connect(lambda _i, c=ch: self._on_micro_selected(c))
grid.addWidget(combo, row, 3)
self._micro_combos[ch] = combo
pos_lbl = QLabel("—")
pos_lbl.setFont(_mono(11))
pos_lbl.setMinimumWidth(76)
pos_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
grid.addWidget(pos_lbl, row, 4)
self._pos_lbls[ch] = pos_lbl
row += 1
# Recovery for a jog whose button-release never arrived (window hidden
# or focus stolen mid-press). It only stops axes this panel started,
# so it can never cut a scan's rotation short.
self.stop_btn = QPushButton("Stop jogging")
self.stop_btn.clicked.connect(self.stop_jogs)
grid.addWidget(self.stop_btn, row, 0, 1, 5)
grid.setColumnStretch(4, 1)
# ── Commands ──────────────────────────────────────────────────────────────
def _on_enable_toggled(self, ch: int, on: bool):
if on:
self._driver.enable(ch)
else:
self._driver.disable(ch)
def _on_micro_selected(self, ch: int):
micro = self._micro_combos[ch].currentData()
self._pending_micro[ch] = micro
self._driver.set_microstep(ch, micro)
def _start_jog(self, ch: int, direction: int):
self._jogging.add(ch)
self._driver.jog(ch, direction * self.vel_spin.value(),
self.accel_spin.value())
def _stop_jog(self, ch: int):
if ch in self._jogging:
self._jogging.discard(ch)
self._driver.stop(ch, False)
def stop_jogs(self):
"""Stop every axis this panel is jogging. Safe to call when idle."""
for ch in sorted(self._jogging):
self._driver.stop(ch, False)
self._jogging.clear()
# ── Device updates ────────────────────────────────────────────────────────
def _on_info(self, ch: int, info):
lbl = self._pos_lbls.get(ch)
if lbl is None:
return
lbl.setText(f"{info.position}")
chk = self._enable_chks[ch]
if chk.isChecked() != info.enabled:
chk.blockSignals(True)
chk.setChecked(info.enabled)
chk.blockSignals(False)
pending = self._pending_micro.get(ch)
if pending is not None:
if info.microsteps != pending:
return # change still in flight
del self._pending_micro[ch]
combo = self._micro_combos[ch]
idx = combo.findData(info.microsteps)
if idx >= 0 and idx != combo.currentIndex():
combo.blockSignals(True)
combo.setCurrentIndex(idx)
combo.blockSignals(False)
def _set_online(self, on: bool):
if not on:
self._jogging.clear()
self._pending_micro.clear()
for lbl in self._pos_lbls.values():
lbl.setText("—")
self.setEnabled(on)
# ── BBD202 ────────────────────────────────────────────────────────────────────
class BBDJogPanel(QGroupBox):
"""X/Y jog controls for the BBD202 stage.
``worker`` is the main window's BBD202Worker; it is duck-typed here to
keep this module free of an import cycle with the app. The stage runs
closed-loop brushless servos, so there is no microstepping to set —
velocity and acceleration are the equivalent knobs.
"""
def __init__(self, worker, parent=None):
super().__init__("BBD202 XY Stage", parent)
self._worker = worker
self._active: tuple[str, int] | None = None
self._repeat = QTimer(self)
self._repeat.setInterval(BBD_JOG_REPEAT_MS)
self._repeat.timeout.connect(self._jog_tick)
self._vel_debounce = QTimer(self)
self._vel_debounce.setSingleShot(True)
self._vel_debounce.setInterval(BBD_VELOCITY_DEBOUNCE_MS)
self._vel_debounce.timeout.connect(self.apply_velocity)
self._build()
worker.connected.connect(self._on_connected)
worker.disconnected.connect(lambda: self._set_online(False))
worker.position_updated.connect(self._on_position)
self._set_online(worker.is_connected)
# ── Construction ──────────────────────────────────────────────────────────
def _build(self):
grid = QGridLayout(self)
grid.setVerticalSpacing(4)
grid.setHorizontalSpacing(6)
row = 0
grid.addWidget(QLabel("X"), row, 0)
self.x_pos_lbl = QLabel("---.---")
self.x_pos_lbl.setFont(_mono(11))
grid.addWidget(self.x_pos_lbl, row, 1)
grid.addWidget(QLabel("Y"), row, 2)
self.y_pos_lbl = QLabel("---.---")
self.y_pos_lbl.setFont(_mono(11))
grid.addWidget(self.y_pos_lbl, row, 3)
row += 1
grid.addWidget(_hline(), row, 0, 1, 4)
row += 1
# Jog pad: Y over X, laid out the way the stage moves.
self.y_pos_btn = _jog_button("▲", "Jog +Y while held")
grid.addWidget(self.y_pos_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
row += 1
self.x_neg_btn = _jog_button("◀", "Jog −X while held")
grid.addWidget(self.x_neg_btn, row, 1, Qt.AlignmentFlag.AlignRight)
self.x_pos_btn = _jog_button("▶", "Jog +X while held")
grid.addWidget(self.x_pos_btn, row, 2, Qt.AlignmentFlag.AlignLeft)
row += 1
self.y_neg_btn = _jog_button("▼", "Jog −Y while held")
grid.addWidget(self.y_neg_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
row += 1
for btn, axis, direction in (
(self.x_pos_btn, "x", +1), (self.x_neg_btn, "x", -1),
(self.y_pos_btn, "y", +1), (self.y_neg_btn, "y", -1),
):
btn.pressed.connect(lambda a=axis, d=direction: self._start_jog(a, d))
btn.released.connect(self.stop_jogs)
grid.addWidget(QLabel("Step"), row, 0)
self.step_spin = QDoubleSpinBox()
self.step_spin.setRange(0.001, 25.0)
self.step_spin.setDecimals(3)
self.step_spin.setSingleStep(0.1)
self.step_spin.setValue(BBD_JOG_STEP_MM)
self.step_spin.setSuffix(" mm")
grid.addWidget(self.step_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Velocity"), row, 0)
self.vel_spin = QDoubleSpinBox()
self.vel_spin.setRange(0.1, 100.0)
self.vel_spin.setDecimals(1)
self.vel_spin.setValue(BBD_JOG_SPEED_MM_S)
self.vel_spin.setSuffix(" mm/s")
self.vel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
grid.addWidget(self.vel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Accel"), row, 0)
self.accel_spin = QDoubleSpinBox()
self.accel_spin.setRange(0.1, 500.0)
self.accel_spin.setDecimals(1)
self.accel_spin.setValue(BBD_JOG_ACCEL_MM_S2)
self.accel_spin.setSuffix(" mm/s²")
self.accel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
grid.addWidget(self.accel_spin, row, 1, 1, 3)
row += 1
note = QLabel("Microstepping: n/a — closed-loop servo")
note.setStyleSheet("color: gray;")
note.setWordWrap(True)
grid.addWidget(note, row, 0, 1, 4)
grid.setColumnStretch(3, 1)
# ── Commands ──────────────────────────────────────────────────────────────
def _start_jog(self, axis: str, direction: int):
self._active = (axis, direction)
self._jog_tick()
self._repeat.start()
def _jog_tick(self):
if self._active is None:
return
axis, direction = self._active
self._worker.queue_jog(axis, direction, step_mm=self.step_spin.value())
def stop_jogs(self):
"""Stop the repeat. A move already sent runs to its (short) end."""
self._repeat.stop()
self._active = None
def apply_velocity(self):
"""Push the panel's velocity/acceleration to both axes."""
if self._worker.is_connected:
self._worker.queue_set_velocity(self.vel_spin.value(),
self.accel_spin.value())
# ── Device updates ────────────────────────────────────────────────────────
def _on_connected(self):
self._set_online(True)
self.apply_velocity()
def _on_position(self, x_mm: float, y_mm: float):
self.x_pos_lbl.setText(f"{x_mm:07.3f}")
self.y_pos_lbl.setText(f"{y_mm:07.3f}")
def _set_online(self, on: bool):
if not on:
self.stop_jogs()
self.x_pos_lbl.setText("---.---")
self.y_pos_lbl.setText("---.---")
self.setEnabled(on)
+61 -16
View File
@@ -40,6 +40,10 @@ from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import ( from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header, 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_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector from gui.inspect_bridge import QtAngleInspector
@@ -54,7 +58,6 @@ from t3r_control_panel import T3RControlPanel
DEFAULTS = ScanDefaults.load() 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. # 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 # The suffix keeps it from overwriting the scan itself, which is the one file
# in the directory that cost hours to acquire. # in the directory that cost hours to acquire.
@@ -136,9 +139,6 @@ class DCBiasImageWidget(FigureCanvas):
self.draw_idle() self.draw_idle()
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# ── BBD202 worker ───────────────────────────────────────────────────────────── # ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(PollingQueueWorker): class BBD202Worker(PollingQueueWorker):
@@ -152,13 +152,14 @@ class BBD202Worker(PollingQueueWorker):
super().__init__(poll_interval_s=self.POLL_INTERVAL_S) super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self.controller: ThorlabsServoDriver | None = None self.controller: ThorlabsServoDriver | None = None
self.scanning_active = False # pause polling during a scan 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_pos: tuple[float, float] | None = None
self._last_homed: tuple[bool, bool] | None = None self._last_homed: tuple[bool, bool] | None = None
self._handlers.update({ self._handlers.update({
"connect": self._do_connect, "connect": self._do_connect,
"disconnect": self._do_disconnect, "disconnect": self._do_disconnect,
"jog": self._do_jog, "jog": self._do_jog,
"set_velocity": self._do_set_velocity,
"home_all": self._do_home_all, "home_all": self._do_home_all,
"enable_all": self._do_enable_all, "enable_all": self._do_enable_all,
}) })
@@ -200,15 +201,23 @@ class BBD202Worker(PollingQueueWorker):
self.is_connected = False self.is_connected = False
self.disconnected.emit() 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: if not self.controller:
return return
dest = AXIS_X if axis == "x" else AXIS_Y dest = AXIS_X if axis == "x" else AXIS_Y
step = self._jog_step if step_mm is None else step_mm
try: 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: except TimeoutError:
pass 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): def _do_home_all(self):
if not self.controller: if not self.controller:
return return
@@ -250,8 +259,12 @@ class BBD202Worker(PollingQueueWorker):
def queue_disconnect(self): def queue_disconnect(self):
self._enqueue("disconnect") self._enqueue("disconnect")
def queue_jog(self, axis: str, direction: int): def queue_jog(self, axis: str, direction: int, step_mm: float | None = None):
self._enqueue("jog", axis=axis, direction=direction) 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): def queue_home_all(self):
self._enqueue("home_all") self._enqueue("home_all")
@@ -426,11 +439,18 @@ class HeliosWorker(PollingQueueWorker):
# ── Camera window popup ─────────────────────────────────────────────────────── # ── Camera window popup ───────────────────────────────────────────────────────
class CameraWindow(QWidget): 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() 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) super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-camera.ui", self) uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
self.setWindowTitle("uC480 Camera") self.setWindowTitle("uC480 Camera")
@@ -468,14 +488,37 @@ class CameraWindow(QWidget):
self.uc480_stop_btn.clicked.connect(self._stop_stream) self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close) 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() 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): def showEvent(self, event):
super().showEvent(event) super().showEvent(event)
self._connect_camera() self._connect_camera()
self._start_stream() self._start_stream()
def closeEvent(self, event): 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._stop_stream()
self._disconnect_camera() self._disconnect_camera()
super().closeEvent(event) super().closeEvent(event)
@@ -980,7 +1023,7 @@ class MainWindow(QMainWindow):
self._helios_thread.started.connect(self._helios_worker.run) self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ───────────────────────────────────────────────────── # ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow() self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker)
self._helios_win = HeliosWindow(self._helios_worker) self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow() self._scan_progress = ScanProgressWindow()
@@ -1022,7 +1065,7 @@ class MainWindow(QMainWindow):
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port) self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip) self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port) 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.x_start_edit.setText("10.000")
self.y_start_edit.setText("10.000") self.y_start_edit.setText("10.000")
@@ -1188,10 +1231,12 @@ class MainWindow(QMainWindow):
return return
axis, direction = self._bbd_active_jog axis, direction = self._bbd_active_jog
try: try:
self._bbd_worker._jog_step = float(self.lineEdit_10.text()) step = float(self.lineEdit_10.text())
except ValueError: except ValueError:
self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM step = BBD_JOG_STEP_MM
self._bbd_worker.queue_jog(axis, direction) # 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): def _stop_bbd_jog(self):
self._bbd_jog_timer.stop() self._bbd_jog_timer.stop()
+216
View File
@@ -0,0 +1,216 @@
"""gui.jog_panel: the camera window's T3R and BBD202 jog controls.
The panels are the only place these devices are driven by a held button, so
what matters here is that press/release map onto the right pair of commands
and that the operator's velocity/microstep settings ride along.
"""
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import QApplication
import hardware.t3r_protocol as proto
from gui.jog_panel import BBDJogPanel, T3RJogPanel
@pytest.fixture(scope="module")
def qapp():
yield QApplication.instance() or QApplication([])
class FakeT3R(QObject):
"""The slice of QtT3RAdapter the T3R panel touches."""
handshake_ok = pyqtSignal(int, int, int)
disconnected = pyqtSignal(str)
info_updated = pyqtSignal(int, object)
def __init__(self, is_open=True):
super().__init__()
self.is_open = is_open
self.calls = []
def enable(self, ch):
self.calls.append(("enable", ch))
def disable(self, ch):
self.calls.append(("disable", ch))
def set_microstep(self, ch, microsteps):
self.calls.append(("set_microstep", ch, microsteps))
def jog(self, ch, velocity, accel):
self.calls.append(("jog", ch, velocity, accel))
def stop(self, ch, hard):
self.calls.append(("stop", ch, hard))
class FakeBBD(QObject):
"""The slice of BBD202Worker the BBD panel touches."""
connected = pyqtSignal()
disconnected = pyqtSignal()
position_updated = pyqtSignal(float, float)
def __init__(self, is_connected=True):
super().__init__()
self.is_connected = is_connected
self.calls = []
def queue_jog(self, axis, direction, step_mm=None):
self.calls.append(("jog", axis, direction, step_mm))
def queue_set_velocity(self, max_velocity, acceleration):
self.calls.append(("velocity", max_velocity, acceleration))
def _info(ch, position=0, microsteps=16, enabled=True):
return proto.Info(ch=ch, state=0, position=position, velocity=0,
microsteps=microsteps, run_ma=800, hold_ma=400,
enabled=enabled, comms_ok=True, fault_mask=0)
# ── T3R ───────────────────────────────────────────────────────────────────────
def test_t3r_jog_holds_then_stops(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel.vel_spin.setValue(1234)
panel.accel_spin.setValue(99)
btn = panel._jog_btns[(0, -1)]
btn.pressed.emit()
assert drv.calls == [("jog", 0, -1234, 99)]
btn.released.emit()
assert drv.calls[-1] == ("stop", 0, False)
def test_t3r_release_without_press_sends_nothing(qapp):
"""A stray release must not stop an axis a scan is driving."""
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._jog_btns[(3, 1)].released.emit()
assert drv.calls == []
def test_t3r_stop_jogs_covers_a_lost_release(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._jog_btns[(1, 1)].pressed.emit()
drv.calls.clear()
panel.stop_jogs()
assert drv.calls == [("stop", 1, False)]
panel.stop_jogs() # already stopped: no repeat command
assert drv.calls == [("stop", 1, False)]
def test_t3r_microstep_applies_and_survives_a_stale_poll(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
combo = panel._micro_combos[2]
combo.setCurrentIndex(combo.findData(64))
combo.activated.emit(combo.currentIndex())
assert drv.calls == [("set_microstep", 2, 64)]
# An info frame already in flight still carries the old value.
drv.info_updated.emit(2, _info(2, microsteps=16))
assert combo.currentData() == 64
# Once the device confirms, the combo tracks it again.
drv.info_updated.emit(2, _info(2, microsteps=64))
assert combo.currentData() == 64
drv.info_updated.emit(2, _info(2, microsteps=8))
assert combo.currentData() == 8
def test_t3r_enable_checkbox_follows_the_device(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._enable_chks[0].setChecked(True)
assert drv.calls == [("enable", 0)]
# A device-side state change updates the box without echoing a command.
drv.info_updated.emit(0, _info(0, position=-42, enabled=False))
assert not panel._enable_chks[0].isChecked()
assert drv.calls == [("enable", 0)]
assert panel._pos_lbls[0].text() == "-42"
def test_t3r_panel_tracks_connection(qapp):
drv = FakeT3R(is_open=False)
panel = T3RJogPanel(drv)
assert not panel.isEnabled()
drv.handshake_ok.emit(1, 1, 4)
assert panel.isEnabled()
drv.info_updated.emit(0, _info(0, position=7))
drv.disconnected.emit("cable")
assert not panel.isEnabled()
assert panel._pos_lbls[0].text() == "—"
# ── BBD202 ────────────────────────────────────────────────────────────────────
def test_bbd_jog_repeats_while_held(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
panel.step_spin.setValue(0.25)
panel.x_pos_btn.pressed.emit()
assert worker.calls == [("jog", "x", 1, 0.25)]
assert panel._repeat.isActive()
panel._jog_tick() # what the repeat timer fires
assert worker.calls[-1] == ("jog", "x", 1, 0.25)
panel.x_pos_btn.released.emit()
assert not panel._repeat.isActive()
panel._jog_tick() # a late tick moves nothing
assert len(worker.calls) == 2
def test_bbd_jog_directions(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
for btn, expected in ((panel.x_neg_btn, ("jog", "x", -1, 0.5)),
(panel.y_pos_btn, ("jog", "y", 1, 0.5)),
(panel.y_neg_btn, ("jog", "y", -1, 0.5))):
btn.pressed.emit()
btn.released.emit()
assert worker.calls[-1] == expected
def test_bbd_velocity_is_debounced_then_applied(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
panel.vel_spin.setValue(4.0)
panel.accel_spin.setValue(20.0)
assert worker.calls == [] # nothing sent mid-adjustment
assert panel._vel_debounce.isActive()
panel.apply_velocity()
assert worker.calls == [("velocity", 4.0, 20.0)]
def test_bbd_panel_tracks_connection(qapp):
worker = FakeBBD(is_connected=False)
panel = BBDJogPanel(worker)
assert not panel.isEnabled()
worker.position_updated.emit(12.0, 34.5)
assert panel.x_pos_lbl.text() == "012.000"
assert panel.y_pos_lbl.text() == "034.500"
worker.is_connected = True
worker.connected.emit()
assert panel.isEnabled()
assert worker.calls == [("velocity", panel.vel_spin.value(),
panel.accel_spin.value())]
worker.disconnected.emit()
assert not panel.isEnabled()
assert panel.x_pos_lbl.text() == "---.---"