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,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
|
||||
Reference in New Issue
Block a user