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:
Thomas Ales
2026-09-04 08:13:54 -05:00
parent dfd6c9e2b8
commit 844fcd0297
11 changed files with 1541 additions and 28 deletions
+111 -8
View File
@@ -6,6 +6,7 @@ and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
"""
import struct
import subprocess
import sys
import time
from pathlib import Path
@@ -32,10 +33,13 @@ from core.scan_engine import (
ResumeState,
LASER_FREQ_HZ, SCAN_VELOCITY_MM_S,
)
from core.saw_check import middle_row_plan
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
from core.scan_resume import is_compatible, plan_resume
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header,
)
from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector
@@ -51,6 +55,10 @@ from t3r_control_panel import T3RControlPanel
DEFAULTS = ScanDefaults.load()
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
# A SAW check is written beside the scan it belongs to, under the same prefix.
# The suffix keeps it from overwriting the scan itself, which is the one file
# in the directory that cost hours to acquire.
SAW_CHECK_SUFFIX = "-sawcheck"
class DCBiasImageWidget(FigureCanvas):
@@ -977,6 +985,11 @@ class MainWindow(QMainWindow):
self._scan_progress = ScanProgressWindow()
self._scan_worker: QtScanController | None = None
# A SAW check runs through the same worker as a scan; this says which,
# since the two finish very differently (a check hands the operator a
# file to look at; a scan shuts the rig down).
self._scan_is_saw_check = False
self._saw_check_path: Path | None = None
self._inspect_thread: QThread | None = None
self._inspect_worker: QtAngleInspector | None = None
self._inspect_window: AngleInspectWindow | None = None
@@ -1101,6 +1114,7 @@ class MainWindow(QMainWindow):
# Scan
self.start_scan_btn.clicked.connect(self._on_start_scan)
self.saw_check_btn.clicked.connect(self._on_saw_check)
self.inspect_angles_btn.clicked.connect(self._on_inspect_angles)
self.save_dir_browse_btn.clicked.connect(self._on_browse_save_dir)
self._scan_progress.abort_requested.connect(self._on_abort_scan)
@@ -1267,6 +1281,11 @@ class MainWindow(QMainWindow):
# ── Scan ──────────────────────────────────────────────────────────────────
def _set_scan_buttons_enabled(self, enabled: bool):
"""Both entry points drive the same rig, so they lock and unlock together."""
self.start_scan_btn.setEnabled(enabled)
self.saw_check_btn.setEnabled(enabled)
def _on_browse_save_dir(self):
d = QFileDialog.getExistingDirectory(
self, "Select Scan Save Directory", self.scan_save_dir_edit.text()
@@ -1275,6 +1294,82 @@ class MainWindow(QMainWindow):
self.scan_save_dir_edit.setText(d)
self._persist_defaults()
def _on_saw_check(self):
"""Acquire the middle row of the current ROI at every angle.
Same engine, same hardware sequence, same file format as a scan — the
plan is just reduced to one row per angle and the result is tagged v10
so the viewer knows it is a check rather than a scan cut short.
"""
if self._scan_thread is not None and self._scan_thread.isRunning():
QMessageBox.warning(
self, "Scan In Progress",
"A scan is running — abort it before starting a SAW check."
)
return
try:
plan, prefix, save_dir = self._build_scan_plan()
check_plan = middle_row_plan(plan) # ScanGeometryError is a ValueError
except ValueError as e:
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
return
check_prefix = f"{prefix}{SAW_CHECK_SUFFIX}"
out_path = Path(save_dir) / f"{check_prefix}.sras"
rows = ", ".join(f"{pa.angle_deg:.1f}°: Y={pa.y_positions[0]:.3f} mm"
for pa in check_plan.per_angle)
overwrite = ("\n\nThis will overwrite the existing file."
if out_path.exists() else "")
reply = QMessageBox.question(
self, "SAW Quality Check",
f"Acquire the middle row of the ROI at {check_plan.n_angles} angle(s)?\n\n"
f"{rows}\n\n"
f"Save → {out_path.name}{overwrite}",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._saw_check_path = out_path
# Burst mode is deliberately not offered here: one row per angle means
# every burst would be a single row, so it buys nothing and still pays
# for the gate preflight.
self._launch_scan_worker(check_plan, check_prefix, save_dir, saw_check=True)
def _on_saw_check_complete(self):
"""A check is a thing to look at, not a run to shut down after."""
path = self._saw_check_path
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle("SAW Check Complete")
box.setText(
f"Middle-row SAW check written to:\n{path}\n\n"
"Open it in the SAW Check Viewer to compare each angle's "
"frequency and judge the alignment."
)
open_btn = box.addButton("Open Viewer", QMessageBox.ButtonRole.AcceptRole)
box.addButton(QMessageBox.StandardButton.Close)
box.exec()
if box.clickedButton() is open_btn:
self._launch_saw_check_viewer(path)
def _launch_saw_check_viewer(self, path: Path):
"""Open the viewer as its own process.
Deliberately not in-process: the acquisition app owns the hardware and
must stay responsive, and the viewer is a separate entry point that
outlives any one scan session.
"""
try:
subprocess.Popen([sys.executable,
str(ROOT / "saw_check_viewer.py"), str(path)])
except OSError as e:
QMessageBox.warning(
self, "Could Not Open Viewer",
f"Could not start the SAW Check Viewer:\n\n{e}\n\n"
f"Run it manually: python saw_check_viewer.py {path}"
)
def _on_inspect_angles(self):
"""Open the pre-scan angle inspector for the plan currently entered."""
if self._scan_thread is not None and self._scan_thread.isRunning():
@@ -1323,7 +1418,7 @@ class MainWindow(QMainWindow):
lambda m: QMessageBox.warning(self, "Inspection Error", m))
self._inspect_thread.started.connect(self._inspect_worker.run)
self.start_scan_btn.setEnabled(False)
self._set_scan_buttons_enabled(False)
self.inspect_angles_btn.setEnabled(False)
window.show()
self._inspect_thread.start()
@@ -1345,7 +1440,7 @@ class MainWindow(QMainWindow):
self._inspect_thread = None
self._inspect_worker = None
self._inspect_window = None
self.start_scan_btn.setEnabled(True)
self._set_scan_buttons_enabled(True)
self.inspect_angles_btn.setEnabled(True)
def _on_start_scan(self):
@@ -1424,8 +1519,10 @@ class MainWindow(QMainWindow):
str(path.parent), resume_plan.to_state(sras))
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
resume: ResumeState | None = None):
resume: ResumeState | None = None,
saw_check: bool = False):
rotator = RotationAxis(self._t3r_driver.driver, DEFAULT_ROTATION)
self._scan_is_saw_check = saw_check
self._scan_thread = QThread(self)
self._scan_worker = QtScanController(
@@ -1439,8 +1536,9 @@ class MainWindow(QMainWindow):
# a worker concern, not the engine's.
on_scan_active=lambda active: setattr(
self._bbd_worker, "scanning_active", active),
burst_mode=self.burst_mode_check.isChecked(),
burst_mode=self.burst_mode_check.isChecked() and not saw_check,
strict_rows=self.strict_rows_check.isChecked(),
file_version=VERSION_SAW_CHECK if saw_check else VERSION,
)
self._scan_worker.moveToThread(self._scan_thread)
self._scan_thread.started.connect(self._scan_worker.run)
@@ -1453,7 +1551,7 @@ class MainWindow(QMainWindow):
self._scan_worker.user_prompt.connect(self._on_scan_user_prompt)
self._scan_worker.paused_changed.connect(self._scan_progress.on_worker_paused)
self.start_scan_btn.setEnabled(False)
self._set_scan_buttons_enabled(False)
self._scan_progress.reset_pause_btn()
ai0 = 0 if resume is None else resume.targets[0].angle_idx
self._scan_progress.update_progress(
@@ -1494,7 +1592,11 @@ class MainWindow(QMainWindow):
def _on_scan_complete(self):
self._scan_progress.close()
self.start_scan_btn.setEnabled(True)
self._set_scan_buttons_enabled(True)
if self._scan_is_saw_check:
self._scan_is_saw_check = False
self._on_saw_check_complete()
return
QMessageBox.information(
self, "Scan Complete",
"All rows and angles have been acquired.\n\n"
@@ -1504,7 +1606,8 @@ class MainWindow(QMainWindow):
def _on_scan_failed(self, msg: str):
self._scan_progress.close()
self.start_scan_btn.setEnabled(True)
self._set_scan_buttons_enabled(True)
self._scan_is_saw_check = False
if "aborted" in msg.lower():
QMessageBox.warning(self, "Scan Aborted", msg)
else: