Auto-align: level the sample on the DC bias levels from the camera window

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>
This commit is contained in:
Thomas Ales
2026-09-04 14:00:36 -05:00
parent 083cbdaa34
commit 6e8c1cb7a2
13 changed files with 1868 additions and 19 deletions
+369 -3
View File
@@ -18,8 +18,8 @@ from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
from PyQt6.QtGui import QImage, QPixmap
from PyQt6.QtWidgets import (
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
QSizePolicy, QVBoxLayout, QWidget,
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit,
QPushButton, QSizePolicy, QVBoxLayout, QWidget,
)
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
@@ -27,6 +27,7 @@ from matplotlib.figure import Figure
ROOT = Path(__file__).parent
sys.path.insert(0, str(ROOT))
from core.auto_align import DEFAULT_ALIGN, DEFAULT_T_AXIS
from core.config import ScanDefaults
from core.rotation import DEFAULT_ROTATION, RotationAxis
from core.scan_engine import (
@@ -36,11 +37,13 @@ from core.scan_engine import (
from core.saw_check import middle_row_plan
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
from core.scan_resume import is_compatible, plan_resume
from core.scope_inspect import BIAS_LABELS
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
plan_from_header,
)
from gui.align_bridge import QtAutoAligner
from gui.jog_panel import (
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
BBDJogPanel, T3RJogPanel,
@@ -49,6 +52,7 @@ from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector
from gui.scan_bridge import QtScanController
from gui.widgets import mono_font
from hardware.helios_laser import HeliosLaser
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
from hardware.tektronix_base import TektronixOscilloscopeBase
@@ -439,18 +443,62 @@ class HeliosWorker(PollingQueueWorker):
# ── Camera window popup ───────────────────────────────────────────────────────
# The channel map auto-align assumes. Nothing reconfigures the scope inputs —
# CH3 is the max-velocity gate during a scan and the DC 1 monitor here — so the
# operator is asked to confirm the cabling instead of being trusted to
# remember it.
ALIGN_SCOPE_CHANNELS = ((1, "SAW"), (2, "Trigger"), (3, "DC 1"), (4, "DC 2"))
def _align_scope_prompt() -> str:
channels = "\n".join(f" CH{ch} — {role}" for ch, role in ALIGN_SCOPE_CHANNELS)
# The channels arrive on screen carrying the inspection labels, which are
# the names for the same two monitors — say so, or the operator is left
# comparing "DC 1" here against "Bias - A" on the instrument.
labels = " and ".join(BIAS_LABELS[ch] for ch in sorted(BIAS_LABELS))
return (
"Auto-align reads the DC levels off CH3 and CH4. Check the scope is "
"cabled this way before starting:\n\n"
f"{channels}\n\n"
f"The scope will label the last two {labels}.\n\n"
"The laser must be pulsing and the detection beam on the sample — the "
"procedure stops rather than servo on a scope that is not triggering.\n\n"
"Start auto-align?"
)
def _align_reference_prompt(reading) -> str:
return (
"The detector reads, where the stage is standing:\n\n"
f" DC 1 {reading.dc1_mv:8.1f} mV\n"
f" DC 2 {reading.dc2_mv:8.1f} mV\n\n"
"Is the image correct?\n\n"
f"Yes takes these as the good values and holds them to within "
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV at "
f"{DEFAULT_ALIGN.offset_mm:.2f} mm either side of here, first on X "
f"(T1) and then on Y (T0/T2 as a pair)."
)
class CameraWindow(QWidget):
"""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.
Auto-align lives here for the same reason: the operator judges the image
to decide the rig is on a good spot, and that judgement is the first step
of the procedure. It needs the oscilloscope as well, so the button is
only offered when all three are on hand.
"""
closed = pyqtSignal()
align_active = pyqtSignal(bool) # lock the scan panel while aligning
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
bbd_worker: BBD202Worker | None = None,
oscope_worker: "OscopeWorker | None" = None,
parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
@@ -459,6 +507,14 @@ class CameraWindow(QWidget):
self._camera: UC480Camera | None = None
self._stream: CameraStreamThread | None = None
self._t3r_driver = t3r_driver
self._bbd_worker = bbd_worker
self._oscope_worker = oscope_worker
self._align_thread: QThread | None = None
self._align_worker: QtAutoAligner | None = None
self._align_window: "AutoAlignWindow | None" = None
self._align_available = True
# Banner warning when the camera shares a USB controller with serial
# adapters — opening any of those ports (T3R, BBD202, …) collapses
# the delivered frame rate to <2 fps (USB split-transaction
@@ -489,6 +545,13 @@ class CameraWindow(QWidget):
self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close)
# Every one of the three is load-bearing: the T3R moves the tilt
# platform, the BBD202 makes the 1.5 mm steps, and the scope is the
# only thing that can say whether either helped.
self.uc480_auto_align_btn.clicked.connect(self._on_auto_align)
self.uc480_auto_align_btn.setVisible(
None not in (t3r_driver, bbd_worker, oscope_worker))
self.t3r_jog_panel = self.bbd_jog_panel = None
self._build_jog_column(t3r_driver, bbd_worker)
@@ -515,6 +578,8 @@ class CameraWindow(QWidget):
self._start_stream()
def closeEvent(self, event):
# An alignment outlives this window otherwise, and it owns the stage.
self._teardown_align()
# 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):
@@ -609,6 +674,298 @@ class CameraWindow(QWidget):
self.uc480_exposure_slider.setEnabled(camera_ok)
self.uc480_gain_slider.setEnabled(camera_ok)
# ── Auto-align ────────────────────────────────────────────────────────────
def _on_auto_align(self):
if self._align_thread is not None and self._align_thread.isRunning():
if self._align_window is not None:
self._align_window.raise_()
self._align_window.activateWindow()
return
problem = self._align_prerequisite_problem()
if problem:
QMessageBox.warning(self, "Cannot Auto-Align", problem)
return
if QMessageBox.question(self, "Check the Oscilloscope",
_align_scope_prompt()) \
!= QMessageBox.StandardButton.Yes:
return
self._align_thread = QThread(self)
self._align_worker = QtAutoAligner(
stage=self._bbd_worker.controller,
scope=self._oscope_worker.scope,
t3r=self._t3r_driver.driver,
# Same reason the scan and the inspector do it: the procedure
# drives the stage from its own thread, and the position poll
# shares the BBD TX queue.
on_align_active=lambda active: setattr(
self._bbd_worker, "scanning_active", active),
)
self._align_worker.moveToThread(self._align_thread)
window = AutoAlignWindow(self)
self._align_window = window
worker = self._align_worker
worker.status_msg.connect(window.on_status)
worker.reading_taken.connect(window.on_reading)
worker.offset_done.connect(window.on_offset_done)
worker.axis_done.connect(window.on_axis_done)
worker.prepared.connect(self._on_align_prepared)
worker.prepare_failed.connect(self._on_align_prepare_failed)
worker.finished.connect(self._on_align_finished)
worker.failed.connect(self._on_align_failed)
worker.aborted.connect(self._on_align_aborted)
worker.stopped.connect(self._release_align_thread)
worker.error_occurred.connect(window.on_failed)
window.abort_requested.connect(worker.request_abort)
window.close_requested.connect(self._teardown_align)
self._align_thread.started.connect(worker.run)
self._set_align_ui_active(True)
window.show()
self._align_thread.start()
worker.request_prepare()
def _align_prerequisite_problem(self) -> str:
"""Why auto-align cannot start, or "" if it can."""
if self._oscope_worker is None or not self._oscope_worker.is_connected:
return ("The oscilloscope is not connected, and it is the only "
"thing that can read the DC levels. Connect it from the "
"main window and try again.")
if self._bbd_worker is None or not self._bbd_worker.is_connected:
return ("The BBD202 stage is not connected, so the 1.5 mm steps "
"cannot be made. Connect it from the main window and try "
"again.")
if self._t3r_driver is None or not self._t3r_driver.is_open:
return ("The T3R is not connected, so the T-axes cannot be moved. "
"Connect it from the T3R panel and try again.")
return ""
def _on_align_prepared(self, reading):
"""The rig is configured and standing on the point — ask the operator."""
if self._align_window is None:
return
confirmed = QMessageBox.question(
self, "Is the Image Correct?", _align_reference_prompt(reading)
) == QMessageBox.StandardButton.Yes
if not confirmed:
self._align_window.on_status(
"Cancelled: the image was not confirmed. Nothing has moved — "
"re-align by hand and start again.")
self._finish_align()
return
self._align_window.set_reference(reading)
self._align_worker.request_run()
def _on_align_prepare_failed(self, message: str):
if self._align_window is not None:
self._align_window.on_failed(message)
QMessageBox.warning(self, "Cannot Auto-Align", message)
self._finish_align()
def _on_align_finished(self, result):
if self._align_window is not None:
self._align_window.on_finished(result)
self._finish_align()
def _on_align_failed(self, message: str):
if self._align_window is not None:
self._align_window.on_failed(message)
QMessageBox.warning(self, "Auto-Align Failed", message)
self._finish_align()
def _on_align_aborted(self):
if self._align_window is not None:
self._align_window.on_aborted()
self._finish_align()
def _finish_align(self):
"""Park the rig and release the thread, leaving the summary on screen."""
if self._align_worker is not None:
self._align_worker.request_stop()
def _release_align_thread(self):
"""Worker idle — take its thread down. Safe to call more than once."""
if self._align_worker is not None:
self._align_worker.stop_worker()
if self._align_thread is not None:
self._align_thread.quit()
self._align_thread.wait(30000)
self._align_thread = None
self._align_worker = None
self._set_align_ui_active(False)
def _teardown_align(self):
"""Stop an alignment and close its window — the window or camera is
going away, so the procedure cannot be left running."""
window, self._align_window = self._align_window, None
if self._align_worker is not None:
self._align_worker.request_stop()
self._release_align_thread()
if window is not None:
window.close()
def set_align_available(self, available: bool):
"""The main window handing the rig over, or taking it back.
A scan or an angle inspection owns the same stage, so auto-align has
to be off the table while either runs.
"""
self._align_available = available
self._refresh_align_button()
def _refresh_align_button(self):
self.uc480_auto_align_btn.setEnabled(
self._align_available and self._align_thread is None)
def _set_align_ui_active(self, active: bool):
"""A running alignment owns the stage and the T-axes; nothing else
may drive them, here or in the main window."""
self._refresh_align_button()
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
if panel is not None:
panel.stop_jogs()
panel.setEnabled(not active)
self.align_active.emit(active)
class AutoAlignWindow(QWidget):
"""Live progress for one auto-align run, and the summary it ends with.
Shows the deviation from the good values rather than the raw levels: the
procedure is a null search, so how far off it is says more than what it
reads, and 5 mV out of ~400 mV does not show up in the raw number.
"""
abort_requested = pyqtSignal()
close_requested = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("Auto-Align")
self.resize(560, 480)
self._reference = None
self._done = False
layout = QVBoxLayout(self)
self.header_label = QLabel(
f"Stepping {DEFAULT_ALIGN.offset_mm:.2f} mm either side of this "
f"point on X (T1), then on Y (T0/T2 as a pair), re-tilting until "
f"the DC levels come back to within "
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV.\n"
f"T-axes: {DEFAULT_T_AXIS.microsteps} µsteps, "
f"{DEFAULT_T_AXIS.run_current_ma} mA.")
self.header_label.setWordWrap(True)
layout.addWidget(self.header_label)
self.reading_label = QLabel("—")
self.reading_label.setFont(mono_font(13))
self.reading_label.setStyleSheet("font-weight: bold;")
layout.addWidget(self.reading_label)
self.status_label = QLabel("Configuring the rig …")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# The verdict outlives the status line, which keeps reporting the
# parking moves after the answer is known.
self.verdict_label = QLabel()
self.verdict_label.setWordWrap(True)
self.verdict_label.setVisible(False)
layout.addWidget(self.verdict_label)
self.log = QPlainTextEdit(self)
self.log.setReadOnly(True)
self.log.setFont(mono_font(11))
layout.addWidget(self.log, 1)
buttons = QHBoxLayout()
self.stop_btn = QPushButton("Stop")
self.stop_btn.setToolTip(
"Stop at the next move and put the stage back on the reference "
"point. Any tilt already applied stays applied.")
self.stop_btn.clicked.connect(self._on_stop_clicked)
self.close_btn = QPushButton("Close")
self.close_btn.clicked.connect(self.close)
self.close_btn.setEnabled(False)
buttons.addWidget(self.stop_btn)
buttons.addWidget(self.close_btn)
layout.addLayout(buttons)
# ── Worker → window ───────────────────────────────────────────────────────
def set_reference(self, reading):
self._reference = reading
self._append(f"Reference: {reading.describe()}")
def on_status(self, msg: str):
self.status_label.setText(msg)
def on_reading(self, reading):
if self._reference is None:
self.reading_label.setText(reading.describe())
return
d1, d2 = reading.error_vs(self._reference)
self.reading_label.setText(
f"DC1 {reading.dc1_mv:8.1f} mV ({d1:+6.1f}) "
f"DC2 {reading.dc2_mv:8.1f} mV ({d2:+6.1f})")
def on_offset_done(self, result):
self._append(f" {result.describe()}")
def on_axis_done(self, result):
self._append(result.describe())
def on_finished(self, result):
# The per-axis lines are already in the log, put there as they
# happened; only the closing verdict is new.
self._append("")
self._append(result.verdict())
self._set_verdict(result.verdict(), ok=result.ok)
def on_failed(self, message: str):
self._append(f"FAILED: {message}")
self._set_verdict(message, ok=False)
def on_aborted(self):
message = ("Stopped by the operator. Any tilt already applied stays "
"applied.")
self._append(message)
self._set_verdict(message, ok=False)
# ── Window → worker ───────────────────────────────────────────────────────
def _on_stop_clicked(self):
self.stop_btn.setEnabled(False)
self.on_status("Stopping at the next move …")
self.abort_requested.emit()
def _set_verdict(self, text: str, ok: bool):
self.verdict_label.setText(text)
self.verdict_label.setStyleSheet(
f"font-weight: bold; color: {'green' if ok else '#b8860b'};")
self.verdict_label.setVisible(True)
self._done = True
self.stop_btn.setEnabled(False)
self.close_btn.setEnabled(True)
def _append(self, line: str):
self.log.appendPlainText(line)
def closeEvent(self, event):
# Closing part-way through is a stop: the procedure owns the stage,
# and nothing else can call it off once this window is gone.
if not self._done:
self.abort_requested.emit()
self.close_requested.emit()
super().closeEvent(event)
# ── Helios laser panel ────────────────────────────────────────────────────────
@@ -1024,7 +1381,8 @@ class MainWindow(QMainWindow):
self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker)
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker,
self._oscope_worker)
self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow()
@@ -1146,6 +1504,7 @@ class MainWindow(QMainWindow):
self._camera_win.closed.connect(
lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window")
)
self._camera_win.align_active.connect(self._on_camera_align_active)
# Helios
self.show_helios_toggle.toggled.connect(self._on_helios_toggle)
@@ -1331,6 +1690,13 @@ class MainWindow(QMainWindow):
"""Both entry points drive the same rig, so they lock and unlock together."""
self.start_scan_btn.setEnabled(enabled)
self.saw_check_btn.setEnabled(enabled)
# Auto-align lives in the camera window but drives this same stage.
self._camera_win.set_align_available(enabled)
def _on_camera_align_active(self, active: bool):
"""An alignment started or finished in the camera window."""
self._set_scan_buttons_enabled(not active)
self.inspect_angles_btn.setEnabled(not active)
def _on_browse_save_dir(self):
d = QFileDialog.getExistingDirectory(