6e8c1cb7a2
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>
137 lines
5.5 KiB
Python
137 lines
5.5 KiB
Python
"""Oscilloscope configuration for pre-scan angle inspection.
|
||
|
||
Inspection is read-on-the-instrument: nothing in this module transfers or
|
||
plots waveform data. The app puts the scope into a free-running, edge-
|
||
triggered state and drives the stage to the point being inspected; the
|
||
operator judges the SAW response and the bias levels on the scope screen.
|
||
|
||
That split is deliberate. A scan's acquisition trigger is the logic AND of
|
||
the laser pulse and the stage's max-velocity gate, and its transfers are
|
||
FastFrame blocks — neither is useful for looking at one point by eye. Here
|
||
the trigger is a plain edge on the laser pulse, FastFrame is off, and the
|
||
acquisition free-runs, so the display updates continuously while the stage
|
||
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
|
||
|
||
import logging
|
||
from dataclasses import replace
|
||
|
||
from core.scope_sras import SAMPLE_RATE_HZ, SRAS_CHANNELS, configure_channels
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# CH2 carries the laser pulse. The scan triggers it at 0.5 V as one term of a
|
||
# logic AND; inspection triggers well above that so a slow edge or a noisy
|
||
# baseline cannot free-run the display.
|
||
INSPECT_TRIG_LEVEL_V = 2.0
|
||
|
||
# CH3/CH4 are the DC bias monitors during inspection. The signal never goes
|
||
# negative and spans roughly 0–700 mV, so both channels get the *same* scale
|
||
# and position — the point of inspecting them is comparing the two by eye, and
|
||
# that only works if a division means the same thing on each.
|
||
#
|
||
# Ground sits BIAS_POSITION_DIV divisions below centre, which puts the whole
|
||
# 0–700 mV range above the centre line with a little room underneath for
|
||
# undershoot. With 100 mV/div and ground 3.5 divisions low, the visible window
|
||
# runs from about -50 mV to +750 mV on an 8-division display and wider on a
|
||
# 10-division one, so 0–700 mV sits comfortably inside either.
|
||
BIAS_CHANNELS = (3, 4)
|
||
BIAS_WINDOW_V = 0.700
|
||
BIAS_SCALE_V_DIV = 0.100
|
||
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.
|
||
|
||
CH1 and CH2 are the acquisition profiles verbatim. CH3 and CH4 differ
|
||
only in label, scale and position — termination, coupling and bandwidth
|
||
stay as the scan sets them, so the bias reading is the same measurement
|
||
the scan records, just displayed usefully.
|
||
"""
|
||
profiles = dict(SRAS_CHANNELS)
|
||
for ch in BIAS_CHANNELS:
|
||
profiles[ch] = replace(
|
||
SRAS_CHANNELS[ch],
|
||
label=BIAS_LABELS[ch],
|
||
scale_v_div=BIAS_SCALE_V_DIV,
|
||
position_div=BIAS_POSITION_DIV,
|
||
)
|
||
return profiles
|
||
|
||
|
||
def configure_inspection(scope) -> None:
|
||
"""Put the scope into free-running inspection mode.
|
||
|
||
Leaves the acquisition running, so the display stays live while the
|
||
operator moves between angles and points.
|
||
"""
|
||
configure_channels(scope, inspect_channel_profiles())
|
||
|
||
# Plain edge trigger on the laser pulse — no logic pattern, so the stage
|
||
# gate plays no part and a stationary stage still triggers.
|
||
scope.write("TRIGger:A:TYPe EDGE")
|
||
scope.set_trigger_source(2)
|
||
scope.set_trigger_slope("RISE")
|
||
scope.set_trigger_level(2, INSPECT_TRIG_LEVEL_V)
|
||
scope.set_trigger_mode("NORMAL")
|
||
|
||
# No averaging: a weak or intermittent SAW response is exactly what the
|
||
# operator is looking for, and averaging would hide it.
|
||
scope.set_acquire_mode("SAMPLE")
|
||
scope.set_fastframe_state(False)
|
||
|
||
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
||
scope.write("HORizontal:POSition 30")
|
||
|
||
# Free-run rather than single-sequence, so the trace keeps updating.
|
||
scope.write("ACQuire:STOPAfter RUNSTop")
|
||
scope.write("ACQuire:STATE RUN")
|
||
|
||
|
||
def stop_inspection(scope) -> None:
|
||
"""Halt the free-running acquisition.
|
||
|
||
The next scan reconfigures the scope from scratch, so this only needs to
|
||
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]
|