SAW quality check: one middle row per angle, and a viewer that overlays them
A full multi-angle scan takes hours, and a rig whose angles disagree produces all of them before anyone finds out. This adds a test mode that acquires one row per angle — the row-wise middle of the ROI — and a viewer that puts every angle's SAW frequency on one graph. The default 80×50 mm ROI at 5 angles goes from 1461 rows to 5. Why the middle row answers an alignment question at all: build_plan centres every angle's rotated bounding box on the same nominal ROI centre, so each angle's middle row crosses that one point on the sample. All the angles measure the same material, so a spread in their frequencies belongs to the rig rather than to where each row happened to land. test_every_angles_middle_row_ crosses_the_roi_centre pins that premise, since the whole comparison rests on it and nothing else in the geometry code would notice it breaking. core/saw_check.py — both halves of the mode, kept together because neither is much use alone. middle_row_plan() reduces a ScanPlan to one row per angle (n_rows // 2, the upper of two centre rows when even); frequency_traces() and alignment_summary() turn the resulting file back into per-angle frequency traces and the scalars an operator is actually asking about — the spread of the per-angle medians, the worst drift along a row, the sparsest row. The verdict thresholds are labelled as rules of thumb, not physics: an anisotropic sample genuinely varies with angle, so a wide spread is a prompt to look at the curves rather than a verdict. Format v10: byte-identical to v6, one row per angle. The version byte earns its keep because the two are otherwise indistinguishable — a v6 scan aborted after its first row is not a check, and a reader guessing from the row count would read a failed scan as a deliberate measurement. create_scan_file() enforces the one-row rule at write time, since nothing downstream can recover from a v10 file that breaks it. ScanEngine gains file_version and is otherwise untouched: the acquisition, the abort/pause path and the background capture are the scan's, unchanged. sras_scan_manager.py now carries the source file's version through an export instead of stamping v6 on everything, which the wider reader would otherwise have made a lie. saw_check_viewer.py — frequency along the row, one curve per angle, over a common offset axis so the curves lie on the same piece of sample; a summary of each angle's median ±1σ against angle; and the per-angle numbers in a table. Analysis parameters (DC threshold, background, time gate) recompute on a worker thread; display ones (smoothing, axis, MHz↔m/s) only redraw. A full v6 scan opens too — the same middle row is pulled out of it — so a finished scan can be re-examined with the check's own read-out. In the app, a check finishes by handing the operator the file and an "Open Viewer" button rather than shutting the rig down the way a completed scan does. Burst mode is not offered: one row per angle means every burst would be a single row, so it buys nothing and still pays for the gate preflight. 137 tests passing, ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
"""Middle-row SAW quality check: plan reduction, the v10 file, and the read-out.
|
||||
|
||||
The acquisition half runs on the same fake rig as the scan tests; the
|
||||
analysis half runs on a synthetic v10 file whose CH1 is a pure sine at a
|
||||
known FFT bin, so the frequency a trace reports is a number the test knows
|
||||
in advance rather than one it copies from the implementation.
|
||||
"""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.rotation import RotationAxis, RotationSettings
|
||||
from core.saw_check import (
|
||||
SPREAD_GOOD_PCT, alignment_summary, frequency_traces, middle_row_index,
|
||||
middle_row_plan,
|
||||
)
|
||||
from core.scan_engine import ScanCallbacks, ScanEngine
|
||||
from core.scan_geometry import ScanGeometryError, build_plan
|
||||
from core.sras_format import (
|
||||
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file,
|
||||
)
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
|
||||
SAMPLE_RATE = 6.25e9
|
||||
SPF = 256
|
||||
LASER_FREQ_HZ = 20000.0
|
||||
VELOCITY_MM_S = 100.0
|
||||
PREAMBLES = [f"WFMOUTPRE:CH{ch};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
|
||||
for ch in SCAN_CHANNELS]
|
||||
# adc_to_mv with those constants maps 0 → +136 mV and -120 → -51 mV, so a
|
||||
# frame of zeros passes a 50 mV CH4 gate and a frame of -120 does not.
|
||||
DC_THRESHOLD_MV = 50.0
|
||||
CH4_PASS = bytes(SPF)
|
||||
CH4_FAIL = bytes([256 - 120]) * SPF
|
||||
|
||||
|
||||
def full_plan(num_angles=3, y_delta=0.05):
|
||||
"""A small ROI, well inside the stage limits, with several rows per angle."""
|
||||
return build_plan(40.0, 30.0, 0.02, y_delta, num_angles, 0.01,
|
||||
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=VELOCITY_MM_S)
|
||||
|
||||
|
||||
def bin_mhz(k: int) -> float:
|
||||
return k * SAMPLE_RATE / SPF / 1e6
|
||||
|
||||
|
||||
def sine_frame(k: int) -> bytes:
|
||||
"""One frame holding a pure sine at FFT bin `k`."""
|
||||
n = np.arange(SPF)
|
||||
return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes()
|
||||
|
||||
|
||||
def write_check(path, bins, n_masked_frames=0, plan=None):
|
||||
"""A synthetic v10 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`."""
|
||||
plan = plan if plan is not None else middle_row_plan(full_plan(len(bins)))
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF),
|
||||
version=VERSION_SAW_CHECK)
|
||||
try:
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
wave = sine_frame(bins[ai])
|
||||
for ch in SCAN_CHANNELS:
|
||||
for fi in range(pa.n_frames):
|
||||
if ch == 1:
|
||||
f.write(wave)
|
||||
elif ch == 3:
|
||||
f.write(bytes(SPF))
|
||||
else:
|
||||
f.write(CH4_FAIL if fi < n_masked_frames else CH4_PASS)
|
||||
finally:
|
||||
f.close()
|
||||
return plan
|
||||
|
||||
|
||||
# ── Plan reduction ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_middle_row_plan_keeps_one_middle_row_per_angle():
|
||||
plan = full_plan(num_angles=3)
|
||||
check = middle_row_plan(plan)
|
||||
|
||||
assert check.n_angles == plan.n_angles
|
||||
assert [pa.n_rows for pa in check.per_angle] == [1] * plan.n_angles
|
||||
for original, reduced in zip(plan.per_angle, check.per_angle, strict=True):
|
||||
mid = original.n_rows // 2
|
||||
assert reduced.y_positions == [original.y_positions[mid]]
|
||||
# The row is scanned exactly as the full scan would have scanned it.
|
||||
assert reduced.angle_deg == original.angle_deg
|
||||
assert reduced.x_start == original.x_start
|
||||
assert reduced.x_delta == original.x_delta
|
||||
assert reduced.n_frames == original.n_frames
|
||||
|
||||
|
||||
def test_middle_row_plan_does_not_mutate_its_input():
|
||||
plan = full_plan(num_angles=3)
|
||||
before = [(pa.n_rows, list(pa.y_positions)) for pa in plan.per_angle]
|
||||
middle_row_plan(plan)
|
||||
assert [(pa.n_rows, pa.y_positions) for pa in plan.per_angle] == before
|
||||
|
||||
|
||||
def test_every_angles_middle_row_crosses_the_roi_centre():
|
||||
"""The premise the whole comparison rests on: one shared point on the sample."""
|
||||
plan = full_plan(num_angles=5)
|
||||
check = middle_row_plan(plan)
|
||||
cx = plan.x_start_nominal + plan.x_delta_nominal / 2
|
||||
cy = plan.y_start_nominal + plan.y_delta_nominal / 2
|
||||
for pa in check.per_angle:
|
||||
assert pa.x_start + pa.x_delta / 2 == pytest.approx(cx, abs=1e-6)
|
||||
# Within one row spacing — the middle row is a grid point, not exact.
|
||||
assert abs(pa.y_positions[0] - cy) <= plan.row_spacing
|
||||
|
||||
|
||||
def test_middle_row_index_rule():
|
||||
assert [middle_row_index(n) for n in (1, 2, 3, 4, 6)] == [0, 1, 1, 2, 3]
|
||||
|
||||
|
||||
def test_middle_row_plan_rejects_an_empty_plan():
|
||||
plan = full_plan(num_angles=1)
|
||||
plan.per_angle = []
|
||||
with pytest.raises(ScanGeometryError, match="no angles"):
|
||||
middle_row_plan(plan)
|
||||
|
||||
|
||||
def test_middle_row_plan_rejects_an_angle_with_no_rows():
|
||||
plan = full_plan(num_angles=1)
|
||||
plan.per_angle[0].y_positions = []
|
||||
with pytest.raises(ScanGeometryError, match="no middle row"):
|
||||
middle_row_plan(plan)
|
||||
|
||||
|
||||
# ── The v10 file ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_v10_write_read_roundtrip(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
plan = write_check(out, bins=(8, 8, 8))
|
||||
|
||||
sras = SrasFile(out)
|
||||
assert sras.version == VERSION_SAW_CHECK
|
||||
assert sras.is_saw_check
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
|
||||
assert [pa.n_rows for pa in sras.per_angle] == [1] * plan.n_angles
|
||||
sras.close()
|
||||
|
||||
|
||||
def test_v10_rejects_a_multi_row_plan(tmp_path):
|
||||
plan = full_plan(num_angles=2)
|
||||
assert any(pa.n_rows > 1 for pa in plan.per_angle)
|
||||
with pytest.raises(ValueError, match="exactly one row per angle"):
|
||||
create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE,
|
||||
PREAMBLES, bytes(SPF), version=VERSION_SAW_CHECK)
|
||||
assert not (tmp_path / "bad.sras").exists()
|
||||
|
||||
|
||||
def test_unknown_version_rejected_at_write(tmp_path):
|
||||
with pytest.raises(ValueError, match="version 7"):
|
||||
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
|
||||
SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF), version=7)
|
||||
|
||||
|
||||
def test_v6_file_is_not_a_saw_check():
|
||||
sras = SrasFile("tests/golden/complete.sras")
|
||||
assert sras.version == VERSION and not sras.is_saw_check
|
||||
|
||||
|
||||
# ── Acquisition through the engine ───────────────────────────────────────────
|
||||
|
||||
def run_engine(tmp_path, num_angles=3):
|
||||
trace = Trace()
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
stage = FakeStage(trace, scope=scope)
|
||||
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
|
||||
plan = full_plan(num_angles)
|
||||
check = middle_row_plan(plan)
|
||||
engine = ScanEngine(stage, scope, rotator, check, tmp_path / "check.sras",
|
||||
callbacks=ScanCallbacks(),
|
||||
file_version=VERSION_SAW_CHECK)
|
||||
return engine.run(), plan, check, trace
|
||||
|
||||
|
||||
def test_engine_writes_a_complete_v10_check(tmp_path):
|
||||
result, plan, check, _ = run_engine(tmp_path)
|
||||
|
||||
assert not result.aborted
|
||||
assert result.rows_written == check.n_angles # exactly one row per angle
|
||||
assert result.angles_acquired == list(range(check.n_angles))
|
||||
|
||||
sras = SrasFile(result.path)
|
||||
assert sras.is_saw_check
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"] * check.n_angles
|
||||
assert [pa.y_positions for pa in sras.per_angle] == [
|
||||
[pytest.approx(original.y_positions[original.n_rows // 2], abs=1e-4)]
|
||||
for original in plan.per_angle
|
||||
]
|
||||
sras.close()
|
||||
|
||||
|
||||
def test_engine_visits_each_middle_row_once(tmp_path):
|
||||
_, _, check, trace = run_engine(tmp_path)
|
||||
y_moves = [round(c[2], 4) for c in trace.of("move_axis_absolute")
|
||||
if c[1] == 0x22]
|
||||
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
|
||||
|
||||
|
||||
def test_engine_still_writes_v6_by_default(tmp_path):
|
||||
trace = Trace()
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
stage = FakeStage(trace, scope=scope)
|
||||
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
|
||||
engine = ScanEngine(stage, scope, rotator, full_plan(1),
|
||||
tmp_path / "scan.sras", callbacks=ScanCallbacks())
|
||||
result = engine.run()
|
||||
assert SrasFile(result.path).version == VERSION
|
||||
|
||||
|
||||
# ── Analysis ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_traces_report_the_injected_frequency(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
bins = (8, 9, 10)
|
||||
write_check(out, bins=bins)
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
|
||||
assert len(traces) == len(bins)
|
||||
for trace, k in zip(traces, bins, strict=True):
|
||||
assert np.allclose(trace.freq_mhz, bin_mhz(k))
|
||||
assert trace.median_mhz == pytest.approx(bin_mhz(k))
|
||||
assert trace.valid_fraction == 1.0
|
||||
assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_masked_pixels_become_nan_not_zero(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 8, 8), n_masked_frames=2)
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
|
||||
for trace in traces:
|
||||
assert np.isnan(trace.freq_mhz[:2]).all()
|
||||
assert np.isfinite(trace.freq_mhz[2:]).all()
|
||||
# A masked pixel must not drag the median toward 0 MHz.
|
||||
assert trace.median_mhz == pytest.approx(bin_mhz(8))
|
||||
assert trace.valid_fraction < 1.0
|
||||
|
||||
|
||||
def test_traces_are_centred_on_a_common_offset(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 9, 10))
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
|
||||
# Absolute X differs per angle (different bounding boxes); the offset the
|
||||
# viewer plots against does not, which is what puts the curves together.
|
||||
assert len({round(t.x_mm[0], 6) for t in traces}) > 1
|
||||
for trace in traces:
|
||||
assert trace.offset_mm[0] == pytest.approx(-trace.offset_mm[-1])
|
||||
|
||||
|
||||
def test_angles_with_no_data_are_skipped(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 8, 8))
|
||||
full = out.read_bytes()
|
||||
with SrasFile(out) as sras:
|
||||
last_offset = sras.angle_data_offset(2)
|
||||
out.write_bytes(full[:last_offset]) # angle 3 never acquired
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
assert [t.angle_idx for t in traces] == [0, 1]
|
||||
|
||||
|
||||
def test_summary_flags_agreeing_angles_as_good(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 8, 8))
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
summary = alignment_summary(
|
||||
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
|
||||
|
||||
assert summary.n_angles == 3
|
||||
assert summary.median_mhz == pytest.approx(bin_mhz(8))
|
||||
assert summary.spread_mhz == pytest.approx(0.0)
|
||||
assert summary.spread_pct <= SPREAD_GOOD_PCT
|
||||
assert summary.level == "good"
|
||||
|
||||
|
||||
def test_summary_flags_disagreeing_angles(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
write_check(out, bins=(8, 9, 10))
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||
summary = alignment_summary(traces)
|
||||
|
||||
assert summary.spread_mhz == pytest.approx(bin_mhz(10) - bin_mhz(8))
|
||||
assert summary.level == "poor"
|
||||
assert summary.worst_angle_deg == traces[0].angle_deg # lowest median
|
||||
assert summary.best_angle_deg == traces[2].angle_deg # highest median
|
||||
assert f"{summary.spread_mhz:.3f} MHz" in summary.describe()
|
||||
|
||||
|
||||
def test_summary_calls_out_a_mostly_masked_row(tmp_path):
|
||||
out = tmp_path / "check.sras"
|
||||
plan = middle_row_plan(full_plan(3))
|
||||
# Mask nearly every frame of every angle: the spread is meaningless then.
|
||||
write_check(out, bins=(8, 8, 8), plan=plan,
|
||||
n_masked_frames=max(pa.n_frames for pa in plan.per_angle) - 1)
|
||||
|
||||
with SrasFile(out) as sras:
|
||||
summary = alignment_summary(
|
||||
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
|
||||
|
||||
assert summary.level == "poor"
|
||||
assert "DC threshold" in summary.describe()
|
||||
|
||||
|
||||
def test_summary_of_nothing_is_not_a_crash():
|
||||
summary = alignment_summary([])
|
||||
assert summary.n_angles == 0 and summary.level == "poor"
|
||||
assert "No angle" in summary.describe()
|
||||
|
||||
|
||||
def test_middle_row_of_a_full_v6_scan_is_readable():
|
||||
"""The check's read-out applied to a finished scan, after the fact."""
|
||||
with SrasFile("tests/golden/complete.sras") as sras:
|
||||
traces = frequency_traces(sras, dc_threshold_mv=-1e6)
|
||||
assert len(traces) == sras.header.n_angles
|
||||
for trace, pa in zip(traces, sras.per_angle, strict=True):
|
||||
assert trace.row_idx == pa.n_rows // 2
|
||||
assert len(trace.freq_mhz) == pa.n_frames
|
||||
@@ -49,6 +49,17 @@ def test_sras_viewer_window(qapp):
|
||||
_pump(qapp)
|
||||
|
||||
|
||||
def test_saw_check_viewer_window(qapp):
|
||||
import saw_check_viewer
|
||||
win = saw_check_viewer.SawCheckWindow()
|
||||
_pump(qapp)
|
||||
try:
|
||||
assert win.windowTitle()
|
||||
finally:
|
||||
win.deleteLater()
|
||||
_pump(qapp)
|
||||
|
||||
|
||||
def test_helios_test_app(qapp):
|
||||
import helios_test_app
|
||||
win = helios_test_app.HeliosTestApp()
|
||||
|
||||
Reference in New Issue
Block a user