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:
@@ -0,0 +1,292 @@
|
||||
"""Pre-scan angle inspection, driven entirely by fake hardware.
|
||||
|
||||
The feature's defining constraint is that it reads nothing back from the
|
||||
scope — the operator looks at the instrument. These tests pin that, the scope
|
||||
state the app is responsible for putting the instrument into, and the motion
|
||||
sequence across angles.
|
||||
"""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from core.angle_inspect import AngleInspector, InspectCallbacks
|
||||
from core.rotation import RotationAxis, RotationSettings
|
||||
from core.scan_engine import AXIS_X, AXIS_Y
|
||||
from core.scan_geometry import build_plan
|
||||
from core.scope_inspect import (
|
||||
BIAS_CHANNELS, BIAS_POSITION_DIV, BIAS_SCALE_V_DIV, BIAS_WINDOW_V,
|
||||
INSPECT_TRIG_LEVEL_V, inspect_channel_profiles,
|
||||
)
|
||||
from core.scope_sras import SRAS_CHANNELS
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
|
||||
SPF = 8
|
||||
|
||||
|
||||
def make_plan(num_angles=3):
|
||||
return build_plan(40.0, 30.0, 2.0, 1.0, num_angles, 0.25,
|
||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||
|
||||
|
||||
def build(num_angles=3, seed=1234, callbacks=None, rotator_open=True):
|
||||
trace = Trace()
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
stage = FakeStage(trace, scope=scope)
|
||||
t3r = FakeT3R(trace, is_open=rotator_open)
|
||||
rotator = RotationAxis(t3r, RotationSettings())
|
||||
plan = make_plan(num_angles)
|
||||
insp = AngleInspector(stage, scope, rotator, plan,
|
||||
callbacks=callbacks or InspectCallbacks(),
|
||||
rng=random.Random(seed))
|
||||
return insp, trace, plan
|
||||
|
||||
|
||||
def writes(trace):
|
||||
return [c[1] for c in trace.of("write")]
|
||||
|
||||
|
||||
# ── The defining constraint ──────────────────────────────────────────────────
|
||||
|
||||
def test_inspection_never_reads_a_waveform_back():
|
||||
"""The operator reads the scope; the app must not pull data off it.
|
||||
|
||||
If this fails, someone has added a transfer path to a feature whose whole
|
||||
premise is that there isn't one.
|
||||
"""
|
||||
insp, trace, plan = build()
|
||||
insp.start()
|
||||
for i in range(plan.n_angles):
|
||||
insp.goto_angle(i)
|
||||
insp.new_point()
|
||||
insp.stop()
|
||||
|
||||
forbidden = {"transfer_fastframe", "transfer_fastframe_bulk",
|
||||
"transfer_curve", "set_data_source", "query_wfmoutpre"}
|
||||
assert forbidden.isdisjoint(set(trace.names()))
|
||||
assert "CURVe?" not in writes(trace)
|
||||
|
||||
|
||||
# ── Scope configuration ──────────────────────────────────────────────────────
|
||||
|
||||
def test_start_sets_an_edge_trigger_on_ch2_above_the_scan_level():
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
|
||||
assert "TRIGger:A:TYPe EDGE" in writes(trace)
|
||||
assert trace.of("set_trigger_source")[-1][1] == 2
|
||||
assert trace.of("set_trigger_slope")[-1][1] == "RISE"
|
||||
ch, level = trace.of("set_trigger_level")[-1][1:3]
|
||||
assert (ch, level) == (2, INSPECT_TRIG_LEVEL_V)
|
||||
assert INSPECT_TRIG_LEVEL_V >= 2.0
|
||||
|
||||
|
||||
def test_start_disables_fastframe_averaging_and_the_logic_trigger():
|
||||
"""Everything the scan needs and inspection must not inherit."""
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
|
||||
assert trace.of("set_fastframe_state")[-1][1] is False
|
||||
assert trace.of("set_acquire_mode")[-1][1] == "SAMPLE"
|
||||
w = writes(trace)
|
||||
assert not any("LOGIc" in cmd or "LOGICPattern" in cmd for cmd in w)
|
||||
|
||||
|
||||
def test_start_leaves_the_acquisition_free_running():
|
||||
"""The display has to keep updating while the operator looks at it."""
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
|
||||
w = writes(trace)
|
||||
assert "ACQuire:STOPAfter RUNSTop" in w
|
||||
assert w.index("ACQuire:STOPAfter RUNSTop") < w.index("ACQuire:STATE RUN")
|
||||
assert "ACQuire:STATE STOP" not in w
|
||||
|
||||
|
||||
def test_bias_channels_are_directly_comparable():
|
||||
"""CH3/CH4 must share scale and position or the eye comparison is a lie."""
|
||||
profiles = inspect_channel_profiles()
|
||||
a, b = (profiles[ch] for ch in BIAS_CHANNELS)
|
||||
assert a.scale_v_div == b.scale_v_div
|
||||
assert a.position_div == b.position_div
|
||||
# Same front end as the scan records — only the display changes.
|
||||
for ch in BIAS_CHANNELS:
|
||||
assert profiles[ch].termination_ohm == SRAS_CHANNELS[ch].termination_ohm
|
||||
assert profiles[ch].coupling == SRAS_CHANNELS[ch].coupling
|
||||
assert profiles[ch].bandwidth_hz == SRAS_CHANNELS[ch].bandwidth_hz
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_divisions", [8, 10])
|
||||
def test_bias_window_shows_zero_to_700mv_with_headroom(n_divisions):
|
||||
"""0–700 mV must fit on screen, above ground, on either graticule size.
|
||||
|
||||
Ground sits BIAS_POSITION_DIV divisions below centre, so the visible
|
||||
window runs from (-N/2 - pos)*scale to (+N/2 - pos)*scale.
|
||||
"""
|
||||
half = n_divisions / 2
|
||||
bottom = (-half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
|
||||
top = (half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
|
||||
|
||||
assert bottom < 0.0, "no room below ground for undershoot"
|
||||
assert top > BIAS_WINDOW_V, "700 mV is clipped or sitting on the top edge"
|
||||
# The point of moving the trace down: most of the screen is above ground.
|
||||
assert abs(bottom) < top
|
||||
|
||||
|
||||
def test_ch1_keeps_the_acquisition_front_end():
|
||||
"""What you see at a point is what a scan would record there."""
|
||||
assert inspect_channel_profiles()[1] == SRAS_CHANNELS[1]
|
||||
|
||||
|
||||
# ── Stage and rotation ───────────────────────────────────────────────────────
|
||||
|
||||
def test_start_parks_on_the_first_angle():
|
||||
insp, _, plan = build()
|
||||
point = insp.start()
|
||||
|
||||
assert point.angle_idx == 0
|
||||
assert point.angle_deg == plan.per_angle[0].angle_deg
|
||||
assert insp.current_point == point
|
||||
|
||||
|
||||
def test_the_gate_is_off_for_the_whole_inspection():
|
||||
"""Nothing here is gated, and an armed output keeps driving the line."""
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
insp.goto_angle(2)
|
||||
insp.new_point()
|
||||
|
||||
assert trace.count("set_trigger_gate_off") >= 1
|
||||
assert trace.count("set_trigger_trigout_maxv") == 0
|
||||
assert [c[2] for c in trace.of("arm_scan_gate") if c[2]] == []
|
||||
|
||||
|
||||
def test_points_land_on_the_scan_grid():
|
||||
"""A point the scan would never sample tells you nothing about the scan."""
|
||||
insp, _, plan = build()
|
||||
insp.start()
|
||||
|
||||
for i in range(plan.n_angles):
|
||||
pa = plan.per_angle[i]
|
||||
for _ in range(5):
|
||||
pt = insp.new_point() if insp.angle_idx == i else insp.goto_angle(i)
|
||||
assert pt.angle_idx == i
|
||||
assert pt.y_mm in pa.y_positions
|
||||
assert pa.x_start <= pt.x_mm <= pa.x_start + pa.x_delta
|
||||
|
||||
|
||||
def test_goto_angle_rotates_then_moves():
|
||||
insp, trace, plan = build()
|
||||
insp.start()
|
||||
trace.calls.clear()
|
||||
|
||||
insp.goto_angle(2)
|
||||
|
||||
# t3r_rotate carries the delta, so assert the resulting absolute angle.
|
||||
assert trace.count("t3r_rotate") == 1, "expected exactly one rotation"
|
||||
assert insp._rotator.current_deg == pytest.approx(plan.per_angle[2].angle_deg)
|
||||
moves = trace.of("move_axis_absolute")
|
||||
assert [m[1] for m in moves] == [AXIS_Y, AXIS_X], "Y then X, as the scan does"
|
||||
|
||||
|
||||
def test_new_point_re_rolls_without_rotating():
|
||||
"""Distinguishing a bad spot from a bad angle depends on not rotating."""
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
insp.goto_angle(1)
|
||||
trace.calls.clear()
|
||||
|
||||
first = insp.current_point
|
||||
second = insp.new_point()
|
||||
|
||||
assert second.angle_idx == first.angle_idx == 1
|
||||
assert (second.x_mm, second.y_mm) != (first.x_mm, first.y_mm)
|
||||
assert trace.count("t3r_rotate") == 0, "new_point must not rotate"
|
||||
assert [m[1] for m in trace.of("move_axis_absolute")] == [AXIS_Y, AXIS_X]
|
||||
|
||||
|
||||
def test_next_and_prev_wrap_around():
|
||||
insp, _, plan = build(num_angles=3)
|
||||
insp.start()
|
||||
|
||||
assert insp.next_angle().angle_idx == 1
|
||||
assert insp.next_angle().angle_idx == 2
|
||||
assert insp.next_angle().angle_idx == 0, "should wrap forward"
|
||||
assert insp.prev_angle().angle_idx == plan.n_angles - 1, "should wrap back"
|
||||
|
||||
|
||||
def test_angle_labels_cover_every_angle():
|
||||
insp, _, plan = build(num_angles=9)
|
||||
labels = insp.angle_labels()
|
||||
assert len(labels) == 9
|
||||
assert labels[0].startswith("Angle 1/9")
|
||||
|
||||
|
||||
# ── Guards ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_multi_angle_inspection_requires_the_rotator():
|
||||
insp, _, _ = build(num_angles=3, rotator_open=False)
|
||||
with pytest.raises(RuntimeError, match="T3R rotation stage"):
|
||||
insp.start()
|
||||
|
||||
|
||||
def test_single_angle_inspection_works_without_the_rotator():
|
||||
insp, _, _ = build(num_angles=1, rotator_open=False)
|
||||
point = insp.start()
|
||||
assert point.angle_idx == 0
|
||||
|
||||
|
||||
def test_navigation_before_start_is_rejected():
|
||||
insp, _, _ = build()
|
||||
with pytest.raises(RuntimeError, match="not been started"):
|
||||
insp.goto_angle(1)
|
||||
with pytest.raises(RuntimeError, match="not been started"):
|
||||
insp.new_point()
|
||||
|
||||
|
||||
def test_out_of_range_angle_is_rejected():
|
||||
insp, _, _ = build(num_angles=3)
|
||||
insp.start()
|
||||
with pytest.raises(IndexError):
|
||||
insp.goto_angle(3)
|
||||
|
||||
|
||||
def test_stop_halts_the_sweep_and_sends_the_rotator_home():
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
insp.goto_angle(2)
|
||||
trace.calls.clear()
|
||||
|
||||
insp.stop()
|
||||
|
||||
assert "ACQuire:STATE STOP" in writes(trace)
|
||||
assert trace.count("t3r_rotate") == 1, "GR not sent home"
|
||||
assert insp._rotator.current_deg == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_stop_is_idempotent():
|
||||
insp, trace, _ = build()
|
||||
insp.start()
|
||||
insp.stop()
|
||||
trace.calls.clear()
|
||||
|
||||
insp.stop() # must not re-issue anything or raise
|
||||
|
||||
assert trace.calls == []
|
||||
|
||||
|
||||
def test_busy_callback_brackets_every_move():
|
||||
"""The window disables its controls on this, so it has to pair up."""
|
||||
events = []
|
||||
insp, _, _ = build(callbacks=InspectCallbacks(on_busy=events.append))
|
||||
insp.start()
|
||||
insp.goto_angle(1)
|
||||
insp.new_point()
|
||||
insp.stop()
|
||||
|
||||
assert events, "no busy events emitted"
|
||||
assert events[0] is True and events[-1] is False
|
||||
depth = 0
|
||||
for e in events:
|
||||
depth += 1 if e else -1
|
||||
assert depth in (0, 1), f"unbalanced busy events: {events}"
|
||||
assert depth == 0
|
||||
Reference in New Issue
Block a user