Files
scanengine-3/core/scope_inspect.py
T
Thomas K Ales [MSE] 880b05fe86 working state commit
2026-09-25 08:13:03 -05:00

137 lines
5.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]