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:
@@ -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() == "---.---"
|
||||
Reference in New Issue
Block a user