6e8c1cb7a2
The operator frames a good spot, confirms the two DC levels the detector reads there, and the rig then measures its own tilt: step 1.5 mm either side on X and then on Y, and tilt the platform until those levels come back. The correction that fixes an offset point is the correction that levels the whole travel — height error and tilt effect are both proportional to the offset — so the procedure ends by applying it and leaving it applied. Both directions are measured from the same starting tilt and averaged, which makes their disagreement a flatness read-out rather than something averaged away silently. core/auto_align.py holds the geometry and the search, Qt-free. The three T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without touching X (tilt_response derives that, and the tests pin it — an axis map that drifts would still converge, on the wrong axis). The search is a secant null on the split-detector difference: probe once to learn what a microstep is worth, sign included, then step at the null. It refuses to servo on a scope that has not re-triggered, escalates a probe that reads as no response before calling an axis dead, and stops at a per-axis travel limit. gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event rather than a queued command, because the worker is inside a long handler for the whole run. The camera window carries the button and the progress window, and locks the scan panel and the jog pads while a run owns the stage. Adds immediate MEAN measurements and an acquisition count to the scope driver, and read_bias_mv to core/scope_inspect — the one scalar the inspection state was missing. KNOWN_ISSUES.md records what only the rig can settle: the probe step, the travel limit, the hold current, and whether the piston the X phase applies alongside its tilt matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
15 KiB
Python
386 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.QtWidgets import (
|
||
QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox,
|
||
QLabel, QPushButton, QSpinBox,
|
||
)
|
||
|
||
import hardware.t3r_protocol as proto
|
||
from gui.widgets import mono_font
|
||
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 _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_font(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_font(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_font(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)
|