f823e2eb42
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>
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""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)
|