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
+107
View File
@@ -258,6 +258,9 @@ class FakeT3R:
self._t = trace
self.is_open = is_open
self._motion_completes = motion_completes
# Microsteps commanded per channel, so a test can read the tilt the
# platform ended up at rather than replaying the move trace.
self.positions = {ch: 0 for ch in range(4)}
def set_microstep(self, ch, micro):
self._t.record("t3r_set_microstep", ch, micro)
@@ -273,9 +276,113 @@ class FakeT3R:
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
* angle_deg / 360.0)
def move(self, ch, steps, velocity, accel):
self._t.record("t3r_move", ch, steps)
self.positions[ch] += steps
def rotate_stage(self, angle_deg, microsteps, velocity, accel):
self._t.record("t3r_rotate", round(angle_deg, 6))
def wait_motion_done(self, ch, timeout):
self._t.record("t3r_wait_motion_done", ch)
return self._motion_completes
class FakeAlignRig:
"""A tilted sample on the tilt platform, as the DC levels would read it.
The detection beam is fixed and the stage carries the sample under it, so
the height error under the beam is the sample's slope times how far the
stage has moved off the reference point. The T-axes tilt the sample the
other way: their three heights define a plane, and its slope adds to the
sample's. Nulling the split-detector difference therefore means cancelling
the sample slope — which is exactly what an aligner has to work out.
The plane is fitted by least squares here, rather than reusing
core.auto_align's closed form, so the two are independent statements of
the same geometry.
``curvature_mv_per_mm2`` bends the surface: a curved sample needs opposite
corrections at +1.5 mm and -1.5 mm, which is the disagreement the
procedure is supposed to report instead of averaging away.
"""
# Actuator azimuths on the platform, in degrees from stage +X.
AZIMUTH_DEG = {0: 120.0, 1: 0.0, 2: 240.0}
def __init__(self, stage, t3r, ref_mm=(50.0, 40.0),
x_slope_mv_per_mm=40.0, y_slope_mv_per_mm=-25.0,
tilt_gain_mv_per_mm=0.2, base_mv=400.0,
curvature_mv_per_mm2=0.0, jitter_mv=0.0):
self._stage = stage
self._t3r = t3r
self.ref_mm = ref_mm
self.x_slope_mv_per_mm = x_slope_mv_per_mm
self.y_slope_mv_per_mm = y_slope_mv_per_mm
self.tilt_gain_mv_per_mm = tilt_gain_mv_per_mm
self.base_mv = base_mv
self.curvature_mv_per_mm2 = curvature_mv_per_mm2
self.jitter_mv = jitter_mv
self._reads = 0
# -- geometry -----------------------------------------------------------
def platform_tilt(self):
"""(x_tilt, y_tilt) of the plane through the three actuator heights."""
import numpy as np
rows, heights = [], []
for ch, azimuth in self.AZIMUTH_DEG.items():
theta = np.radians(azimuth)
rows.append([1.0, np.cos(theta), np.sin(theta)])
heights.append(float(self._t3r.positions[ch]))
_, x_tilt, y_tilt = np.linalg.lstsq(np.array(rows), np.array(heights),
rcond=None)[0]
return float(x_tilt), float(y_tilt)
def slopes_mv_per_mm(self):
"""The residual sample slope the beam sees, after the platform tilt."""
x_tilt, y_tilt = self.platform_tilt()
return (self.x_slope_mv_per_mm + self.tilt_gain_mv_per_mm * x_tilt,
self.y_slope_mv_per_mm + self.tilt_gain_mv_per_mm * y_tilt)
def difference_mv(self):
x_off = self._stage.positions[0] - self.ref_mm[0]
y_off = self._stage.positions[1] - self.ref_mm[1]
slope_x, slope_y = self.slopes_mv_per_mm()
return (slope_x * x_off + slope_y * y_off
+ self.curvature_mv_per_mm2 * (x_off ** 2 + y_off ** 2))
# -- what the scope reports ---------------------------------------------
def level_v(self, channel):
"""CH3 and CH4 as volts: the difference straddling a constant sum.
The sum is fixed because tilt steers the beam across the detector
rather than changing how much light comes back — so a nulled
difference does put both levels back where they were.
"""
self._reads += 1
# A deterministic alternating wobble, so a test can check the median
# of several reads is what keeps the loop stable.
jitter = self.jitter_mv * (1 if self._reads % 2 else -1)
half = 0.5 * self.difference_mv()
mv = self.base_mv + (half if channel == 3 else -half) + jitter
return mv / 1000.0
class FakeAlignScope(FakeScope):
"""FakeScope that also answers the DC measurements auto-align reads."""
def __init__(self, trace: Trace, rig: FakeAlignRig, samples_per_frame=8,
acquisitions_advance=True):
super().__init__(trace, samples_per_frame=samples_per_frame)
self._rig = rig
self._acq = 0
self._advance = acquisitions_advance
def measure_immediate(self, channel, measurement_type="MEAN"):
self._t.record("measure_immediate", channel, measurement_type)
return self._rig.level_v(channel)
def get_acquisition_count(self):
if self._advance:
self._acq += 1
return self._acq
+312
View File
@@ -0,0 +1,312 @@
"""Auto-align, driven entirely by fake hardware.
The feature is a closed loop over hardware, so the tests are built round a
model of the rig (fakes.FakeAlignRig): a sample at a known tilt, a platform
whose three actuators tilt it, and DC levels that follow from both. A test
therefore asks the question the operator does — is the sample level now? —
rather than replaying a command sequence.
The other half is geometry. Which axis moves for which stage direction is
the one thing here that cannot be discovered at run time, and getting it
wrong would still converge (on the wrong axis, at the wrong point), so it is
pinned separately and explicitly.
"""
from dataclasses import replace
import pytest
from core.auto_align import (
AutoAligner, AutoAlignAborted, AutoAlignError, DEFAULT_ALIGN,
T_AXIS_AZIMUTH_DEG, X_TILT, Y_TILT, tilt_response,
)
from core.scan_engine import AXIS_X, AXIS_Y
from core.scope_inspect import BIAS_CHANNELS, BIAS_SCALE_V_DIV, INSPECT_TRIG_LEVEL_V
from fakes import FakeAlignRig, FakeAlignScope, FakeStage, FakeT3R, Trace
REF_MM = (50.0, 40.0)
# No settle: the sleeps are there for the instrument, and every test here
# takes a few dozen readings.
FAST = replace(DEFAULT_ALIGN, settle_s=0.0)
def build(*, settings=FAST, acquisitions_advance=True, ref_mm=REF_MM,
should_abort=lambda: False, **rig_kwargs):
trace = Trace()
stage = FakeStage(trace)
stage.positions = list(ref_mm)
t3r = FakeT3R(trace)
rig = FakeAlignRig(stage, t3r, ref_mm=ref_mm, **rig_kwargs)
scope = FakeAlignScope(trace, rig, acquisitions_advance=acquisitions_advance)
aligner = AutoAligner(stage, scope, t3r, settings=settings,
should_abort=should_abort)
return aligner, rig, trace, stage, t3r
def moves(trace, ch=None):
return [c for c in trace.of("t3r_move") if ch is None or c[1] == ch]
# ── Geometry: the half that cannot be discovered at run time ─────────────────
def test_tilt_groups_are_the_moves_they_claim_to_be():
"""X tilts along X only, Y along Y only — otherwise the phases interfere.
If this fails, the azimuth map and the groups have drifted apart and the
Y phase would be undoing the X phase's correction.
"""
x_piston, x_x, x_y = tilt_response(X_TILT)
y_piston, y_x, y_y = tilt_response(Y_TILT)
assert x_x != 0 and x_y == pytest.approx(0.0, abs=1e-9)
assert y_y != 0 and y_x == pytest.approx(0.0, abs=1e-9)
# The Y pair is equal and opposite, so it lifts nothing on average; the
# single X axis unavoidably lifts the platform as well as tilting it.
assert y_piston == pytest.approx(0.0, abs=1e-9)
assert x_piston != 0
def test_x_is_corrected_by_the_axis_lying_along_x():
"""T1 sits at 0°, so it is the one that tilts the platform along X."""
assert T_AXIS_AZIMUTH_DEG[1] == 0.0
assert set(X_TILT.weights) == {1}
def test_y_is_corrected_by_the_other_two_as_an_opposed_pair():
assert set(Y_TILT.weights) == {0, 2}
assert Y_TILT.weights[0] == -Y_TILT.weights[2]
# ── The loop does what it is for ─────────────────────────────────────────────
def test_alignment_cancels_the_sample_slope_on_both_axes():
"""The point of the whole procedure: a level sample when it finishes."""
aligner, rig, _, _, _ = build()
aligner.prepare()
result = aligner.run()
slope_x, slope_y = rig.slopes_mv_per_mm()
# Residual slope over the +/-1.5 mm the scan cares about, in mV.
assert abs(slope_x * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
assert abs(slope_y * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
assert result.ok
def test_every_search_ends_inside_the_tolerance():
aligner, _, _, _, _ = build()
reference = aligner.prepare()
result = aligner.run()
for axis in result.axes:
for offset in axis.offsets:
assert offset.converged, offset.describe()
assert offset.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
def test_a_flat_sample_gives_the_same_answer_in_both_directions():
"""Both offsets measure one angle, so on a plane they must agree.
The agreement is what licenses averaging them; see the curved case below
for what happens when it does not hold.
"""
aligner, _, _, _, _ = build()
aligner.prepare()
result = aligner.run()
for axis in result.axes:
plus, minus = axis.offsets
assert plus.correction_steps == pytest.approx(minus.correction_steps,
rel=0.02, abs=5.0)
assert axis.disagreement_steps < 10.0
assert axis.applied
def test_a_curved_sample_is_reported_rather_than_averaged_away():
"""Curvature needs opposite corrections either side, and says so."""
# Big enough that the near side is still outside the tolerance once the
# far side's error has been curved past it — otherwise one search has
# nothing to do and the disagreement never shows up.
aligner, _, _, _, _ = build(curvature_mv_per_mm2=60.0)
aligner.prepare()
result = aligner.run()
x_axis = result.axes[0]
plus, minus = x_axis.offsets
assert plus.correction_steps * minus.correction_steps < 0 # opposite signs
assert x_axis.disagreement_steps > 100.0
def test_the_search_survives_a_noisy_detector():
"""Five reads and a median, so a wobbling level still converges."""
aligner, _, _, _, _ = build(jitter_mv=1.5)
reference = aligner.prepare()
result = aligner.run()
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
# ── Which hardware moves, and how ───────────────────────────────────────────
def test_the_x_phase_moves_t1_and_the_y_phase_moves_t0_and_t2():
"""The phases stay on their own axes, in the order X then Y."""
aligner, _, trace, _, t3r = build()
aligner.prepare()
aligner.run()
channels = [c[1] for c in moves(trace)]
first_y = next(i for i, ch in enumerate(channels) if ch in (0, 2))
assert set(channels[:first_y]) == {1}, "the X phase moved something else"
assert set(channels[first_y:]) == {0, 2}, "the Y phase moved something else"
# The Y pair ends equal and opposite: anything else is a tilt along X the
# Y phase had no business applying.
assert t3r.positions[0] == -t3r.positions[2]
assert t3r.positions[1] != 0
def test_the_rotation_axis_is_never_touched():
"""GR carries the scan's angle; an alignment that moved it would silently
re-datum every subsequent scan."""
aligner, _, trace, _, t3r = build()
aligner.prepare()
aligner.run()
assert moves(trace, ch=3) == []
assert t3r.positions[3] == 0
assert [c for c in trace.of("t3r_enable") if c[1] == 3] == []
def test_the_t_axes_are_configured_before_they_are_moved():
"""32 microsteps and 600 mA, applied rather than assumed — a correction is
reported in microsteps, so what a microstep means has to be pinned."""
aligner, _, trace, _, _ = build()
aligner.prepare()
for ch in (0, 1, 2):
assert ("t3r_set_microstep", ch, 32) in trace.calls
run_ma = [c for c in trace.of("t3r_set_current") if c[1] == ch]
assert run_ma and run_ma[0][2] == 600
assert ("t3r_enable", ch) in trace.calls
names = trace.names()
assert "t3r_move" not in names[:names.index("t3r_enable")]
def test_the_stage_steps_either_side_and_comes_back():
aligner, _, trace, stage, _ = build()
aligner.prepare()
aligner.run()
aligner.stop()
x_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_X]
y_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_Y]
off = DEFAULT_ALIGN.offset_mm
assert REF_MM[0] + off in x_targets and REF_MM[0] - off in x_targets
assert REF_MM[1] + off in y_targets and REF_MM[1] - off in y_targets
assert stage.positions == list(REF_MM)
def test_the_scope_is_put_into_the_bias_reading_state():
"""The same free-running, edge-triggered state the angle inspector uses:
the operator has to be able to read CH1 while this runs."""
aligner, _, trace, _, _ = build()
aligner.prepare()
scales = {c[1]: c[2] for c in trace.of("set_channel_scale")}
for ch in BIAS_CHANNELS:
assert scales[ch] == BIAS_SCALE_V_DIV
assert ("set_trigger_level", 2, INSPECT_TRIG_LEVEL_V) in trace.calls
assert ("set_fastframe_state", False) in trace.calls
assert "ACQuire:STATE RUN" in [c[1] for c in trace.of("write")]
# Only the two bias channels are ever measured.
assert {c[1] for c in trace.of("measure_immediate")} == set(BIAS_CHANNELS)
def test_the_gate_is_dropped_before_anything_moves():
"""An armed TRIGOUT would drive the scan gate on every positioning move."""
aligner, _, trace, _, _ = build()
aligner.prepare()
assert ("set_trigger_gate_off", AXIS_X) in trace.calls
# ── Refusals ────────────────────────────────────────────────────────────────
def test_a_dead_axis_stops_the_procedure():
"""No response to a probe, however large: something is wrong upstream of
the tilt platform, and stepping the actuators further will not find it."""
aligner, _, _, _, _ = build(tilt_gain_mv_per_mm=0.0)
aligner.prepare()
with pytest.raises(AutoAlignError, match="laser"):
aligner.run()
def test_a_scope_that_never_retriggers_stops_the_procedure():
"""A stale record reads as a rock-steady measurement — the one failure the
loop cannot see for itself."""
aligner, _, _, _, _ = build(acquisitions_advance=False)
with pytest.raises(AutoAlignError, match="not triggered"):
aligner.prepare()
def test_an_axis_that_would_run_out_of_travel_stops_the_procedure():
aligner, _, _, _, _ = build(x_slope_mv_per_mm=200.0,
tilt_gain_mv_per_mm=0.01)
aligner.prepare()
with pytest.raises(AutoAlignError, match="safety limit"):
aligner.run()
def test_there_has_to_be_room_either_side_of_the_reference_point():
aligner, _, _, _, _ = build(ref_mm=(0.5, 40.0))
with pytest.raises(AutoAlignError, match="either side"):
aligner.prepare()
def test_run_before_the_operator_confirms_is_refused():
aligner, _, _, _, _ = build()
with pytest.raises(AutoAlignError, match="prepare"):
aligner.run()
def test_missing_hardware_is_named():
trace = Trace()
stage = FakeStage(trace)
t3r = FakeT3R(trace, is_open=False)
rig = FakeAlignRig(stage, t3r)
scope = FakeAlignScope(trace, rig)
with pytest.raises(AutoAlignError, match="T3R"):
AutoAligner(stage, scope, t3r, settings=FAST).prepare()
with pytest.raises(AutoAlignError, match="Oscilloscope"):
AutoAligner(stage, None, t3r, settings=FAST).prepare()
with pytest.raises(AutoAlignError, match="BBD202"):
AutoAligner(None, scope, t3r, settings=FAST).prepare()
def test_an_abort_stops_the_run_and_still_parks_the_stage():
"""Stopping is the operator's, so it must not leave the stage 1.5 mm off
the point they were looking at."""
calls = {"n": 0}
def abort_after_a_few_moves():
calls["n"] += 1
return calls["n"] > 12
aligner, _, _, stage, _ = build(should_abort=abort_after_a_few_moves)
aligner.prepare()
with pytest.raises(AutoAlignAborted):
aligner.run()
aligner.stop()
assert stage.positions == list(REF_MM)
def test_stop_leaves_the_correction_applied():
"""The tilt is the result — a stop parks the stage, not the platform."""
aligner, _, _, _, t3r = build()
aligner.prepare()
aligner.run()
applied = dict(t3r.positions)
aligner.stop()
assert t3r.positions == applied
+45
View File
@@ -38,6 +38,51 @@ def test_sc3_aui_main_window(qapp):
_pump(qapp)
def test_camera_window_without_hardware_is_a_plain_viewer(qapp):
"""No stage, no scope, no auto-align button to press."""
import sc3_aui_app
win = sc3_aui_app.CameraWindow()
_pump(qapp)
try:
assert not win.uc480_auto_align_btn.isVisible()
finally:
win.deleteLater()
_pump(qapp)
def test_camera_window_auto_align_needs_every_device(qapp):
"""The button appears once the three devices exist, and says which one is
missing rather than starting and failing on the rig."""
import sc3_aui_app
from gui.qt_t3r import QtT3RAdapter
win = sc3_aui_app.CameraWindow(QtT3RAdapter(), sc3_aui_app.BBD202Worker(),
sc3_aui_app.OscopeWorker())
_pump(qapp)
try:
assert win.uc480_auto_align_btn.isVisibleTo(win)
assert "oscilloscope" in win._align_prerequisite_problem()
finally:
win.deleteLater()
_pump(qapp)
def test_auto_align_window_logs_a_result(qapp):
import sc3_aui_app
from core.auto_align import AlignResult, Reading
win = sc3_aui_app.AutoAlignWindow()
_pump(qapp)
try:
reference = Reading(400.0, 400.0)
win.set_reference(reference)
win.on_reading(Reading(403.0, 397.0))
win.on_finished(AlignResult(reference=reference, final=reference))
assert "Reference" in win.log.toPlainText()
assert win.close_btn.isEnabled()
finally:
win.deleteLater()
_pump(qapp)
def test_sras_viewer_window(qapp):
import sras_viewer
win = sras_viewer.SrasViewerWindow()