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
+32
View File
@@ -14,6 +14,11 @@ sits still.
CH1 keeps the acquisition front-end so what is on screen is what a scan would
record. CH3 and CH4 are rescaled as DC bias monitors (see BIAS_* below).
``read_bias_mv`` is the one exception to "nothing is transferred": it reads
the two bias levels back as scalars, not waveforms, because the auto-align
procedure (core.auto_align) has to close a loop on them. The operator still
watches the same screen this configures.
"""
from __future__ import annotations
@@ -46,6 +51,12 @@ BIAS_POSITION_DIV = -3.5
BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"}
# One MEAN measurement carries the shot-to-shot noise of a single record, and
# the alignment loop has to resolve 5 mV. The median of a handful of reads
# rejects the odd outlier without the averaging acquisition mode, which would
# hide exactly the intermittent response the operator is watching CH1 for.
BIAS_READS = 5
def inspect_channel_profiles() -> dict:
"""Channel front-end config for inspection.
@@ -102,3 +113,24 @@ def stop_inspection(scope) -> None:
stop the sweep — it does not try to restore the acquisition profile.
"""
scope.write("ACQuire:STATE STOP")
def read_bias_mv(scope, reads: int = BIAS_READS) -> tuple[float, float]:
"""Read the two DC bias levels in millivolts.
Returns ``(ch3_mv, ch4_mv)`` — DC 1 and DC 2 in the auto-align channel
map. Each channel is read ``reads`` times and reduced by the median.
The two channels are read in separate batches rather than interleaved:
switching the immediate-measurement source costs a round trip, and these
are DC levels, so the few milliseconds between the batches are not a
source of error the way they would be for a transient.
"""
if reads < 1:
raise ValueError("read_bias_mv needs at least one read per channel")
levels = []
for ch in BIAS_CHANNELS:
samples = sorted(scope.measure_immediate(ch, "MEAN") for _ in range(reads))
levels.append(samples[len(samples) // 2] * 1000.0)
return levels[0], levels[1]