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
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""
SAW Check Viewer — every angle's frequency on one graph.
Opens a v10 middle-row SAW check (written by the main app's "SAW Quality
Check") and plots the peak SAW frequency along each angle's row, all angles
on the same axes. ``core.saw_check`` explains why that answers an alignment
question: every angle's middle row crosses the same ROI centre, so the angles
all measure the same material and a spread between them belongs to the rig.
Two readings share the window:
* the main graph — frequency along the row, one curve per angle. Curves
that lie on top of each other and run flat are what a well-aligned rig
looks like; a curve offset from the rest indicts its angle, and a sloped
curve indicts the ROI (tilt or defocus across it, at that angle).
* the summary — each angle's median with ±1σ, plotted against angle, plus
the same numbers per angle in a table.
A full v6 scan opens too: the same middle row is pulled out of it, so a scan
can be re-examined with the check's own read-out after the fact.
"""
import sys
from pathlib import Path
import numpy as np
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame,
QGroupBox, QHBoxLayout, QHeaderView, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMessageBox, QPushButton, QSizePolicy, QSpinBox, QSplitter,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from matplotlib import colormaps
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core.saw_check import alignment_summary, frequency_traces
from core.sras_analysis import ChannelCalibration
from core.sras_format import SrasFile
RECOMPUTE_DEBOUNCE_MS = 250
# Angle curve colours, sampled across the sequence so the legend reads as the
# progression 0° → 180° rather than as an arbitrary set.
ANGLE_CMAP = "viridis"
VERDICT_STYLE = {
"good": ("#1b5e20", "#c8e6c9", "Alignment looks good"),
"marginal": ("#7a4f01", "#ffe0b2", "Alignment is marginal"),
"poor": ("#7f1d1d", "#ffcdd2", "Alignment needs attention"),
}
X_AXIS_MODES = [
("Offset from row centre", "offset"),
("Absolute stage X", "absolute"),
]
def angle_colors(n: int) -> list:
cmap = colormaps[ANGLE_CMAP]
if n <= 1:
return [cmap(0.5)]
return [cmap(i / (n - 1)) for i in range(n)]
def nan_moving_mean(y: np.ndarray, window: int) -> np.ndarray:
"""Moving mean over `window` frames that steps over masked pixels.
A plain convolution would let one NaN swallow a whole window, which on a
sparsely-masked row erases most of the trace; this divides by the number
of samples that actually contributed instead.
"""
if window <= 1:
return y
valid = np.isfinite(y)
kernel = np.ones(int(window))
num = np.convolve(np.where(valid, y, 0.0), kernel, mode="same")
den = np.convolve(valid.astype(float), kernel, mode="same")
return np.divide(num, den, out=np.full(num.shape, np.nan), where=den > 0)
class LoadedCheck:
"""A parsed check file plus the traces currently computed from it."""
def __init__(self, path: Path):
self.sras = SrasFile(path)
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
bg = np.frombuffer(self.sras.background, dtype=np.int8)
self.background = bg.astype(np.float32) if len(bg) else None
self.traces = []
self.summary = None
def describe(self) -> str:
h = self.sras.header
kind = ("v10 SAW check" if self.sras.is_saw_check
else f"v{self.sras.version} scan — middle row of each angle")
return (f"{self.sras.path.name}\n{kind}\n"
f"{h.n_angles} angle(s) · {h.samples_per_frame} samples/frame · "
f"{h.sample_rate / 1e9:.2f} GS/s")
def close(self):
self.sras.close()
class FnWorker(QObject):
"""Runs a callable on a QThread; emits its return value or the error."""
finished = pyqtSignal(object)
error = pyqtSignal(str)
def __init__(self, fn):
super().__init__()
self._fn = fn
def run(self):
try:
self.finished.emit(self._fn())
except Exception as exc:
self.error.emit(str(exc))
class TraceCanvas(FigureCanvasQTAgg):
"""Frequency along the row, one curve per angle, all on one axes."""
def __init__(self, parent=None):
fig = Figure(figsize=(8, 5), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.clear("Open a SAW check file to begin.")
def clear(self, message: str):
self.ax.clear()
self.ax.text(0.5, 0.5, message, ha="center", va="center",
transform=self.ax.transAxes, color="#888888")
self.ax.set_xticks([])
self.ax.set_yticks([])
self.draw_idle()
def plot(self, traces, colors, visible, x_mode, scale, unit, y_label,
smoothing, show_median):
self.ax.clear()
shown = 0
for trace, color in zip(traces, colors, strict=True):
if not visible.get(trace.angle_idx, True):
continue
x = trace.offset_mm if x_mode == "offset" else trace.x_mm
y = nan_moving_mean(trace.freq_mhz, smoothing) * scale
self.ax.plot(x, y, color=color, linewidth=1.0,
label=f"{trace.angle_deg:+.1f}° "
f"med {trace.median_mhz * scale:.2f}")
shown += 1
if shown == 0:
self.clear("No angle selected.")
return
if show_median:
medians = [t.median_mhz for t in traces
if visible.get(t.angle_idx, True) and t.n_valid]
if medians:
self.ax.axhline(float(np.median(medians)) * scale, color="#555555",
linestyle="--", linewidth=1.0,
label="median of shown angles")
self.ax.set_xlabel("Offset from row centre (mm)" if x_mode == "offset"
else "Stage X (mm)")
self.ax.set_ylabel(y_label)
self.ax.grid(True, alpha=0.25)
self.ax.legend(fontsize=7, ncol=2, loc="best", framealpha=0.85)
self.draw_idle()
class SummaryCanvas(FigureCanvasQTAgg):
"""Each angle's median frequency, ±1σ, against the GR angle."""
def __init__(self, parent=None):
fig = Figure(figsize=(8, 2.6), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def plot(self, traces, colors, scale, unit):
self.ax.clear()
usable = [(t, c) for t, c in zip(traces, colors, strict=True) if t.n_valid]
if not usable:
self.ax.set_xticks([])
self.ax.set_yticks([])
self.draw_idle()
return
order = sorted(usable, key=lambda tc: tc[0].angle_deg)
angles = [t.angle_deg for t, _ in order]
medians = np.array([t.median_mhz for t, _ in order]) * scale
sigmas = np.array([0.0 if not np.isfinite(t.std_mhz) else t.std_mhz
for t, _ in order]) * scale
self.ax.plot(angles, medians, color="#999999", linewidth=1.0, zorder=1)
self.ax.errorbar(angles, medians, yerr=sigmas, fmt="none",
ecolor="#999999", capsize=3, zorder=2)
for (_, color), angle, median in zip(order, angles, medians, strict=True):
self.ax.plot([angle], [median], marker="o", markersize=6,
color=color, zorder=3)
self.ax.axhline(float(np.median(medians)), color="#555555",
linestyle="--", linewidth=1.0)
self.ax.set_xlabel("GR angle (deg)")
self.ax.set_ylabel(f"Median ({unit})")
self.ax.grid(True, alpha=0.25)
self.draw_idle()
class SawCheckWindow(QMainWindow):
"""Left: what to compute and what to show. Right: the graphs."""
TABLE_COLUMNS = ["Angle (°)", "Y (mm)", "Median", "σ", "Drift (/mm)", "Valid (%)"]
def __init__(self, initial_path: str | None = None):
super().__init__()
self.setWindowTitle("SAW Check Viewer")
self.resize(1280, 860)
self._check: LoadedCheck | None = None
self._colors: list = []
self._visible: dict[int, bool] = {}
self._compute_thread: QThread | None = None
self._compute_worker: FnWorker | None = None
self._pending_recompute = False
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(RECOMPUTE_DEBOUNCE_MS)
self._debounce.timeout.connect(self._recompute)
self._build_ui()
if initial_path:
self._load(Path(initial_path))
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
splitter = QSplitter(Qt.Orientation.Horizontal, self)
splitter.addWidget(self._build_controls())
splitter.addWidget(self._build_plots())
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([340, 940])
self.setCentralWidget(splitter)
def _build_controls(self) -> QWidget:
panel = QWidget(self)
layout = QVBoxLayout(panel)
# File
grp_file = QGroupBox("File")
fl = QVBoxLayout(grp_file)
self.btn_open = QPushButton("Open SAW Check…")
self.btn_open.clicked.connect(self._on_open)
fl.addWidget(self.btn_open)
self.lbl_file = QLabel("No file loaded.")
self.lbl_file.setWordWrap(True)
self.lbl_file.setStyleSheet("color: #666; font-size: 11px;")
fl.addWidget(self.lbl_file)
layout.addWidget(grp_file)
# Analysis — anything here changes the numbers, so it recomputes.
self.grp_analysis = QGroupBox("Analysis")
al = QVBoxLayout(self.grp_analysis)
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("CH4 DC threshold:"))
self.spin_threshold_mv = QDoubleSpinBox()
self.spin_threshold_mv.setRange(-500.0, 500.0)
self.spin_threshold_mv.setDecimals(1)
self.spin_threshold_mv.setSingleStep(5.0)
self.spin_threshold_mv.setSuffix(" mV")
self.spin_threshold_mv.setValue(50.0)
self.spin_threshold_mv.setToolTip(
"Pixels whose CH4 DC mean falls below this are dropped from the "
"trace — the detection beam was off the sample or out of focus there."
)
self.spin_threshold_mv.valueChanged.connect(self._queue_recompute)
thr_row.addWidget(self.spin_threshold_mv)
al.addLayout(thr_row)
self.chk_bg_sub = QCheckBox("Subtract background waveform")
self.chk_bg_sub.setChecked(True)
self.chk_bg_sub.toggled.connect(self._queue_recompute)
al.addWidget(self.chk_bg_sub)
self.chk_gate = QCheckBox("Time gate before FFT")
self.chk_gate.toggled.connect(self._on_gate_toggled)
al.addWidget(self.chk_gate)
gate_row = QHBoxLayout()
gate_row.addWidget(QLabel("Start:"))
self.spin_gate_start = QDoubleSpinBox()
self.spin_gate_start.setRange(0.0, 100000.0)
self.spin_gate_start.setDecimals(1)
self.spin_gate_start.setSingleStep(10.0)
self.spin_gate_start.setSuffix(" ns")
self.spin_gate_start.setValue(50.0)
self.spin_gate_start.setEnabled(False)
self.spin_gate_start.valueChanged.connect(self._queue_recompute)
gate_row.addWidget(self.spin_gate_start)
gate_row.addWidget(QLabel("End:"))
self.spin_gate_end = QDoubleSpinBox()
self.spin_gate_end.setRange(0.0, 100000.0)
self.spin_gate_end.setDecimals(1)
self.spin_gate_end.setSingleStep(10.0)
self.spin_gate_end.setSuffix(" ns")
self.spin_gate_end.setValue(200.0)
self.spin_gate_end.setEnabled(False)
self.spin_gate_end.valueChanged.connect(self._queue_recompute)
gate_row.addWidget(self.spin_gate_end)
al.addLayout(gate_row)
layout.addWidget(self.grp_analysis)
# Display — cheap, so these only redraw.
grp_display = QGroupBox("Display")
dl = QVBoxLayout(grp_display)
x_row = QHBoxLayout()
x_row.addWidget(QLabel("X axis:"))
self.combo_x = QComboBox()
for label, _ in X_AXIS_MODES:
self.combo_x.addItem(label)
self.combo_x.setToolTip(
"Every angle's row is centred on the same ROI centre, so offset "
"puts the angles over the same piece of sample; absolute shows "
"where each rotated bounding box actually sat on the stage."
)
self.combo_x.currentIndexChanged.connect(self._redraw)
x_row.addWidget(self.combo_x)
dl.addLayout(x_row)
y_row = QHBoxLayout()
y_row.addWidget(QLabel("Y axis:"))
self.combo_y = QComboBox()
self.combo_y.addItems(["Frequency (MHz)", "Velocity (m/s)"])
self.combo_y.currentIndexChanged.connect(self._on_y_mode_changed)
y_row.addWidget(self.combo_y)
dl.addLayout(y_row)
grat_row = QHBoxLayout()
grat_row.addWidget(QLabel("Grating:"))
self.spin_grating_um = QDoubleSpinBox()
self.spin_grating_um.setRange(0.1, 1000.0)
self.spin_grating_um.setDecimals(2)
self.spin_grating_um.setSingleStep(0.5)
self.spin_grating_um.setSuffix(" µm")
self.spin_grating_um.setValue(12.5)
self.spin_grating_um.setEnabled(False)
self.spin_grating_um.setToolTip("v (m/s) = freq (MHz) × grating (µm)")
self.spin_grating_um.valueChanged.connect(self._redraw)
grat_row.addWidget(self.spin_grating_um)
dl.addLayout(grat_row)
smooth_row = QHBoxLayout()
smooth_row.addWidget(QLabel("Smoothing:"))
self.spin_smoothing = QSpinBox()
self.spin_smoothing.setRange(1, 2001)
self.spin_smoothing.setSingleStep(10)
self.spin_smoothing.setSuffix(" frames")
self.spin_smoothing.setValue(1)
self.spin_smoothing.setToolTip(
"Moving average along the row, masked pixels skipped. Display "
"only — the table's statistics always use the unsmoothed trace."
)
self.spin_smoothing.valueChanged.connect(self._redraw)
smooth_row.addWidget(self.spin_smoothing)
dl.addLayout(smooth_row)
self.chk_median_line = QCheckBox("Show median of shown angles")
self.chk_median_line.setChecked(True)
self.chk_median_line.toggled.connect(self._redraw)
dl.addWidget(self.chk_median_line)
layout.addWidget(grp_display)
# Angles
grp_angles = QGroupBox("Angles")
gl = QVBoxLayout(grp_angles)
self.list_angles = QListWidget()
self.list_angles.setMaximumHeight(190)
self.list_angles.itemChanged.connect(self._on_angle_toggled)
gl.addWidget(self.list_angles)
btn_row = QHBoxLayout()
btn_all = QPushButton("All")
btn_all.clicked.connect(lambda: self._set_all_angles(True))
btn_none = QPushButton("None")
btn_none.clicked.connect(lambda: self._set_all_angles(False))
btn_row.addWidget(btn_all)
btn_row.addWidget(btn_none)
gl.addLayout(btn_row)
layout.addWidget(grp_angles)
# Verdict
self.lbl_verdict = QLabel("—")
self.lbl_verdict.setWordWrap(True)
self.lbl_verdict.setFrameShape(QFrame.Shape.StyledPanel)
self.lbl_verdict.setMinimumHeight(92)
self.lbl_verdict.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.addWidget(self.lbl_verdict)
self.lbl_status = QLabel("")
self.lbl_status.setStyleSheet("color: #666; font-size: 11px;")
layout.addWidget(self.lbl_status)
layout.addStretch(1)
return panel
def _build_plots(self) -> QWidget:
splitter = QSplitter(Qt.Orientation.Vertical, self)
top = QWidget()
tl = QVBoxLayout(top)
tl.setContentsMargins(0, 0, 0, 0)
self.trace_canvas = TraceCanvas(top)
tl.addWidget(NavigationToolbar2QT(self.trace_canvas, top))
tl.addWidget(self.trace_canvas)
splitter.addWidget(top)
tabs = QTabWidget()
self.summary_canvas = SummaryCanvas(tabs)
tabs.addTab(self.summary_canvas, "Frequency vs angle")
self.table = QTableWidget(0, len(self.TABLE_COLUMNS))
self.table.setHorizontalHeaderLabels(self.TABLE_COLUMNS)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
tabs.addTab(self.table, "Per-angle statistics")
splitter.addWidget(tabs)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
# Stretch factors alone leave the summary too short to fit its own
# axis label on first show; give it a real starting height.
splitter.setSizes([540, 300])
return splitter
# ── Loading ───────────────────────────────────────────────────────────────
def _on_open(self):
start = str(self._check.sras.path.parent) if self._check else ""
path, _ = QFileDialog.getOpenFileName(
self, "Open SAW Check File", start, "SRAS Files (*.sras)")
if path:
self._load(Path(path))
def _load(self, path: Path):
try:
check = LoadedCheck(path)
except Exception as exc:
QMessageBox.critical(self, "Cannot Open File",
f"Could not read {path.name}:\n\n{exc}")
return
if self._check is not None:
self._check.close()
self._check = check
self.setWindowTitle(f"SAW Check Viewer — {path.name}")
self.lbl_file.setText(check.describe())
if not check.sras.is_saw_check:
self.lbl_status.setText(
"Not a v10 check — reading the middle row of each angle "
"out of this scan instead.")
else:
self.lbl_status.setText("")
self._colors = angle_colors(check.sras.header.n_angles)
self._visible = {i: True for i in range(check.sras.header.n_angles)}
self._recompute()
# ── Compute ───────────────────────────────────────────────────────────────
def _queue_recompute(self):
if self._check is not None:
self._debounce.start()
def _on_gate_toggled(self, enabled: bool):
self.spin_gate_start.setEnabled(enabled)
self.spin_gate_end.setEnabled(enabled)
self._queue_recompute()
def _recompute(self):
if self._check is None:
return
if self._compute_thread is not None and self._compute_thread.isRunning():
# One worker owns the mmap at a time; fold this request into the
# one already in flight rather than racing it.
self._pending_recompute = True
return
check = self._check
gated = self.chk_gate.isChecked()
kwargs = dict(
dc_threshold_mv=self.spin_threshold_mv.value(),
background=check.background if self.chk_bg_sub.isChecked() else None,
gate_start_ns=self.spin_gate_start.value() if gated else None,
gate_end_ns=self.spin_gate_end.value() if gated else None,
calib=check.calib,
)
self.grp_analysis.setEnabled(False)
self.lbl_status.setText("Computing frequency traces …")
self._compute_thread = QThread(self)
self._compute_worker = FnWorker(
lambda: frequency_traces(check.sras, **kwargs))
self._compute_worker.moveToThread(self._compute_thread)
self._compute_thread.started.connect(self._compute_worker.run)
self._compute_worker.finished.connect(self._on_traces_ready)
self._compute_worker.error.connect(self._on_compute_error)
self._compute_thread.start()
def _finish_compute(self):
if self._compute_thread is not None:
self._compute_thread.quit()
self._compute_thread.wait(5000)
self._compute_thread = None
self._compute_worker = None
self.grp_analysis.setEnabled(True)
if self._pending_recompute:
self._pending_recompute = False
self._queue_recompute()
def _on_compute_error(self, message: str):
self._finish_compute()
self.lbl_status.setText("")
QMessageBox.critical(self, "Analysis Failed", message)
def _on_traces_ready(self, traces):
self._finish_compute()
if self._check is None:
return
self._check.traces = traces
self._check.summary = alignment_summary(traces)
self.lbl_status.setText(
f"{len(traces)} of {self._check.sras.header.n_angles} angle(s) "
f"produced a trace.")
self._rebuild_angle_list()
self._redraw()
# ── Display ───────────────────────────────────────────────────────────────
def _scale(self) -> tuple[float, str, str]:
"""Display factor, unit and axis label.
The file only ever holds a frequency; velocity is that frequency times
the grating period, applied at display time so switching units never
costs a recompute.
"""
if self.combo_y.currentIndex() == 1:
return self.spin_grating_um.value(), "m/s", "SAW velocity (m/s)"
return 1.0, "MHz", "Peak SAW frequency (MHz)"
def _on_y_mode_changed(self):
self.spin_grating_um.setEnabled(self.combo_y.currentIndex() == 1)
self._redraw()
def _rebuild_angle_list(self):
self.list_angles.blockSignals(True)
self.list_angles.clear()
for trace in self._check.traces:
item = QListWidgetItem(
f"{trace.angle_deg:+7.2f}° Y={trace.y_mm:.3f} mm")
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if self._visible.get(trace.angle_idx, True)
else Qt.CheckState.Unchecked)
item.setData(Qt.ItemDataRole.UserRole, trace.angle_idx)
r, g, b, _ = self._colors[trace.angle_idx]
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
self.list_angles.addItem(item)
self.list_angles.blockSignals(False)
def _on_angle_toggled(self, item: QListWidgetItem):
self._visible[item.data(Qt.ItemDataRole.UserRole)] = (
item.checkState() == Qt.CheckState.Checked)
self._redraw()
def _set_all_angles(self, visible: bool):
self.list_angles.blockSignals(True)
for row in range(self.list_angles.count()):
item = self.list_angles.item(row)
item.setCheckState(Qt.CheckState.Checked if visible
else Qt.CheckState.Unchecked)
self._visible[item.data(Qt.ItemDataRole.UserRole)] = visible
self.list_angles.blockSignals(False)
self._redraw()
def _redraw(self):
if self._check is None or not self._check.traces:
self.trace_canvas.clear("No angle in this file has data on disk.")
return
traces = self._check.traces
colors = [self._colors[t.angle_idx] for t in traces]
scale, unit, y_label = self._scale()
self.trace_canvas.plot(
traces, colors, self._visible,
X_AXIS_MODES[self.combo_x.currentIndex()][1], scale, unit, y_label,
self.spin_smoothing.value(), self.chk_median_line.isChecked())
self.summary_canvas.plot(traces, colors, scale, unit)
self._fill_table(traces, scale, unit)
self._show_verdict(scale, unit)
def _fill_table(self, traces, scale: float, unit: str):
headers = list(self.TABLE_COLUMNS)
headers[2] = f"Median ({unit})"
headers[3] = f"σ ({unit})"
headers[4] = f"Drift ({unit}/mm)"
self.table.setHorizontalHeaderLabels(headers)
self.table.setRowCount(len(traces))
for row, trace in enumerate(traces):
values = [
f"{trace.angle_deg:+.2f}",
f"{trace.y_mm:.3f}",
f"{trace.median_mhz * scale:.3f}",
f"{trace.std_mhz * scale:.3f}",
f"{trace.drift_mhz_per_mm * scale:+.4f}",
f"{trace.valid_fraction * 100:.1f}",
]
for col, text in enumerate(values):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
if col == 0:
r, g, b, _ = self._colors[trace.angle_idx]
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
self.table.setItem(row, col, item)
def _show_verdict(self, scale: float, unit: str):
summary = self._check.summary
fg, bg, headline = VERDICT_STYLE[summary.level]
detail = summary.describe()
if scale != 1.0 and summary.n_angles:
detail += (f"\nIn {unit}: spread {summary.spread_mhz * scale:.3f} "
f"about {summary.median_mhz * scale:.1f}.")
self.lbl_verdict.setText(f"{headline}\n\n{detail}")
self.lbl_verdict.setStyleSheet(
f"color: {fg}; background: {bg}; padding: 8px; font-size: 11px;")
# ── Teardown ──────────────────────────────────────────────────────────────
def closeEvent(self, event):
self._debounce.stop()
if self._compute_thread is not None:
self._compute_thread.quit()
self._compute_thread.wait(5000)
if self._check is not None:
self._check.close()
super().closeEvent(event)
def main():
app = QApplication(sys.argv)
window = SawCheckWindow(sys.argv[1] if len(sys.argv) > 1 else None)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()