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:
+107
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user