844fcd0297
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>
675 lines
27 KiB
Python
Executable File
675 lines
27 KiB
Python
Executable File
#!/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()
|