#!/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()