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:
@@ -0,0 +1,124 @@
|
||||
"""Qt bridge over the headless AutoAligner.
|
||||
|
||||
The procedure is two long blocking runs with an operator decision between
|
||||
them — ``prepare()`` puts the rig in a known state and reads the reference
|
||||
levels, the operator confirms the camera image, then ``run()`` spends a minute
|
||||
or two moving the stage and the tilt platform. Both belong on a worker
|
||||
thread; the window only enqueues and reacts to signals.
|
||||
|
||||
Stopping cannot go through the command queue: while ``run()`` is executing,
|
||||
the worker is inside a handler and will not look at the queue until it
|
||||
returns. The stop request is therefore a threading.Event the core polls
|
||||
between moves (``should_abort``), and the queued "stop" command only handles
|
||||
the tidy-up afterwards.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import pyqtSignal
|
||||
|
||||
from core.auto_align import AlignCallbacks, AutoAligner, AutoAlignAborted
|
||||
from gui.qt_workers import QueueWorker
|
||||
|
||||
|
||||
class QtAutoAligner(QueueWorker):
|
||||
"""Runs an AutoAligner on its own QThread and republishes its events."""
|
||||
|
||||
prepared = pyqtSignal(object) # Reading — the candidate reference
|
||||
prepare_failed = pyqtSignal(str)
|
||||
status_msg = pyqtSignal(str)
|
||||
reading_taken = pyqtSignal(object) # Reading
|
||||
busy_changed = pyqtSignal(bool)
|
||||
offset_done = pyqtSignal(object) # OffsetResult
|
||||
axis_done = pyqtSignal(object) # AxisResult
|
||||
finished = pyqtSignal(object) # AlignResult
|
||||
failed = pyqtSignal(str)
|
||||
aborted = pyqtSignal()
|
||||
stopped = pyqtSignal()
|
||||
|
||||
def __init__(self, stage, scope, t3r, settings=None, on_align_active=None):
|
||||
super().__init__()
|
||||
self._on_align_active = on_align_active
|
||||
self._abort = threading.Event()
|
||||
|
||||
callbacks = AlignCallbacks(
|
||||
on_status=self.status_msg.emit,
|
||||
on_reading=self.reading_taken.emit,
|
||||
on_busy=self.busy_changed.emit,
|
||||
on_offset_done=self.offset_done.emit,
|
||||
on_axis_done=self.axis_done.emit,
|
||||
)
|
||||
kwargs = {"settings": settings} if settings is not None else {}
|
||||
self._aligner = AutoAligner(stage, scope, t3r, callbacks=callbacks,
|
||||
should_abort=self._abort.is_set, **kwargs)
|
||||
self._handlers = {
|
||||
"prepare": self._do_prepare,
|
||||
"run": self._do_run,
|
||||
"stop": self._do_stop,
|
||||
}
|
||||
|
||||
# ── Command submission (GUI thread) ───────────────────────────────────────
|
||||
|
||||
def request_prepare(self):
|
||||
self._abort.clear()
|
||||
self._enqueue("prepare")
|
||||
|
||||
def request_run(self):
|
||||
self._enqueue("run")
|
||||
|
||||
def request_abort(self):
|
||||
"""Stop the procedure at the next move, wherever it has got to."""
|
||||
self._abort.set()
|
||||
|
||||
def request_stop(self):
|
||||
self._abort.set()
|
||||
self._enqueue("stop")
|
||||
|
||||
# ── Handlers (worker thread) ──────────────────────────────────────────────
|
||||
|
||||
def _do_prepare(self):
|
||||
if self._on_align_active is not None:
|
||||
self._on_align_active(True)
|
||||
try:
|
||||
reading = self._aligner.prepare()
|
||||
except Exception as exc:
|
||||
traceback.print_exc()
|
||||
if self._on_align_active is not None:
|
||||
self._on_align_active(False)
|
||||
self.prepare_failed.emit(str(exc))
|
||||
return
|
||||
self.prepared.emit(reading)
|
||||
|
||||
def _do_run(self):
|
||||
try:
|
||||
result = self._aligner.run()
|
||||
except AutoAlignAborted:
|
||||
self.status_msg.emit("Auto-align stopped.")
|
||||
self.aborted.emit()
|
||||
return
|
||||
except Exception as exc:
|
||||
traceback.print_exc()
|
||||
self.failed.emit(str(exc))
|
||||
return
|
||||
self.finished.emit(result)
|
||||
|
||||
def _do_stop(self):
|
||||
try:
|
||||
self._aligner.stop()
|
||||
finally:
|
||||
if self._on_align_active is not None:
|
||||
self._on_align_active(False)
|
||||
self.stopped.emit()
|
||||
|
||||
def _on_stop(self):
|
||||
"""Worker loop exiting — leave the rig parked even if the window went
|
||||
away without a clean stop command reaching the queue."""
|
||||
try:
|
||||
self._aligner.stop()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if self._on_align_active is not None:
|
||||
self._on_align_active(False)
|
||||
+4
-11
@@ -15,13 +15,13 @@ 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 gui.widgets import mono_font
|
||||
from hardware.t3r_driver import T3RDriver
|
||||
|
||||
# BBD202 jog defaults, shared with the main window's worker.
|
||||
@@ -44,13 +44,6 @@ BBD_JOG_REPEAT_MS = 200
|
||||
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)
|
||||
@@ -157,7 +150,7 @@ class T3RJogPanel(QGroupBox):
|
||||
self._micro_combos[ch] = combo
|
||||
|
||||
pos_lbl = QLabel("—")
|
||||
pos_lbl.setFont(_mono(11))
|
||||
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)
|
||||
@@ -280,11 +273,11 @@ class BBDJogPanel(QGroupBox):
|
||||
|
||||
grid.addWidget(QLabel("X"), row, 0)
|
||||
self.x_pos_lbl = QLabel("---.---")
|
||||
self.x_pos_lbl.setFont(_mono(11))
|
||||
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(11))
|
||||
self.y_pos_lbl.setFont(mono_font(11))
|
||||
grid.addWidget(self.y_pos_lbl, row, 3)
|
||||
row += 1
|
||||
|
||||
|
||||
+9
-4
@@ -17,6 +17,14 @@ from PyQt6.QtWidgets import (
|
||||
from hardware.serial_util import scored_ports
|
||||
|
||||
|
||||
def mono_font(size: int = 11) -> QFont:
|
||||
"""Monospace font for numeric read-outs, so columns of digits line up."""
|
||||
font = QFont("Menlo")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
font.setPointSize(size)
|
||||
return font
|
||||
|
||||
|
||||
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
|
||||
"""Update a checkable button without re-triggering its toggled signal."""
|
||||
btn.blockSignals(True)
|
||||
@@ -138,10 +146,7 @@ class LogConsole(QWidget):
|
||||
self.view = QPlainTextEdit()
|
||||
self.view.setReadOnly(True)
|
||||
self.view.setMaximumBlockCount(max_blocks)
|
||||
font = QFont("Menlo")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
font.setPointSize(11)
|
||||
self.view.setFont(font)
|
||||
self.view.setFont(mono_font())
|
||||
layout.addWidget(self.view)
|
||||
|
||||
row = QHBoxLayout()
|
||||
|
||||
Reference in New Issue
Block a user