293 lines
10 KiB
Python
Executable File
293 lines
10 KiB
Python
Executable File
"""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
|