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
+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)