Pre-scan angle inspection: park the rig per angle, read the response on the scope

A 9-angle scan takes hours, and an angle that responds poorly still produces
rows that look structurally fine in the file — the SAW packet is just not
there. This lets the operator walk the angles first, parking the rig at a
random point in each, and judge the response before committing to the run.

Nothing reads the scope. The operator inspects the instrument directly, so
there is no transfer path, no plotting, and no waveform crossing the module
boundary — test_inspection_never_reads_a_waveform_back pins that, since it is
the kind of premise a later change erodes without noticing.

core/scope_inspect.py — the scope state worth looking at, which is not the
scan's state:
- plain rising-edge trigger on CH2 at 2.0 V, not the scan's logic AND of the
  laser pulse and the stage gate, so a stationary stage still triggers
- FastFrame off, SAMPLE (no averaging) — a weak or intermittent response is
  exactly what is being looked for, and averaging would hide it
- free-running (STOPAfter RUNSTop + STATE RUN) so the trace keeps updating
  while the operator looks at it
- CH1 keeps the acquisition front-end verbatim, so what is on screen is what
  a scan would record
- CH3/CH4 become bias monitors sharing one scale and position, since the
  comparison is by eye and only works if a division means the same on each.
  100 mV/div with ground 3.5 divisions below centre puts 0–700 mV on screen
  with headroom on an 8- or 10-division graticule (the signal never goes
  negative, hence moving the trace down).

core/angle_inspect.py — AngleInspector, headless and Qt-free like ScanEngine.
Points are drawn from the angle's own bounding box: Y from its actual row
positions and X uniformly across its data window, so the point is somewhere
the scan would really sample rather than merely inside the box. New Point
re-rolls without rotating, which is what separates a bad spot on the sample
from a bad angle. The stage gate is held off throughout, and the rotator goes
home on stop.

gui/inspect_bridge.py — QtAngleInspector on the existing QueueWorker base.
Inspection is click-driven rather than one long run, so the worker blocks on
its queue between commands and an open window costs nothing. BBD position
polling is suppressed while inspecting, for the same reason the scan does it:
the shared TX queue.

sc3_aui_app.py — AngleInspectWindow (angle list, prev/next, New Point) driven
off the plan currently entered in the scan panel, so it inspects exactly the
scan about to be run. Navigation locks while the stage moves. The list syncs
via itemClicked rather than currentRowChanged, so echoing the worker's
position back does not re-trigger the move it is reporting.

