aa06fa1460
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.
Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.
Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.
v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.
Two fixes made along the way:
* QtScanController never accepted file_version, so every scan launched
from the app raised TypeError at construction.
* angle_status() left its cursor parked at the frontier, so every angle
past it reported the frontier's own data_offset — which handed a resumed
scan the same write position for several angles. Two recorded offsets in
tests/golden/sras_expected.json are corrected accordingly.
The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
679 lines
27 KiB
Python
Executable File
679 lines
27 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
SAW Check Viewer — every angle's frequency on one graph.
|
||
|
||
Opens a 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 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)
|
||
# Each angle carries its own background (v7/v11); the read-out pulls
|
||
# the right one per angle, so the viewer only needs to know whether
|
||
# there is anything to subtract at all.
|
||
self.has_background = any(self.sras.background_array(ai) is not None
|
||
for ai in range(self.sras.header.n_angles))
|
||
self.traces = []
|
||
self.summary = None
|
||
|
||
def describe(self) -> str:
|
||
h = self.sras.header
|
||
kind = (f"v{self.sras.version} 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())
|
||
self.chk_bg_sub.setEnabled(check.has_background)
|
||
|
||
if not check.sras.is_saw_check:
|
||
self.lbl_status.setText(
|
||
"Not a SAW check file — 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(),
|
||
subtract_background=self.chk_bg_sub.isChecked(),
|
||
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()
|