README picks up the new modules, and scope_burst.py which the previous merge
left out of the structure listing. 114 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-02 12:41:52 -05:00
parent 817da0160c
commit 23546a03f7
7 changed files with 952 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
"""Pre-scan angle inspection: park the rig on a point and let the operator look.
A multi-angle scan can take hours, and an angle that responds poorly produces
rows that look fine in the file but carry no usable SAW packet. This drives
the rig through the same angles the scan will use, parking at a random point
inside each angle's own bounding box so the response can be judged on the
oscilloscope before committing to the run.
Headless and Qt-free, like ScanEngine: gui/inspect_bridge.py wraps it.
No waveform ever crosses this boundary. The operator reads the scope screen
directly; this module's job is only to put the hardware in the right place and
the scope in a state worth looking at (see core.scope_inspect).
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass
from typing import Callable
from core import scope_inspect
from core.rotation import RotationAxis
from core.scan_engine import (
AXIS_X, AXIS_Y, SCAN_ACCEL_MM_S2, SCAN_VELOCITY_MM_S,
)
from core.scan_geometry import DEFAULT_STAGE_LIMITS, ScanPlan, StageLimits
logger = logging.getLogger(__name__)
# Positioning moves only — no data is taken while moving, so there is no
# reason to cross the tray at full scan velocity.
INSPECT_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0
@dataclass(frozen=True)
class InspectionPoint:
"""Where the rig is parked, and which angle it is parked for."""
angle_idx: int
angle_deg: float
x_mm: float
y_mm: float
def describe(self) -> str:
return (f"Angle {self.angle_idx + 1} ({self.angle_deg:.1f}°) "
f"X={self.x_mm:.3f} mm Y={self.y_mm:.3f} mm")
@dataclass
class InspectCallbacks:
"""Progress reporting. Defaults are no-ops so the core needs no front end."""
on_status: Callable[[str], None] = lambda msg: None
on_point: Callable[[InspectionPoint], None] = lambda pt: None
on_busy: Callable[[bool], None] = lambda busy: None
@dataclass
class _State:
angle_idx: int = 0
point: InspectionPoint | None = None
started: bool = False
rotator_ready: bool = False
class AngleInspector:
"""Drives stage + rotator to inspection points across a plan's angles."""
def __init__(self, stage, scope, rotator: RotationAxis | None,
plan: ScanPlan,
callbacks: InspectCallbacks | None = None,
limits: StageLimits = DEFAULT_STAGE_LIMITS,
rng: random.Random | None = None):
self._stage = stage
self._scope = scope
self._rotator = rotator
self._plan = plan
self._cb = callbacks if callbacks is not None else InspectCallbacks()
self._limits = limits
# Injectable so tests can pin the point selection.
self._rng = rng if rng is not None else random.Random()
self._st = _State()
# ── Introspection ─────────────────────────────────────────────────────────
@property
def n_angles(self) -> int:
return self._plan.n_angles
@property
def angle_idx(self) -> int:
return self._st.angle_idx
@property
def current_point(self) -> InspectionPoint | None:
return self._st.point
def angle_labels(self) -> list[str]:
return [f"Angle {i + 1}/{self.n_angles} — {pa.angle_deg:.2f}°"
for i, pa in enumerate(self._plan.per_angle)]
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self) -> InspectionPoint:
"""Configure the hardware and park on the first angle."""
if self._stage is None:
raise RuntimeError("BBD202 not connected")
if self._scope is None:
raise RuntimeError("Oscilloscope not connected")
self._st.rotator_ready = (self._rotator is not None
and self._rotator.is_available)
if self.n_angles > 1 and not self._st.rotator_ready:
raise RuntimeError(
f"Inspecting {self.n_angles} angles requires the T3R rotation "
"stage (GR-axis), but it is not connected. Connect T3R from "
"the T3R panel, or inspect a single-angle plan."
)
self._cb.on_busy(True)
try:
self._cb.on_status("Configuring stage for inspection …")
ctrl = self._stage
for axis in (AXIS_X, AXIS_Y):
ctrl.set_velocity_params(axis,
max_velocity=INSPECT_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
# Nothing here is gated, and an armed trigger output would keep
# driving the gate line on every positioning move.
ctrl.set_trigger_gate_off(AXIS_X)
if self._st.rotator_ready:
self._cb.on_status("Configuring GR axis …")
self._rotator.configure()
self._cb.on_status("Configuring oscilloscope for inspection …")
scope_inspect.configure_inspection(self._scope)
self._st.started = True
return self._goto(0, new_point=True)
finally:
self._cb.on_busy(False)
def stop(self) -> None:
"""Stop the sweep and send the rotator home. Safe to call twice."""
if not self._st.started:
return
self._st.started = False
self._cb.on_busy(True)
try:
try:
scope_inspect.stop_inspection(self._scope)
except Exception:
logger.exception("Could not stop the inspection acquisition")
if self._st.rotator_ready and abs(self._rotator.current_deg) > 0.001:
self._cb.on_status("Returning GR to home …")
try:
self._rotator.return_to_zero()
except Exception:
logger.exception("GR return-to-home failed")
self._cb.on_status("Inspection finished.")
finally:
self._cb.on_busy(False)
# ── Navigation ────────────────────────────────────────────────────────────
def goto_angle(self, angle_idx: int) -> InspectionPoint:
"""Rotate to `angle_idx` and park on a fresh random point there."""
self._require_started()
self._cb.on_busy(True)
try:
return self._goto(angle_idx, new_point=True)
finally:
self._cb.on_busy(False)
def next_angle(self) -> InspectionPoint:
"""Advance one angle, wrapping at the end."""
return self.goto_angle((self._st.angle_idx + 1) % self.n_angles)
def prev_angle(self) -> InspectionPoint:
return self.goto_angle((self._st.angle_idx - 1) % self.n_angles)
def new_point(self) -> InspectionPoint:
"""Re-roll the point within the current angle, without rotating.
One point can be unrepresentative — a bad spot on the sample looks the
same as a bad angle. Re-rolling a few times is how you tell them
apart, so this deliberately skips the rotation.
"""
self._require_started()
self._cb.on_busy(True)
try:
return self._goto(self._st.angle_idx, new_point=True, rotate=False)
finally:
self._cb.on_busy(False)
# ── Internals ─────────────────────────────────────────────────────────────
def _require_started(self):
if not self._st.started:
raise RuntimeError("Inspection has not been started")
def _goto(self, angle_idx: int, new_point: bool,
rotate: bool = True) -> InspectionPoint:
if not 0 <= angle_idx < self.n_angles:
raise IndexError(
f"Angle {angle_idx} out of range (plan has {self.n_angles})")
pa = self._plan.per_angle[angle_idx]
self._st.angle_idx = angle_idx
if rotate and self._st.rotator_ready:
delta = pa.angle_deg - self._rotator.current_deg
if abs(delta) > 0.001:
self._cb.on_status(
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
point = self._pick_point(angle_idx) if new_point else self._st.point
self._cb.on_status(f"Moving to {point.describe()} …")
# Y first, then X — the same order the scan uses to reach a row.
self._stage.move_axis_absolute(AXIS_Y, point.y_mm, timeout=60.0)
self._stage.move_axis_absolute(AXIS_X, point.x_mm, timeout=60.0)
self._st.point = point
self._cb.on_point(point)
self._cb.on_status(f"Parked at {point.describe()}")
return point
def _pick_point(self, angle_idx: int) -> InspectionPoint:
"""A random point on this angle's scan grid.
Y is drawn from the angle's actual row positions and X uniformly from
its data window, so the point is somewhere the scan would really
sample — not merely inside the bounding box.
"""
pa = self._plan.per_angle[angle_idx]
if not pa.y_positions:
raise ValueError(f"Angle {angle_idx + 1} has no rows to inspect")
y = self._rng.choice(pa.y_positions)
x = self._rng.uniform(pa.x_start, pa.x_start + pa.x_delta)
lim = self._limits
if not (lim.x_min <= x <= lim.x_max and lim.y_min <= y <= lim.y_max):
raise ValueError(
f"Inspection point X={x:.3f} Y={y:.3f} is outside the stage "
f"travel ({lim.x_min}–{lim.x_max} × {lim.y_min}–{lim.y_max} mm)"
)
return InspectionPoint(angle_idx=angle_idx, angle_deg=pa.angle_deg,
x_mm=x, y_mm=y)
+104
View File
@@ -0,0 +1,104 @@
"""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).
"""
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"}
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")