Files
scanengine-3/sras_viewer.py
Thomas Ales [M S E] a0e0151b5d pre uc480 integration
2026-05-22 09:38:39 -05:00

1962 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
SRAS Scan File Viewer
PyQt6 application for visualizing channel data from .sras binary scan files.
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
CH1 — RF Acoustic Packet (AC-coupled, 100 mV/div): FFT → peak frequency
CH3 — Bias A (DC-coupled, 50 mV/div): waveform mean
CH4 — Bias B (DC-coupled, 50 mV/div): waveform mean
RF images are masked: pixels where CH4_dc < dc_threshold show 0.
Frame-count correction: the scanner writes the *configured* frame count in the
header before acquisition, but the scope may acquire fewer frames. The actual
count is computed from the file size and used for the reshape so channels are
correctly aligned.
"""
import re
import sys
import struct
import numpy as np
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import os
from scipy.signal import butter, sosfiltfilt, decimate as sp_decimate, hilbert
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
QFileDialog, QSizePolicy, QSplitter, QCheckBox, QFrame, QProgressDialog,
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
# ---------------------------------------------------------------------------
# SAW signal processing pipeline
# ---------------------------------------------------------------------------
class SawPipeline:
"""Modular EMI-cleaning and SAW extraction pipeline.
Stages (each independently bypassable):
1. EMI gate — cosine-taper first `emi_gate_ns` ns to suppress the
laser-firing burst at t≈0; leaves SAW packet untouched.
2. Bandpass — 6th-order Butterworth zero-phase (sosfiltfilt), default
85–200 MHz. Matches hardware bandpass already applied.
3. Decimate — optional; reduces to ~781 MS/s (factor-8) before the
matched filter without losing SAW information.
4. Matched filter — FFT cross-correlation with a Hann-windowed template
built from the average of N clean shots.
5. Analytic — Hilbert transform of MF output → amplitude envelope and
instantaneous phase.
Typical usage::
pipe = SawPipeline(sras.sample_rate_hz)
waveforms = sras.data[angle, :, CH1_IDX, :, :].reshape(-1, spf)
pipe.build_template(waveforms[:50])
result = pipe.process_shot(waveform)
# result["envelope"], result["peak_amplitude"], result["peak_time_ns"]
"""
DECIMATE_FACTOR = 8 # 6250 MS/s → 781.25 MS/s (~4× SAW BW of 200 MHz)
def __init__(self, sample_rate_hz: float,
emi_gate_ns: float = 50.0,
bp_lo_mhz: float = 85.0,
bp_hi_mhz: float = 200.0,
saw_window_ns: tuple[float, float] = (80.0, 350.0),
decimate_enable: bool = False):
self.sample_rate_hz = float(sample_rate_hz)
self.emi_gate_ns = float(emi_gate_ns)
self.bp_lo_mhz = float(bp_lo_mhz)
self.bp_hi_mhz = float(bp_hi_mhz)
self.saw_window_ns = (float(saw_window_ns[0]), float(saw_window_ns[1]))
self.decimate_enable = decimate_enable
self.template: np.ndarray | None = None
self._emi_gate_samples: int = 0
self._sos = None
self._effective_sr = self.sample_rate_hz
self._build_filter()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
def _build_filter(self):
self._emi_gate_samples = max(1, int(round(
self.emi_gate_ns * 1e-9 * self.sample_rate_hz)))
nyq = self.sample_rate_hz / 2.0
lo = np.clip(self.bp_lo_mhz * 1e6 / nyq, 1e-6, 0.999)
hi = np.clip(self.bp_hi_mhz * 1e6 / nyq, lo + 1e-6, 0.9999)
# 6th-order Butterworth → 12th-order bandpass; ~120 dB/decade rolloff
self._sos = butter(6, [lo, hi], btype='bandpass', output='sos')
self._effective_sr = (self.sample_rate_hz / self.DECIMATE_FACTOR
if self.decimate_enable else self.sample_rate_hz)
# ------------------------------------------------------------------
# Individual stages
# ------------------------------------------------------------------
def gate_emi(self, signal: np.ndarray) -> np.ndarray:
"""Cosine-taper (raised cosine 0→1) the first `emi_gate_samples` samples.
The taper rolls up smoothly from zero so the abrupt EMI burst is
suppressed without introducing a step discontinuity at the gate edge.
"""
n = min(self._emi_gate_samples, len(signal))
out = signal.copy()
out[:n] *= 0.5 * (1.0 - np.cos(np.pi * np.arange(n) / n))
return out
def bandpass(self, signal: np.ndarray) -> np.ndarray:
"""Zero-phase IIR Butterworth bandpass (sosfiltfilt)."""
return sosfiltfilt(self._sos, signal.astype(np.float64)).astype(np.float32)
def decimate_signal(self, signal: np.ndarray) -> np.ndarray:
"""Decimate by DECIMATE_FACTOR with scipy anti-alias filter."""
return sp_decimate(signal.astype(np.float64), self.DECIMATE_FACTOR,
zero_phase=True).astype(np.float32)
# ------------------------------------------------------------------
# Template construction
# ------------------------------------------------------------------
def build_template(self, waveforms: np.ndarray) -> None:
"""Build Hann-windowed average template.
Parameters
----------
waveforms : ndarray, shape (N, n_samples)
Raw or pre-processed CH1 waveforms. EMI gating + bandpass are
applied here before averaging so the template is clean.
"""
processed = np.stack([
self.bandpass(self.gate_emi(w.astype(np.float32)))
for w in waveforms
])
avg = processed.mean(axis=0)
# Hann window restricted to the declared SAW window region
n = len(avg)
t_ns = np.arange(n) / self.sample_rate_hz * 1e9
i0 = max(0, int(np.searchsorted(t_ns, self.saw_window_ns[0])))
i1 = min(n, int(np.searchsorted(t_ns, self.saw_window_ns[1])))
windowed = np.zeros(n, dtype=np.float32)
win_len = i1 - i0
if win_len > 0:
windowed[i0:i1] = avg[i0:i1] * np.hanning(win_len)
self.template = windowed
# ------------------------------------------------------------------
# Matched filter
# ------------------------------------------------------------------
def matched_filter(self, signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""FFT cross-correlation with template.
Returns
-------
mf_output : float32 ndarray, length = len(signal)
envelope : float32 ndarray, Hilbert amplitude envelope of mf_output
"""
if self.template is None:
raise RuntimeError("No template — call build_template() first")
n = len(signal)
nfft = 1 << (n + len(self.template) - 1).bit_length()
S = np.fft.rfft(signal.astype(np.float64), nfft)
T = np.fft.rfft(self.template.astype(np.float64), nfft)
mf = np.fft.irfft(S * np.conj(T), nfft)[:n]
env = np.abs(hilbert(mf))
return mf.astype(np.float32), env.astype(np.float32)
# ------------------------------------------------------------------
# Full pipeline for a single waveform
# ------------------------------------------------------------------
def process_shot(self, signal: np.ndarray) -> dict:
"""Run EMI gate → bandpass → (decimate) → matched filter on one shot.
Returns a dict with keys:
raw, gated, filtered, [decimated], mf_output, envelope,
peak_amplitude (float), peak_sample (int), peak_time_ns (float),
snr (float), sample_rate_hz (float).
"""
raw = signal.astype(np.float32)
gated = self.gate_emi(raw)
filtered = self.bandpass(gated)
if self.decimate_enable:
proc = self.decimate_signal(filtered)
sr = self._effective_sr
else:
proc = filtered
sr = self.sample_rate_hz
if self.template is not None:
mf_out, env = self.matched_filter(proc)
else:
mf_out = proc.copy()
env = np.abs(hilbert(proc)).astype(np.float32)
t_ns = np.arange(len(env)) / sr * 1e9
# Peak within SAW window
s0, s1 = self.saw_window_ns
roi = (t_ns >= s0) & (t_ns <= s1)
if roi.any():
idx_in_roi = np.argmax(env[roi])
peak_sample = int(np.where(roi)[0][idx_in_roi])
else:
peak_sample = int(np.argmax(env))
peak_amplitude = float(env[peak_sample])
peak_time_ns = float(peak_sample / sr * 1e9)
# SNR: peak / RMS of noise floor in the gated EMI region (after bandpass)
noise_seg = filtered[:self._emi_gate_samples]
noise_rms = float(np.sqrt(np.mean(noise_seg ** 2))) if len(noise_seg) > 0 else 1.0
snr = peak_amplitude / noise_rms if noise_rms > 0 else 0.0
return {
"raw": raw,
"gated": gated,
"filtered": filtered,
"mf_output": mf_out,
"envelope": env,
"peak_amplitude": peak_amplitude,
"peak_sample": peak_sample,
"peak_time_ns": peak_time_ns,
"snr": snr,
"sample_rate_hz": sr,
}
# ---------------------------------------------------------------------------
# SRAS format
# ---------------------------------------------------------------------------
HDR_FMT = ">4sBHHffffIIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes
# Fixed-order channels in the file: index 0=CH1, 1=CH3, 2=CH4
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
CH_LABELS = [
"CH1 — RF (FFT peak freq)",
"CH3 — Bias A (DC mean)",
"CH4 — Bias B (DC mean)",
"CH1 — Velocity (SRAS)",
"CH1 — SAW Amplitude (matched filter)",
"CH1 — SAW Arrival time (matched filter)",
]
CH_NAMES = ["CH1", "CH3", "CH4", "VEL", "SAW-AMP", "SAW-TOF"]
# Combo indices for derived modes (all use CH1_IDX data)
VELOCITY_MODE_IDX = 3
SAW_MODE_AMP_IDX = 4
SAW_MODE_TOF_IDX = 5
SAW_MODES = (SAW_MODE_AMP_IDX, SAW_MODE_TOF_IDX)
# All modes that operate on CH1 waveforms
CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX) + SAW_MODES
# Fallback scope calibration used only when reading v2 files without embedded
# preambles. v3+ files carry the WFMOutpre string so these are not used.
# 50 mV/div, 8 div full-scale, int8 ADC, position = -2.72 div
# ymult = 50 mV × 8 / 256 = 1.5625 mV/count
# yoff = position × (256/8) = -2.72 × 32 = -87.04 (ADC count for 0 V)
_FALLBACK_YMULT_MV = 1.5625 # mV per ADC count
_FALLBACK_YOFF_ADC = -87.04 # ADC count that represents 0 V
CMAPS = ["gray", "viridis", "plasma", "inferno", "hot", "jet", "RdBu_r", "seismic"]
def _parse_preamble(preamble: str) -> dict[str, float]:
"""Extract YMULT, YOFF, YZERO from a Tektronix WFMOutpre string.
Returns a dict with float values for whichever keys are present.
YMULT is left in V/count as the scope reports it.
"""
result = {}
for key in ("YMULT", "YOFF", "YZERO"):
m = re.search(rf'\b{key}\s+([-+]?\d*\.?\d+(?:[Ee][+-]?\d+)?)', preamble)
if m:
result[key] = float(m.group(1))
return result
def mv_to_adc(mv: float, ymult_mv: float = _FALLBACK_YMULT_MV,
yoff_adc: float = _FALLBACK_YOFF_ADC,
yzero_mv: float = 0.0) -> float:
return (mv - yzero_mv) / ymult_mv + yoff_adc
def adc_to_mv(adc: float, ymult_mv: float = _FALLBACK_YMULT_MV,
yoff_adc: float = _FALLBACK_YOFF_ADC,
yzero_mv: float = 0.0) -> float:
return (adc - yoff_adc) * ymult_mv + yzero_mv
# ---------------------------------------------------------------------------
# File parser
# ---------------------------------------------------------------------------
class SrasFile:
"""Parsed in-memory representation of a v2/v3/v4 .sras file."""
def __init__(self, path: str):
self.path = Path(path)
self._parse()
def _parse(self):
with open(self.path, "rb") as f:
fields = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
(magic, ver, n_angles, n_rows, x_start, x_delta, vel, freq,
n_frames_hdr, spf, sr, bps, n_ch) = fields
if magic != b"SRAS":
raise ValueError(f"Bad magic bytes: {magic!r}")
if ver not in (2, 3, 4):
raise ValueError(f"Unsupported version: {ver}")
self.n_angles = n_angles
self.n_rows = n_rows
self.x_start_mm = float(x_start)
self.x_delta_mm = float(x_delta)
self.velocity_mm_s = float(vel)
self.laser_freq_hz = float(freq)
self.n_frames_header = n_frames_hdr # configured count (may be wrong)
self.samples_per_frame = spf
self.sample_rate_hz = float(sr)
self.bytes_per_sample = bps
self.n_channels = n_ch
with open(self.path, "rb") as f:
f.seek(HDR_SIZE)
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
if ver >= 3:
preambles = []
for _ in range(n_ch):
(length,) = struct.unpack(">H", f.read(2))
preambles.append(f.read(length).decode("utf-8"))
self.preambles = preambles
self.ch_ymult_mv = []
self.ch_yoff_adc = []
self.ch_yzero_mv = []
for p in preambles:
cal = _parse_preamble(p)
# YMULT from scope is V/count; store as mV/count
self.ch_ymult_mv.append(cal.get("YMULT", _FALLBACK_YMULT_MV / 1000) * 1000)
self.ch_yoff_adc.append(cal.get("YOFF", _FALLBACK_YOFF_ADC))
# YZERO from scope is in V; store as mV
self.ch_yzero_mv.append(cal.get("YZERO", 0.0) * 1000)
else:
self.preambles = None
self.ch_ymult_mv = [_FALLBACK_YMULT_MV] * n_ch
self.ch_yoff_adc = [_FALLBACK_YOFF_ADC] * n_ch
self.ch_yzero_mv = [0.0] * n_ch
if ver >= 4:
(n_bg,) = struct.unpack(">I", f.read(4))
self.background = np.frombuffer(f.read(n_bg), dtype=np.int8).astype(np.float32)
else:
self.background = None
raw = f.read()
total_samples = len(raw) // bps
samples_per_row_per_ch = n_ch * spf
# Compute actual frames per channel from the file size — the scanner
# writes the configured frame count in the header before acquisition
# begins, but ACQuire:NUMFRAMESACQuired may be lower.
actual_n_frames = total_samples // (n_angles * n_rows * samples_per_row_per_ch)
remainder = total_samples % (n_angles * n_rows * samples_per_row_per_ch)
self.n_frames = actual_n_frames # actual, use this for all indexing
self.n_frames_header = n_frames_hdr
self.frame_count_mismatch = (actual_n_frames != n_frames_hdr)
self.n_frames_remainder = remainder # partial last-row samples
# Reshape using the actual count; discard any fractional last row
dtype = np.int8 if bps == 1 else ">i2"
good = n_angles * n_rows * n_ch * actual_n_frames * spf
data = np.frombuffer(raw[:good * bps], dtype=dtype)
data = data.reshape(n_angles, n_rows, n_ch, actual_n_frames, spf)
self.data = data.astype(np.int16 if bps == 2 else np.int8)
self.angles_deg = angles
self.y_positions_mm = y_pos
# ------------------------------------------------------------------
# Axes helpers
# ------------------------------------------------------------------
@property
def pixel_x_mm(self) -> float:
return self.velocity_mm_s / self.laser_freq_hz
def x_axis_mm(self) -> np.ndarray:
return self.x_start_mm + np.arange(self.n_frames) * self.pixel_x_mm
def time_axis_ns(self) -> np.ndarray:
return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9
def freq_axis_mhz(self) -> np.ndarray:
return np.fft.rfftfreq(self.samples_per_frame, d=1.0 / self.sample_rate_hz) / 1e6
# ---------------------------------------------------------------------------
# Image computation (vectorised)
# ---------------------------------------------------------------------------
def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray:
"""Mean of each waveform → (n_rows, n_frames) float32."""
return sras.data[angle_idx, :, ch_idx, :, :].astype(np.float32).mean(axis=-1)
def compute_rf_image(sras: SrasFile, angle_idx: int,
dc_threshold_mv: float,
apply_bg_sub: bool = True,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None) -> np.ndarray:
"""
FFT of each CH1 waveform; pixel = peak frequency in MHz.
Pixels where CH4_dc < dc_threshold_mv are set to 0; FFT is skipped for
those pixels entirely. DC is always computed before any FFT work.
The threshold and DC mean are both in mV, using per-channel calibration
from the file (or fallback constants for v2 files).
If apply_bg_sub is True and the file contains a background waveform
(v4+), each CH1 waveform has the background subtracted before the FFT.
gate_start_ns / gate_end_ns: when either is set, samples outside the
[start, end] time window are zeroed before the FFT (time-domain gating).
"""
# --- Step 1: compute CH4 DC mask before any FFT work ---
dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
mask = dc4_mv < dc_threshold_mv # True = below threshold = skip FFT
valid = ~mask # pixels that require FFT
img = np.zeros(mask.shape, dtype=np.float32)
if valid.any():
# --- Step 2: FFT only on pixels that passed the DC threshold ---
waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
# shape: (n_rows, n_frames, samples_per_frame)
if apply_bg_sub and sras.background is not None:
waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :]
if gate_start_ns is not None or gate_end_ns is not None:
t_ns = sras.time_axis_ns()
keep = np.ones(len(t_ns), dtype=bool)
if gate_start_ns is not None:
keep &= t_ns >= gate_start_ns
if gate_end_ns is not None:
keep &= t_ns <= gate_end_ns
waveforms = waveforms.copy()
waveforms[..., ~keep] = 0.0
valid_waves = waveforms[valid] # (n_valid, spf)
fft_pow = np.abs(np.fft.rfft(valid_waves, axis=-1)) ** 2
fft_pow[:, 0] = 0.0 # suppress DC bin
peak_bins = np.argmax(fft_pow, axis=-1) # (n_valid,)
img[valid] = sras.freq_axis_mhz()[peak_bins]
return img
def compute_saw_image(sras: SrasFile, angle_idx: int, dc_threshold_mv: float,
pipeline: SawPipeline, mode: str,
apply_bg_sub: bool = True) -> np.ndarray:
"""Run the SAW matched-filter pipeline over every pixel.
mode : "amplitude" → MF envelope peak in SAW window
"tof" → arrival time (ns) of that peak
Returns (n_rows, n_frames) float32, DC-masked.
"""
# --- Step 1: compute CH4 DC mask before running the pipeline ---
dc4_mv = adc_to_mv(compute_dc_image(sras, angle_idx, CH4_IDX),
sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX],
sras.ch_yzero_mv[CH4_IDX])
mask = dc4_mv < dc_threshold_mv # True = below threshold = skip pipeline
valid = ~mask
img = np.zeros(mask.shape, dtype=np.float32)
if valid.any():
# --- Step 2: run pipeline only on pixels that passed the DC threshold ---
waveforms = sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
if apply_bg_sub and sras.background is not None:
waveforms = waveforms - sras.background[np.newaxis, np.newaxis, :]
valid_waves = waveforms[valid] # (n_valid, spf)
n_workers = min(os.cpu_count() or 4, len(valid_waves))
with ThreadPoolExecutor(max_workers=n_workers) as executor:
results = list(executor.map(pipeline.process_shot, valid_waves))
if mode == "amplitude":
vals = np.array([r["peak_amplitude"] for r in results], dtype=np.float32)
else:
vals = np.array([r["peak_time_ns"] for r in results], dtype=np.float32)
img[valid] = vals
return img
# ---------------------------------------------------------------------------
# Background workers
# ---------------------------------------------------------------------------
class LoadWorker(QObject):
finished = pyqtSignal(object) # SrasFile | None
error = pyqtSignal(str)
def __init__(self, path: str):
super().__init__()
self._path = path
def run(self):
try:
self.finished.emit(SrasFile(self._path))
except Exception as exc:
self.error.emit(str(exc))
self.finished.emit(None)
class ComputeWorker(QObject):
finished = pyqtSignal(np.ndarray)
error = pyqtSignal(str)
def __init__(self, sras: SrasFile, angle_idx: int,
ch_idx: int, dc_threshold_mv: float,
grating_um: float = 12.5,
apply_bg_sub: bool = True,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None,
saw_pipeline: "SawPipeline | None" = None):
super().__init__()
self._sras = sras
self._angle = angle_idx
self._ch = ch_idx
self._threshold = dc_threshold_mv
self._grating_um = grating_um
self._apply_bg_sub = apply_bg_sub
self._gate_start = gate_start_ns
self._gate_end = gate_end_ns
self._saw_pipeline = saw_pipeline
def run(self):
try:
if self._ch == CH1_IDX:
img = compute_rf_image(self._sras, self._angle, self._threshold,
self._apply_bg_sub,
self._gate_start, self._gate_end)
elif self._ch == VELOCITY_MODE_IDX:
# velocity (m/s) = freq (MHz) × grating (µm) [units cancel to m/s]
img = compute_rf_image(self._sras, self._angle, self._threshold,
self._apply_bg_sub,
self._gate_start, self._gate_end)
img = img * self._grating_um
elif self._ch in SAW_MODES:
if self._saw_pipeline is None or self._saw_pipeline.template is None:
raise RuntimeError(
"SAW pipeline: no template built yet.\n"
"Use \"Build Template\" in the SAW Pipeline panel first.")
mode = "amplitude" if self._ch == SAW_MODE_AMP_IDX else "tof"
img = compute_saw_image(
self._sras, self._angle, self._threshold,
self._saw_pipeline, mode, self._apply_bg_sub)
else:
# DC channels: convert ADC counts → mV
adc_img = compute_dc_image(self._sras, self._angle, self._ch)
img = adc_to_mv(adc_img,
self._sras.ch_ymult_mv[self._ch],
self._sras.ch_yoff_adc[self._ch],
self._sras.ch_yzero_mv[self._ch])
self.finished.emit(img)
except Exception as exc:
self.error.emit(str(exc))
class TemplateBuildWorker(QObject):
"""Background thread worker that calls SawPipeline.build_template()."""
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, pipeline: SawPipeline, waveforms: np.ndarray):
super().__init__()
self._pipeline = pipeline
self._waveforms = waveforms
def run(self):
try:
self._pipeline.build_template(self._waveforms)
self.finished.emit()
except Exception as exc:
self.error.emit(str(exc))
# ---------------------------------------------------------------------------
# Matplotlib canvases
# ---------------------------------------------------------------------------
class ImageCanvas(FigureCanvasQTAgg):
pixel_clicked = pyqtSignal(int, int) # row_idx, frame_idx
def __init__(self, parent=None):
fig = Figure(figsize=(7, 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._extent = None
self._img_shape = None
self.mpl_connect("button_press_event", self._on_click)
def show_image(self, img: np.ndarray, extent: list[float], cmap: str,
vmin: float, vmax: float, xlabel: str, ylabel: str, title: str,
colorbar_label: str = ""):
self.figure.clf()
self.ax = self.figure.add_subplot(111)
self._extent = extent
self._img_shape = img.shape
im = self.ax.imshow(
img, aspect="auto", origin="upper",
extent=extent, cmap=cmap, vmin=vmin, vmax=vmax,
interpolation="nearest",
)
cb = self.figure.colorbar(im, ax=self.ax, fraction=0.046, pad=0.04)
if colorbar_label:
cb.set_label(colorbar_label)
self.ax.set_xlabel(xlabel)
self.ax.set_ylabel(ylabel)
self.ax.set_title(title)
self.draw()
def _on_click(self, event):
if event.inaxes is not self.ax or self._extent is None:
return
x0, x1, y_bot, y_top = self._extent
n_rows, n_frames = self._img_shape
col = int((event.xdata - x0) / (x1 - x0) * n_frames)
row = int((event.ydata - y_top) / (y_bot - y_top) * n_rows)
col = max(0, min(col, n_frames - 1))
row = max(0, min(row, n_rows - 1))
self.pixel_clicked.emit(row, col)
class WaveformCanvas(FigureCanvasQTAgg):
def __init__(self, parent=None):
fig = Figure(figsize=(8, 3), tight_layout=True)
self.ax_wave = fig.add_subplot(121)
self.ax_right = fig.add_subplot(122)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def show_rf_waveform(self, sras: SrasFile, angle_idx: int,
row_idx: int, frame_idx: int,
apply_bg_sub: bool = True,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None):
"""CH1 RF: time-domain + FFT spectrum.
If apply_bg_sub is True and sras.background is not None, the background
waveform is overlaid on the time-domain plot and the FFT is computed
on the subtracted signal. The unsubtracted FFT is also shown faintly
for comparison.
"""
waveform = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
t_ns = sras.time_axis_ns()
f_mhz = sras.freq_axis_mhz()
dc3_val = sras.data[angle_idx, row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean()
dc4_val = sras.data[angle_idx, row_idx, CH4_IDX, frame_idx, :].astype(np.float32).mean()
bg = sras.background if (apply_bg_sub and sras.background is not None) else None
waveform_plot = waveform - bg if bg is not None else waveform
self.ax_wave.cla()
self.ax_right.cla()
if bg is not None:
self.ax_wave.plot(t_ns, waveform, linewidth=0.5, color="#aaaaaa",
label="raw", zorder=1)
self.ax_wave.plot(t_ns, bg, linewidth=0.5, color="#e07030",
linestyle="--", label="background", zorder=2)
self.ax_wave.plot(t_ns, waveform_plot, linewidth=0.7, color="#4488cc",
label="subtracted", zorder=3)
self.ax_wave.legend(fontsize=7, loc="upper right")
else:
self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc")
# Draw gate boundaries if active
if gate_start_ns is not None:
self.ax_wave.axvline(gate_start_ns, color="#22cc44", linestyle="--",
linewidth=1.0, label=f"gate start {gate_start_ns:.0f} ns")
if gate_end_ns is not None:
self.ax_wave.axvline(gate_end_ns, color="#cc4422", linestyle="--",
linewidth=1.0, label=f"gate end {gate_end_ns:.0f} ns")
if gate_start_ns is not None or gate_end_ns is not None:
t_ns = sras.time_axis_ns()
lo = gate_start_ns if gate_start_ns is not None else t_ns[0]
hi = gate_end_ns if gate_end_ns is not None else t_ns[-1]
self.ax_wave.axvspan(t_ns[0], lo, alpha=0.10, color="#cc4422")
self.ax_wave.axvspan(hi, t_ns[-1], alpha=0.10, color="#cc4422")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
bg_tag = " [bg sub]" if bg is not None else ""
self.ax_wave.set_title(
f"CH1 RF row={row_idx} frame={frame_idx}{bg_tag}\n"
f"CH3={dc3_val:.1f} CH4={dc4_val:.1f} "
f"({adc_to_mv(dc3_val, sras.ch_ymult_mv[CH3_IDX], sras.ch_yoff_adc[CH3_IDX], sras.ch_yzero_mv[CH3_IDX]):.2f} / "
f"{adc_to_mv(dc4_val, sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], sras.ch_yzero_mv[CH4_IDX]):.2f} mV)",
fontsize=8,
)
# FFT of the (possibly subtracted) waveform
power_sub = np.abs(np.fft.rfft(waveform_plot)) ** 2
power_sub[0] = 0.0
peak_idx = int(np.argmax(power_sub))
peak_mhz = f_mhz[peak_idx]
if bg is not None:
# Also show the unsubtracted FFT for reference
power_raw = np.abs(np.fft.rfft(waveform)) ** 2
power_raw[0] = 0.0
self.ax_right.plot(f_mhz, power_raw, linewidth=0.5, color="#aaaaaa",
label="raw FFT", zorder=1)
self.ax_right.plot(f_mhz, power_sub, linewidth=0.7, color="#4488cc",
label="subtracted FFT" if bg is not None else None, zorder=2)
self.ax_right.axvline(peak_mhz, color="tomato", linestyle="--",
linewidth=1.2, label=f"peak = {peak_mhz:.1f} MHz")
self.ax_right.set_xlabel("Frequency (MHz)")
self.ax_right.set_ylabel("Power (arb.)")
self.ax_right.set_title("FFT Power Spectrum")
self.ax_right.set_xlim(0, 500)
self.ax_right.legend(fontsize=8)
self.draw()
def show_dc_waveform(self, sras: SrasFile, angle_idx: int, ch_idx: int,
row_idx: int, frame_idx: int):
"""CH3 or CH4 DC: time-domain + mean annotation."""
waveform = sras.data[angle_idx, row_idx, ch_idx, frame_idx, :].astype(np.float32)
t_ns = sras.time_axis_ns()
mean_val = float(waveform.mean())
mean_mv = adc_to_mv(mean_val, sras.ch_ymult_mv[ch_idx], sras.ch_yoff_adc[ch_idx],
sras.ch_yzero_mv[ch_idx])
self.ax_wave.cla()
self.ax_right.cla()
self.ax_wave.plot(t_ns, waveform, linewidth=0.7, color="#4488cc")
self.ax_wave.axhline(mean_val, color="tomato", linestyle="--",
linewidth=1.2, label=f"mean = {mean_val:.2f} ADC")
self.ax_wave.set_xlabel("Time (ns)")
self.ax_wave.set_ylabel("ADC counts")
self.ax_wave.set_title(
f"{CH_NAMES[ch_idx]} DC row={row_idx} frame={frame_idx}"
)
self.ax_wave.legend(fontsize=8)
self.ax_right.text(
0.5, 0.5,
f"DC mode\n\n"
f"mean = {mean_val:.3f} ADC\n"
f" = {mean_mv:.3f} mV",
ha="center", va="center",
transform=self.ax_right.transAxes, fontsize=11,
)
self.ax_right.set_axis_off()
self.draw()
# ---------------------------------------------------------------------------
# SAW diagnostic window
# ---------------------------------------------------------------------------
class SawDiagnosticWindow(QMainWindow):
"""6-panel matplotlib window showing every SAW pipeline stage for one pixel.
Panels:
1. Raw signal with zone shading (EMI gate / noise region / SAW window)
2. After EMI gating (cosine taper) — same zone shading
3. After bandpass filter (time domain) — zone shading
4. Frequency spectrum of bandpass output — passband shading + raw PSD
5. Matched filter output + Hilbert envelope + metrics — zone shading
6. Shot-to-shot overlay (up to 20 frames from the same row)
"""
# Zone colour constants (all panels use the same palette)
_C_EMI = "#e05030" # red — EMI gate
_C_NOISE = "#ccaa00" # amber — noise / inter-packet region
_C_SAW = "#30c060" # green — SAW window
def __init__(self, sras: SrasFile, pipeline: SawPipeline,
angle_idx: int, row_idx: int, frame_idx: int,
parent=None):
super().__init__(parent)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setWindowTitle(
f"SAW Diagnostics angle={angle_idx} row={row_idx} frame={frame_idx}")
self.resize(1400, 940)
# Store references for refresh
self._sras = sras
self._pipeline = pipeline
self._angle_idx = angle_idx
self._row_idx = row_idx
self._frame_idx = frame_idx
central = QWidget()
self.setCentralWidget(central)
vl = QVBoxLayout(central)
vl.setContentsMargins(4, 4, 4, 4)
vl.setSpacing(4)
# Toolbar row: matplotlib toolbar + refresh button
toolbar_row = QHBoxLayout()
fig = Figure(figsize=(14, 9), tight_layout=True)
self._canvas = FigureCanvasQTAgg(fig)
mpl_toolbar = NavigationToolbar2QT(self._canvas, central)
toolbar_row.addWidget(mpl_toolbar, stretch=1)
btn_refresh = QPushButton("Re-apply filter & refresh PSDs")
btn_refresh.setToolTip(
"Re-run the current pipeline on this pixel and redraw all panels.\n"
"Use after rebuilding the template or changing pipeline parameters.")
btn_refresh.clicked.connect(self._on_refresh)
toolbar_row.addWidget(btn_refresh)
vl.addLayout(toolbar_row)
vl.addWidget(self._canvas)
self._plot()
# ------------------------------------------------------------------
# Zone shading helper — call on any time-domain axes
# ------------------------------------------------------------------
def _shade_time_zones(self, ax, t_full: np.ndarray,
emi_end_ns: float, s0: float, s1: float,
show_legend: bool = False):
"""Shade EMI gate, noise region, and SAW window on a time-domain axes."""
t0, t_end = float(t_full[0]), float(t_full[-1])
ax.axvspan(t0, emi_end_ns, alpha=0.15, color=self._C_EMI,
label=f"EMI gate 0–{emi_end_ns:.0f} ns")
if emi_end_ns < s0:
ax.axvspan(emi_end_ns, s0, alpha=0.08, color=self._C_NOISE,
label=f"noise {emi_end_ns:.0f}–{s0:.0f} ns")
ax.axvspan(s0, min(s1, t_end), alpha=0.10, color=self._C_SAW,
label=f"SAW {s0:.0f}–{s1:.0f} ns")
if show_legend:
ax.legend(fontsize=7, loc="upper right")
# ------------------------------------------------------------------
def _on_refresh(self):
self._plot()
def _plot(self):
sras = self._sras
pipeline = self._pipeline
angle_idx = self._angle_idx
row_idx = self._row_idx
frame_idx = self._frame_idx
raw_adc = sras.data[angle_idx, row_idx, CH1_IDX, frame_idx, :].astype(np.float32)
t_full = sras.time_axis_ns()
result = pipeline.process_shot(raw_adc)
sr = result["sample_rate_hz"]
t_proc = np.arange(len(result["envelope"])) / sr * 1e9
# Collect up to 20 frames from the same row for the overlay panel
n_overlay = min(20, sras.n_frames)
overlay_idxs = np.linspace(0, sras.n_frames - 1, n_overlay, dtype=int)
overlays = []
for fi in overlay_idxs:
sig = sras.data[angle_idx, row_idx, CH1_IDX, fi, :].astype(np.float32)
r = pipeline.process_shot(sig)
overlays.append(r["envelope"])
# Shot-to-shot peak amplitude variance
peak_amps = [float(e[np.argmax(e)]) if len(e) > 0 else 0.0
for e in overlays]
peak_var = float(np.var(peak_amps))
fig = self._canvas.figure
fig.clf()
axes = fig.subplots(3, 2)
ax_raw, ax_gated = axes[0]
ax_filt, ax_spec = axes[1]
ax_mf, ax_over = axes[2]
emi_end_ns = pipeline.emi_gate_ns
s0, s1 = pipeline.saw_window_ns
# --- 1. Raw signal ---
ax_raw.plot(t_full, raw_adc, lw=0.6, color="#4488cc", zorder=3)
self._shade_time_zones(ax_raw, t_full, emi_end_ns, s0, s1, show_legend=True)
ax_raw.set_xlabel("Time (ns)")
ax_raw.set_ylabel("ADC counts")
ax_raw.set_title(f"1 — Raw signal (row={row_idx}, frame={frame_idx})")
# --- 2. After EMI gating ---
ax_gated.plot(t_full[:len(result["gated"])], result["gated"],
lw=0.6, color="#cc8833", zorder=3)
self._shade_time_zones(ax_gated, t_full, emi_end_ns, s0, s1)
ax_gated.set_xlabel("Time (ns)")
ax_gated.set_ylabel("Amplitude")
ax_gated.set_title("2 — After EMI gating (cosine taper)")
# --- 3. After bandpass ---
ax_filt.plot(t_full[:len(result["filtered"])], result["filtered"],
lw=0.6, color="#44aa44", zorder=3)
self._shade_time_zones(ax_filt, t_full, emi_end_ns, s0, s1)
ax_filt.set_xlabel("Time (ns)")
ax_filt.set_ylabel("Amplitude")
ax_filt.set_title(
f"3 — After bandpass ({pipeline.bp_lo_mhz:.0f}–{pipeline.bp_hi_mhz:.0f} MHz, "
f"6th-order Butterworth, zero-phase)")
# --- 4. Frequency spectrum — raw PSD + post-bandpass PSD ---
raw_sig = raw_adc.astype(np.float64)
filt_sig = result["filtered"]
f_hz = np.fft.rfftfreq(len(filt_sig), d=1.0 / sras.sample_rate_hz)
f_mhz = f_hz / 1e6
spec_raw = np.abs(np.fft.rfft(raw_sig, n=len(filt_sig))) ** 2
spec_filt = np.abs(np.fft.rfft(filt_sig)) ** 2
spec_raw[0] = 0.0
spec_filt[0] = 0.0
ax_spec.plot(f_mhz, spec_raw, lw=0.5, color="#aaaaaa", alpha=0.7,
label="raw PSD", zorder=1)
ax_spec.plot(f_mhz, spec_filt, lw=0.8, color="#44aa44",
label="bandpass PSD", zorder=2)
ax_spec.axvspan(pipeline.bp_lo_mhz, pipeline.bp_hi_mhz,
alpha=0.14, color=self._C_SAW, label="passband", zorder=0)
ax_spec.set_xlabel("Frequency (MHz)")
ax_spec.set_ylabel("Power (arb.)")
ax_spec.set_title("4 — FFT PSD: raw vs. after bandpass")
ax_spec.set_xlim(0, min(600.0, sras.sample_rate_hz / 2e6))
ax_spec.legend(fontsize=7)
# --- 5. Matched filter output + envelope ---
ax_mf.plot(t_proc, result["mf_output"], lw=0.5, color="#8855cc",
alpha=0.55, label="MF output", zorder=3)
ax_mf.plot(t_proc, result["envelope"], lw=1.3, color="#cc4488",
label="envelope (Hilbert)", zorder=4)
if pipeline.template is not None:
ax_mf.axvline(result["peak_time_ns"], color="#ffaa00",
linestyle="--", lw=1.2, zorder=5,
label=f"peak {result['peak_time_ns']:.1f} ns")
self._shade_time_zones(ax_mf, t_proc, emi_end_ns, s0, s1)
ax_mf.set_xlabel("Time (ns)")
ax_mf.set_ylabel("Amplitude")
ax_mf.set_title(
f"5 — Matched filter | "
f"A = {result['peak_amplitude']:.3f} | "
f"SNR = {result['snr']:.1f} | "
f"t = {result['peak_time_ns']:.1f} ns")
ax_mf.legend(fontsize=7)
# --- 6. Shot-to-shot overlay ---
for env in overlays:
t_ov = np.arange(len(env)) / sr * 1e9
ax_over.plot(t_ov, env, lw=0.5, alpha=0.45, color="#cc4488", zorder=3)
self._shade_time_zones(ax_over, t_proc, emi_end_ns, s0, s1)
ax_over.set_xlabel("Time (ns)")
ax_over.set_ylabel("MF envelope amplitude")
ax_over.set_title(
f"6 — Shot-to-shot overlay (row {row_idx}, {n_overlay} frames) | "
f"peak-amp variance = {peak_var:.4g}")
# Bottom metrics bar
fig.text(
0.5, 0.005,
f"Peak amplitude: {result['peak_amplitude']:.4g} | "
f"Arrival time: {result['peak_time_ns']:.2f} ns | "
f"SNR: {result['snr']:.1f} | "
f"Shot-to-shot peak-amp variance ({n_overlay} shots): {peak_var:.4g}",
ha="center", va="bottom", fontsize=9,
bbox=dict(boxstyle="round,pad=0.3", facecolor="#2a2a2a", alpha=0.85),
color="#e8e8e8",
)
self._canvas.draw()
# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------
class SrasViewerWindow(QMainWindow):
def __init__(self, initial_path: str | None = None):
super().__init__()
self.setWindowTitle("SRAS Scan Viewer")
self.resize(1560, 840)
self.setAcceptDrops(True)
self._sras: SrasFile | None = None
self._current_image: np.ndarray | None = None
self._current_angle: int = 0
self._current_ch: int = 0
self._load_thread: QThread | None = None
self._compute_thread: QThread | None = None
self._pending_angle: int = 0
self._pending_ch: int = 0
self._pending_threshold: float = 50.0 # mV
self._pending_grating_um: float = 12.5 # µm
self._pending_bg_sub: bool = True
self._pending_gate_enabled: bool = False
self._pending_gate_start: float = 0.0
self._pending_gate_end: float = 200.0
self._progress_dlg: QProgressDialog | None = None
# SAW pipeline state
self._saw_pipeline: SawPipeline | None = None
self._template_thread: QThread | None = None
self._diag_window: SawDiagnosticWindow | None = None
self._last_row: int | None = None
self._last_frame: int | None = None
self._build_ui()
if initial_path:
self._load_file(initial_path)
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(8, 8, 8, 8)
root.setSpacing(8)
# ---- Left control panel ----------------------------------------
panel = QWidget()
panel.setFixedWidth(260)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(6)
root.addWidget(panel)
# File
grp_file = QGroupBox("File")
fl = QVBoxLayout(grp_file)
self.btn_open = QPushButton("Open .sras…")
self.btn_open.clicked.connect(self._on_open)
self.lbl_filename = QLabel("No file loaded")
self.lbl_filename.setWordWrap(True)
self.lbl_filename.setStyleSheet("color: #888; font-size: 11px;")
fl.addWidget(self.btn_open)
fl.addWidget(self.lbl_filename)
panel_layout.addWidget(grp_file)
# Scan info
grp_info = QGroupBox("Scan Info")
il = QVBoxLayout(grp_info)
self._info = {}
for key in ("Angles", "Rows", "Frames / row", "Samples / frame",
"Sample rate", "X start", "Pixel Δx", "Laser freq"):
lbl = QLabel(f"{key}: —")
lbl.setWordWrap(True)
lbl.setStyleSheet("font-size: 11px;")
il.addWidget(lbl)
self._info[key] = lbl
# Frame count warning (hidden until needed)
self.lbl_frame_warn = QLabel("")
self.lbl_frame_warn.setWordWrap(True)
self.lbl_frame_warn.setStyleSheet("color: #e07000; font-size: 11px;")
il.addWidget(self.lbl_frame_warn)
panel_layout.addWidget(grp_info)
# View settings
grp_view = QGroupBox("View Settings")
vl = QVBoxLayout(grp_view)
# Angle
ar = QHBoxLayout()
ar.addWidget(QLabel("Angle:"))
self.spin_angle = QSpinBox()
self.spin_angle.setRange(0, 0)
self.spin_angle.setEnabled(False)
self.spin_angle.valueChanged.connect(self._on_view_changed)
self.lbl_angle_deg = QLabel("—")
ar.addWidget(self.spin_angle)
ar.addWidget(self.lbl_angle_deg)
vl.addLayout(ar)
# Channel
cr = QHBoxLayout()
cr.addWidget(QLabel("Channel:"))
self.combo_channel = QComboBox()
self.combo_channel.addItems(CH_LABELS)
self.combo_channel.setEnabled(False)
self.combo_channel.currentIndexChanged.connect(self._on_channel_changed)
cr.addWidget(self.combo_channel)
vl.addLayout(cr)
# DC threshold (for RF / CH1 masking)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #555;")
vl.addWidget(sep)
self.grp_threshold = QGroupBox("RF Mask Threshold (CH1 only)")
tl = QVBoxLayout(self.grp_threshold)
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("DC threshold:"))
self.spin_threshold_mv = QDoubleSpinBox()
self.spin_threshold_mv.setRange(-500.0, 500.0)
self.spin_threshold_mv.setDecimals(3)
self.spin_threshold_mv.setSingleStep(0.025)
self.spin_threshold_mv.setSuffix(" mV")
self.spin_threshold_mv.setValue(50.0)
self.spin_threshold_mv.setEnabled(False)
self.spin_threshold_mv.valueChanged.connect(self._on_threshold_changed)
thr_row.addWidget(self.spin_threshold_mv)
tl.addLayout(thr_row)
self.lbl_threshold_adc = QLabel(f"≈ {mv_to_adc(50.0):.1f} ADC counts") # updated on file load
self.lbl_threshold_adc.setStyleSheet("font-size: 11px; color: #888;")
tl.addWidget(self.lbl_threshold_adc)
vl.addWidget(self.grp_threshold)
# Background subtraction (v4 files only)
self.chk_bg_sub = QCheckBox("Background subtraction (CH1 only)")
self.chk_bg_sub.setChecked(True)
self.chk_bg_sub.setEnabled(False)
self.chk_bg_sub.setToolTip(
"Subtract the stored background waveform from each CH1 frame\n"
"before computing the FFT (v4 files only)."
)
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
vl.addWidget(self.chk_bg_sub)
# Time gate (for FFT; CH1/velocity only)
self.grp_gate = QGroupBox("Time Gate (CH1 only)")
gl = QVBoxLayout(self.grp_gate)
self.chk_gate = QCheckBox("Enable time gate")
self.chk_gate.setChecked(False)
self.chk_gate.setEnabled(False)
self.chk_gate.setToolTip(
"Zero-out samples outside the specified time window before\n"
"computing the FFT (useful for isolating a specific acoustic packet)."
)
self.chk_gate.toggled.connect(self._on_gate_toggled)
gl.addWidget(self.chk_gate)
gate_start_row = QHBoxLayout()
gate_start_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._on_gate_changed)
gate_start_row.addWidget(self.spin_gate_start)
gl.addLayout(gate_start_row)
gate_end_row = QHBoxLayout()
gate_end_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._on_gate_changed)
gate_end_row.addWidget(self.spin_gate_end)
gl.addLayout(gate_end_row)
vl.addWidget(self.grp_gate)
# Velocity settings (visible only in velocity mode)
self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)")
vel_l = QVBoxLayout(self.grp_velocity)
grat_row = QHBoxLayout()
grat_row.addWidget(QLabel("Grating size:"))
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.valueChanged.connect(self._on_grating_changed)
grat_row.addWidget(self.spin_grating_um)
vel_l.addLayout(grat_row)
self.lbl_velocity_formula = QLabel("v (m/s) = freq (MHz) × grating (µm)")
self.lbl_velocity_formula.setStyleSheet("font-size: 10px; color: #888;")
vel_l.addWidget(self.lbl_velocity_formula)
self.grp_velocity.setVisible(False)
vl.addWidget(self.grp_velocity)
# Export
sep2 = QFrame()
sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet("color: #555;")
vl.addWidget(sep2)
self.btn_export_csv = QPushButton("Export Image as CSV…")
self.btn_export_csv.setEnabled(False)
self.btn_export_csv.setToolTip(
"Save the current CH1 image (one scan row per CSV line)."
)
self.btn_export_csv.clicked.connect(self._on_export_csv)
vl.addWidget(self.btn_export_csv)
panel_layout.addWidget(grp_view)
# ---- SAW Pipeline panel ----------------------------------------
grp_saw = QGroupBox("SAW Pipeline (CH1 only)")
sl = QVBoxLayout(grp_saw)
# EMI gate end
emi_row = QHBoxLayout()
emi_row.addWidget(QLabel("EMI gate end:"))
self.spin_saw_emi_ns = QDoubleSpinBox()
self.spin_saw_emi_ns.setRange(1.0, 10000.0)
self.spin_saw_emi_ns.setDecimals(1)
self.spin_saw_emi_ns.setSingleStep(5.0)
self.spin_saw_emi_ns.setSuffix(" ns")
self.spin_saw_emi_ns.setValue(50.0)
emi_row.addWidget(self.spin_saw_emi_ns)
sl.addLayout(emi_row)
# SAW window
sl.addWidget(QLabel("SAW window (ns):"))
saw_win_row = QHBoxLayout()
self.spin_saw_win_start = QDoubleSpinBox()
self.spin_saw_win_start.setRange(0.0, 100000.0)
self.spin_saw_win_start.setDecimals(1)
self.spin_saw_win_start.setSuffix(" ns")
self.spin_saw_win_start.setValue(80.0)
saw_win_row.addWidget(self.spin_saw_win_start)
saw_win_row.addWidget(QLabel("–"))
self.spin_saw_win_end = QDoubleSpinBox()
self.spin_saw_win_end.setRange(0.0, 100000.0)
self.spin_saw_win_end.setDecimals(1)
self.spin_saw_win_end.setSuffix(" ns")
self.spin_saw_win_end.setValue(350.0)
saw_win_row.addWidget(self.spin_saw_win_end)
sl.addLayout(saw_win_row)
# Bandpass limits
sl.addWidget(QLabel("Bandpass (MHz):"))
bp_row = QHBoxLayout()
self.spin_saw_bp_lo = QDoubleSpinBox()
self.spin_saw_bp_lo.setRange(1.0, 3000.0)
self.spin_saw_bp_lo.setDecimals(1)
self.spin_saw_bp_lo.setSuffix(" MHz")
self.spin_saw_bp_lo.setValue(85.0)
bp_row.addWidget(self.spin_saw_bp_lo)
bp_row.addWidget(QLabel("–"))
self.spin_saw_bp_hi = QDoubleSpinBox()
self.spin_saw_bp_hi.setRange(1.0, 3000.0)
self.spin_saw_bp_hi.setDecimals(1)
self.spin_saw_bp_hi.setSuffix(" MHz")
self.spin_saw_bp_hi.setValue(200.0)
bp_row.addWidget(self.spin_saw_bp_hi)
sl.addLayout(bp_row)
# Template shots
tmpl_row = QHBoxLayout()
tmpl_row.addWidget(QLabel("Template shots:"))
self.spin_saw_n_shots = QSpinBox()
self.spin_saw_n_shots.setRange(1, 10000)
self.spin_saw_n_shots.setValue(50)
tmpl_row.addWidget(self.spin_saw_n_shots)
sl.addLayout(tmpl_row)
self.btn_build_template = QPushButton("Build Template")
self.btn_build_template.setEnabled(False)
self.btn_build_template.setToolTip(
"Average N shots (EMI-gated + bandpass-filtered) to form a\n"
"Hann-windowed template for the matched filter.")
self.btn_build_template.clicked.connect(self._on_build_template_clicked)
sl.addWidget(self.btn_build_template)
self.lbl_saw_status = QLabel("No template")
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #888;")
self.lbl_saw_status.setWordWrap(True)
sl.addWidget(self.lbl_saw_status)
# ---- Apply matched filter controls ----
sep_mf = QFrame()
sep_mf.setFrameShape(QFrame.Shape.HLine)
sep_mf.setStyleSheet("color: #555;")
sl.addWidget(sep_mf)
sl.addWidget(QLabel("Apply matched filter:"))
mf_mode_row = QHBoxLayout()
mf_mode_row.addWidget(QLabel("Output:"))
self.combo_mf_mode = QComboBox()
self.combo_mf_mode.addItems(["Amplitude", "Time-of-Flight"])
self.combo_mf_mode.setEnabled(False)
mf_mode_row.addWidget(self.combo_mf_mode)
sl.addLayout(mf_mode_row)
self.btn_apply_mf = QPushButton("Apply Filter → Image")
self.btn_apply_mf.setEnabled(False)
self.btn_apply_mf.setToolTip(
"Switch to the SAW matched-filter channel and compute the image.\n"
"Requires a template to be built first.")
self.btn_apply_mf.clicked.connect(self._on_apply_mf_clicked)
sl.addWidget(self.btn_apply_mf)
panel_layout.addStretch()
# Colormap
sep2 = QFrame()
sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet("color: #555;")
vl.addWidget(sep2)
cmr = QHBoxLayout()
cmr.addWidget(QLabel("Colormap:"))
self.combo_cmap = QComboBox()
self.combo_cmap.addItems(CMAPS)
self.combo_cmap.setCurrentText("gray")
self.combo_cmap.setEnabled(False)
self.combo_cmap.currentIndexChanged.connect(self._on_view_changed)
cmr.addWidget(self.combo_cmap)
vl.addLayout(cmr)
# Auto-scale
self.chk_auto = QCheckBox("Auto-scale colormap")
self.chk_auto.setChecked(True)
self.chk_auto.toggled.connect(self._on_autoscale_toggled)
vl.addWidget(self.chk_auto)
for label, attr in (("min:", "spin_vmin"), ("max:", "spin_vmax")):
row = QHBoxLayout()
row.addWidget(QLabel(label))
spin = QDoubleSpinBox()
spin.setRange(-1e9, 1e9)
spin.setDecimals(4)
spin.setEnabled(False)
spin.valueChanged.connect(self._on_manual_range_changed)
setattr(self, attr, spin)
row.addWidget(spin)
vl.addLayout(row)
# ---- Right: image + waveform splitter --------------------------
splitter = QSplitter(Qt.Orientation.Vertical)
root.addWidget(splitter, stretch=1)
# Image canvas
img_widget = QWidget()
img_vl = QVBoxLayout(img_widget)
img_vl.setContentsMargins(0, 0, 0, 0)
self.image_canvas = ImageCanvas()
self.image_canvas.pixel_clicked.connect(self._on_pixel_clicked)
toolbar = NavigationToolbar2QT(self.image_canvas, img_widget)
img_vl.addWidget(toolbar)
img_vl.addWidget(self.image_canvas)
splitter.addWidget(img_widget)
# Waveform inspector
wave_widget = QWidget()
wave_vl = QVBoxLayout(wave_widget)
wave_vl.setContentsMargins(0, 0, 0, 0)
self.lbl_wave_hint = QLabel(
"Click a pixel in the image above to inspect its waveform."
)
self.lbl_wave_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_wave_hint.setStyleSheet("color: #888; font-size: 11px;")
self.wave_canvas = WaveformCanvas()
self.btn_saw_diag = QPushButton("Open SAW Diagnostics…")
self.btn_saw_diag.setEnabled(False)
self.btn_saw_diag.setToolTip(
"Show the 6-panel SAW pipeline diagnostic for the clicked pixel.\n"
"Requires a SAW template to be built first.")
self.btn_saw_diag.clicked.connect(self._on_open_diagnostics)
wave_vl.addWidget(self.lbl_wave_hint)
wave_vl.addWidget(self.btn_saw_diag)
wave_vl.addWidget(self.wave_canvas)
splitter.addWidget(wave_widget)
splitter.setSizes([580, 250])
# ---- Right control panel (SAW pipeline) ----------------------------
right_panel = QWidget()
right_panel.setFixedWidth(260)
right_panel_layout = QVBoxLayout(right_panel)
right_panel_layout.setContentsMargins(0, 0, 0, 0)
right_panel_layout.setSpacing(6)
right_panel_layout.addWidget(grp_saw)
right_panel_layout.addStretch()
root.addWidget(right_panel)
self.statusBar().showMessage("Open an .sras file to begin.")
# ------------------------------------------------------------------
# Drag-and-drop
# ------------------------------------------------------------------
def dragEnterEvent(self, event):
urls = event.mimeData().urls()
if urls and urls[0].toLocalFile().lower().endswith(".sras"):
event.acceptProposedAction()
def dropEvent(self, event):
self._load_file(event.mimeData().urls()[0].toLocalFile())
# ------------------------------------------------------------------
# File loading
# ------------------------------------------------------------------
def _on_open(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open SRAS File", "", "SRAS Files (*.sras);;All Files (*)"
)
if path:
self._load_file(path)
def _load_file(self, path: str):
if self._load_thread is not None:
return
self.btn_open.setEnabled(False)
self.statusBar().showMessage(f"Loading {Path(path).name}…")
self._show_progress(f"Loading {Path(path).name}…")
self._load_worker = LoadWorker(path)
self._load_thread = QThread()
self._load_worker.moveToThread(self._load_thread)
self._load_thread.started.connect(self._load_worker.run)
self._load_worker.finished.connect(self._on_load_done)
self._load_worker.error.connect(
lambda msg: self.statusBar().showMessage(f"Error: {msg}")
)
self._load_worker.finished.connect(self._load_thread.quit)
self._load_thread.finished.connect(lambda: setattr(self, "_load_thread", None))
self._load_thread.start()
def _on_load_done(self, sras):
self._close_progress()
self.btn_open.setEnabled(True)
if sras is None:
return
self._sras = sras
self._current_image = None
s = sras
self.lbl_filename.setText(s.path.name)
self._info["Angles"].setText(f"Angles: {s.n_angles}")
self._info["Rows"].setText(f"Rows: {s.n_rows}")
self._info["Frames / row"].setText(f"Frames / row: {s.n_frames}")
self._info["Samples / frame"].setText(f"Samples / frame: {s.samples_per_frame}")
self._info["Sample rate"].setText(f"Sample rate: {s.sample_rate_hz/1e9:.4g} GS/s")
self._info["X start"].setText(f"X start: {s.x_start_mm:.4g} mm")
self._info["Pixel Δx"].setText(f"Pixel Δx: {s.pixel_x_mm*1e3:.3g} µm")
self._info["Laser freq"].setText(f"Laser freq: {s.laser_freq_hz/1e3:.4g} kHz")
notes = []
if s.frame_count_mismatch:
notes.append(
f"! Header n_frames={s.n_frames_header}, "
f"actual={s.n_frames} (scanner bug — corrected)"
)
if s.background is not None:
notes.append(f"Background waveform: {len(s.background)} samples")
self.lbl_frame_warn.setText("\n".join(notes))
self.spin_angle.blockSignals(True)
self.spin_angle.setRange(0, max(0, s.n_angles - 1))
self.spin_angle.setValue(0)
self.spin_angle.blockSignals(False)
self._update_controls_enabled(True)
# Refresh the ADC-count label now that we have file calibration
self._on_threshold_changed(self.spin_threshold_mv.value())
self._on_view_changed()
# ------------------------------------------------------------------
# Controls
# ------------------------------------------------------------------
def _update_controls_enabled(self, enabled: bool):
s = self._sras
self.spin_angle.setEnabled(enabled and s is not None and s.n_angles > 1)
self.combo_channel.setEnabled(enabled)
self.combo_cmap.setEnabled(enabled)
self.chk_auto.setEnabled(enabled)
manual = enabled and not self.chk_auto.isChecked()
self.spin_vmin.setEnabled(manual)
self.spin_vmax.setEnabled(manual)
ch_idx = self.combo_channel.currentIndex()
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
is_saw = enabled and ch_idx in SAW_MODES
# Threshold and bg-sub apply to all CH1 modes
self.spin_threshold_mv.setEnabled(is_ch1)
has_bg = enabled and s is not None and s.background is not None
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
# Time gate only for legacy FFT modes (SAW pipeline has its own gating)
self.chk_gate.setEnabled(is_fft)
gate_active = is_fft and self.chk_gate.isChecked()
self.spin_gate_start.setEnabled(gate_active)
self.spin_gate_end.setEnabled(gate_active)
# Velocity grating spinbox
is_vel = enabled and ch_idx == VELOCITY_MODE_IDX
self.spin_grating_um.setEnabled(is_vel)
self.grp_velocity.setVisible(is_vel)
# SAW pipeline build button
has_file = enabled and s is not None
self.btn_build_template.setEnabled(has_file)
# Diagnostics button: need template + a clicked pixel
has_template = self._saw_pipeline is not None and self._saw_pipeline.template is not None
has_pixel = self._last_row is not None
self.btn_saw_diag.setEnabled(has_file and has_template and has_pixel)
# Apply filter button: need file + template
self.btn_apply_mf.setEnabled(has_file and has_template)
self.combo_mf_mode.setEnabled(has_file and has_template)
# CSV export: enabled when a CH1-derived image is displayed
self.btn_export_csv.setEnabled(is_ch1 and self._current_image is not None)
def _on_channel_changed(self):
ch_idx = self.combo_channel.currentIndex()
has_file = self._sras is not None
is_ch1 = ch_idx in CH1_DERIVED_MODES
is_fft = ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
self.spin_threshold_mv.setEnabled(is_ch1 and has_file)
has_bg = has_file and self._sras.background is not None
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
self.chk_gate.setEnabled(is_fft and has_file)
gate_active = is_fft and has_file and self.chk_gate.isChecked()
self.spin_gate_start.setEnabled(gate_active)
self.spin_gate_end.setEnabled(gate_active)
is_vel = ch_idx == VELOCITY_MODE_IDX
self.spin_grating_um.setEnabled(is_vel and has_file)
self.grp_velocity.setVisible(is_vel)
self.btn_export_csv.setEnabled(is_ch1 and has_file and self._current_image is not None)
self._on_view_changed()
def _on_bg_sub_toggled(self):
if self._sras is not None:
if self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute()
def _on_gate_toggled(self, checked: bool):
self.spin_gate_start.setEnabled(checked)
self.spin_gate_end.setEnabled(checked)
if self._sras is not None:
ch_idx = self.combo_channel.currentIndex()
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
self._start_compute()
def _on_gate_changed(self):
if self._sras is not None and self.chk_gate.isChecked():
ch_idx = self.combo_channel.currentIndex()
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
self._start_compute()
def _on_grating_changed(self):
if self._sras is not None and self.combo_channel.currentIndex() == VELOCITY_MODE_IDX:
self._start_compute()
def _on_export_csv(self):
if self._current_image is None or self._sras is None:
return
ch_idx = self._current_ch
angle = self._current_angle
ch_name = CH_NAMES[ch_idx]
default_name = (
f"{self._sras.path.stem}_angle{angle}_{ch_name}.csv"
)
path, _ = QFileDialog.getSaveFileName(
self, "Export Image as CSV",
str(self._sras.path.parent / default_name),
"CSV files (*.csv);;All files (*)",
)
if not path:
return
np.savetxt(path, self._current_image, delimiter=",", fmt="%.6g")
self.statusBar().showMessage(f"Exported {Path(path).name}")
def _on_threshold_changed(self, mv: float):
if self._sras is not None:
ymult = self._sras.ch_ymult_mv[CH4_IDX]
yoff = self._sras.ch_yoff_adc[CH4_IDX]
yzero = self._sras.ch_yzero_mv[CH4_IDX]
else:
ymult, yoff, yzero = _FALLBACK_YMULT_MV, _FALLBACK_YOFF_ADC, 0.0
self.lbl_threshold_adc.setText(f"≈ {mv_to_adc(mv, ymult, yoff, yzero):.1f} ADC counts")
if self._sras is not None and self.combo_channel.currentIndex() in CH1_DERIVED_MODES:
self._start_compute()
def _on_autoscale_toggled(self, checked: bool):
manual = not checked
self.spin_vmin.setEnabled(manual and self._sras is not None)
self.spin_vmax.setEnabled(manual and self._sras is not None)
if self._sras is not None and self._current_image is not None:
self._redraw_image(self._current_image)
def _on_manual_range_changed(self):
if not self.chk_auto.isChecked() and self._current_image is not None:
self._redraw_image(self._current_image)
def _on_view_changed(self):
if self._sras is None:
return
idx = self.spin_angle.value()
self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)")
self._start_compute()
# ------------------------------------------------------------------
# Computation
# ------------------------------------------------------------------
def _start_compute(self):
if self._sras is None:
return
if self._compute_thread is not None:
return # re-check in _on_compute_thread_finished
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked()
gate_enabled = self.chk_gate.isChecked()
gate_start = self.spin_gate_start.value() if gate_enabled else None
gate_end = self.spin_gate_end.value() if gate_enabled else None
self._pending_angle = angle_idx
self._pending_ch = ch_idx
self._pending_threshold = threshold_mv
self._pending_grating_um = grating_um
self._pending_bg_sub = apply_bg_sub
self._pending_gate_enabled = gate_enabled
self._pending_gate_start = self.spin_gate_start.value()
self._pending_gate_end = self.spin_gate_end.value()
self.statusBar().showMessage("Computing image…")
self._show_progress("Computing image…")
self._compute_worker = ComputeWorker(
self._sras, angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
gate_start_ns=gate_start, gate_end_ns=gate_end,
saw_pipeline=self._saw_pipeline,
)
self._compute_thread = QThread()
self._compute_worker.moveToThread(self._compute_thread)
self._compute_thread.started.connect(self._compute_worker.run)
self._compute_worker.finished.connect(self._on_compute_done)
self._compute_worker.error.connect(
lambda msg: self.statusBar().showMessage(f"Compute error: {msg}")
)
self._compute_worker.finished.connect(self._compute_thread.quit)
self._compute_thread.finished.connect(self._on_compute_thread_finished)
self._compute_thread.start()
def _on_compute_thread_finished(self):
self._compute_thread = None
angle_idx = self.spin_angle.value()
ch_idx = self.combo_channel.currentIndex()
threshold_mv = self.spin_threshold_mv.value()
grating_um = self.spin_grating_um.value()
apply_bg_sub = self.chk_bg_sub.isChecked()
gate_enabled = self.chk_gate.isChecked()
if (angle_idx, ch_idx, threshold_mv, grating_um, apply_bg_sub,
gate_enabled,
self.spin_gate_start.value(), self.spin_gate_end.value()) != (
self._pending_angle, self._pending_ch,
self._pending_threshold, self._pending_grating_um,
self._pending_bg_sub,
self._pending_gate_enabled,
self._pending_gate_start, self._pending_gate_end):
self._start_compute()
def _on_compute_done(self, img: np.ndarray):
self._close_progress()
self._current_image = img
self._current_angle = self._pending_angle
self._current_ch = self._pending_ch
self.btn_export_csv.setEnabled(self._pending_ch in CH1_DERIVED_MODES)
self._redraw_image(img)
def _redraw_image(self, img: np.ndarray):
s = self._sras
x_axis = s.x_axis_mm()
y_axis = s.y_positions_mm
dx = x_axis[1] - x_axis[0] if len(x_axis) > 1 else s.pixel_x_mm
dy = float(y_axis[1] - y_axis[0]) if len(y_axis) > 1 else 1.0
extent = [
x_axis[0] - dx / 2,
x_axis[-1] + dx / 2,
y_axis[-1] + dy / 2,
y_axis[0] - dy / 2,
]
if self.chk_auto.isChecked():
vmin, vmax = float(img.min()), float(img.max())
for spin, val in ((self.spin_vmin, vmin), (self.spin_vmax, vmax)):
spin.blockSignals(True)
spin.setValue(val)
spin.blockSignals(False)
else:
vmin = self.spin_vmin.value()
vmax = self.spin_vmax.value()
ch_idx = self._current_ch
angle_deg = s.angles_deg[self._current_angle]
ch_label = CH_LABELS[ch_idx]
if ch_idx == CH1_IDX:
mode_str = "RF"
unit = "Peak frequency (MHz)"
colorbar_label = "MHz"
elif ch_idx == VELOCITY_MODE_IDX:
grating = self.spin_grating_um.value()
mode_str = "Velocity"
unit = "Velocity (m/s)"
colorbar_label = "m/s"
ch_label = f"Velocity [grating={grating:.2f} µm]"
elif ch_idx == SAW_MODE_AMP_IDX:
mode_str = "SAW-AMP"
unit = "MF envelope peak (arb.)"
colorbar_label = "amplitude"
elif ch_idx == SAW_MODE_TOF_IDX:
mode_str = "SAW-TOF"
unit = "SAW arrival time (ns)"
colorbar_label = "ns"
else:
mode_str = "DC"
unit = "DC mean (mV)"
colorbar_label = "mV"
title = f"{CH_NAMES[ch_idx]} | {mode_str} | {angle_deg:.1f}°"
self.image_canvas.show_image(
img, extent,
cmap=self.combo_cmap.currentText(),
vmin=vmin, vmax=vmax,
xlabel="X (mm)", ylabel="Y (mm)",
title=title,
colorbar_label=colorbar_label,
)
self.statusBar().showMessage(
f"{s.path.name} | {ch_label} @ {angle_deg:.1f}° "
f"| {img.shape[1]} × {img.shape[0]} px | {unit}"
)
# ------------------------------------------------------------------
# Pixel inspector
# ------------------------------------------------------------------
def _on_pixel_clicked(self, row_idx: int, frame_idx: int):
if self._sras is None or self._current_image is None:
return
self._last_row = row_idx
self._last_frame = frame_idx
self.lbl_wave_hint.hide()
ch_idx = self._current_ch
if ch_idx in CH1_DERIVED_MODES:
gate_enabled = self.chk_gate.isChecked() and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
self.wave_canvas.show_rf_waveform(
self._sras, self._current_angle, row_idx, frame_idx,
apply_bg_sub=self.chk_bg_sub.isChecked(),
gate_start_ns=self.spin_gate_start.value() if gate_enabled else None,
gate_end_ns=self.spin_gate_end.value() if gate_enabled else None,
)
else:
self.wave_canvas.show_dc_waveform(
self._sras, self._current_angle, ch_idx, row_idx, frame_idx
)
# Update diagnostics button availability
has_template = (self._saw_pipeline is not None and
self._saw_pipeline.template is not None)
self.btn_saw_diag.setEnabled(
self._sras is not None and has_template and True)
# ------------------------------------------------------------------
# SAW pipeline management
# ------------------------------------------------------------------
def _on_build_template_clicked(self):
if self._sras is None or self._template_thread is not None:
return
# (Re-)create pipeline with current settings
sr = self._sras.sample_rate_hz
self._saw_pipeline = SawPipeline(
sample_rate_hz = sr,
emi_gate_ns = self.spin_saw_emi_ns.value(),
bp_lo_mhz = self.spin_saw_bp_lo.value(),
bp_hi_mhz = self.spin_saw_bp_hi.value(),
saw_window_ns = (self.spin_saw_win_start.value(),
self.spin_saw_win_end.value()),
decimate_enable = False,
)
angle_idx = self.spin_angle.value()
if self._last_row is not None and self._last_frame is not None:
# Build from the single selected pixel's waveform
waveforms = self._sras.data[
angle_idx, self._last_row, CH1_IDX,
self._last_frame:self._last_frame + 1, :
].astype(np.float32)
n_shots = 1
src_desc = f"selected pixel (row={self._last_row}, frame={self._last_frame})"
else:
# No pixel selected — sample N shots spread across the whole scan
n_shots = min(self.spin_saw_n_shots.value(),
self._sras.n_frames * self._sras.n_rows)
waveforms_all = self._sras.data[angle_idx, :, CH1_IDX, :, :].astype(np.float32)
waveforms_flat = waveforms_all.reshape(-1, waveforms_all.shape[-1])
indices = np.linspace(0, len(waveforms_flat) - 1, n_shots, dtype=int)
waveforms = waveforms_flat[indices]
src_desc = f"{n_shots} shots (full scan)"
if (self._sras.background is not None and self.chk_bg_sub.isChecked()):
waveforms = waveforms - self._sras.background[np.newaxis, :]
self.btn_build_template.setEnabled(False)
self.lbl_saw_status.setText(f"Building template from {src_desc}…")
self._show_progress("Building SAW template…")
self._template_worker = TemplateBuildWorker(self._saw_pipeline, waveforms)
self._template_thread = QThread()
self._template_worker.moveToThread(self._template_thread)
self._template_thread.started.connect(self._template_worker.run)
self._template_worker.finished.connect(self._on_template_built)
self._template_worker.error.connect(self._on_template_error)
self._template_worker.finished.connect(self._template_thread.quit)
self._template_worker.error.connect(self._template_thread.quit)
self._template_thread.finished.connect(
lambda: setattr(self, "_template_thread", None))
self._template_thread.start()
def _on_template_built(self):
self._close_progress()
self.btn_build_template.setEnabled(True)
if self._last_row is not None and self._last_frame is not None:
src = f"pixel row={self._last_row} frame={self._last_frame}"
else:
src = f"{self.spin_saw_n_shots.value()} shots"
self.lbl_saw_status.setText(
f"Template ready ({src})\n"
f"EMI gate: {self.spin_saw_emi_ns.value():.0f} ns "
f"BP: {self.spin_saw_bp_lo.value():.0f}–{self.spin_saw_bp_hi.value():.0f} MHz")
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #44cc66;")
has_pixel = self._last_row is not None
self.btn_saw_diag.setEnabled(self._sras is not None and has_pixel)
self.btn_apply_mf.setEnabled(True)
self.combo_mf_mode.setEnabled(True)
def _on_template_error(self, msg: str):
self._close_progress()
self.btn_build_template.setEnabled(True)
self.lbl_saw_status.setText(f"Error: {msg}")
self.lbl_saw_status.setStyleSheet("font-size: 11px; color: #e05030;")
def _on_open_diagnostics(self):
if (self._sras is None or self._saw_pipeline is None or
self._last_row is None):
return
if self._diag_window is not None:
try:
self._diag_window.close()
except RuntimeError:
pass # C++ object already deleted (user closed the window)
self._diag_window = None
self._diag_window = SawDiagnosticWindow(
self._sras, self._saw_pipeline,
self._current_angle, self._last_row, self._last_frame,
parent=None, # free-floating window
)
# Clear our reference when the user closes the window so we never
# call into a deleted C++ object again.
self._diag_window.destroyed.connect(
lambda: setattr(self, "_diag_window", None))
self._diag_window.show()
def _on_apply_mf_clicked(self):
if self._sras is None or self._saw_pipeline is None:
return
if self._saw_pipeline.template is None:
self.statusBar().showMessage(
"No template built yet — use 'Build Template' first.")
return
target_ch = (SAW_MODE_AMP_IDX
if self.combo_mf_mode.currentIndex() == 0
else SAW_MODE_TOF_IDX)
self.combo_channel.blockSignals(True)
self.combo_channel.setCurrentIndex(target_ch)
self.combo_channel.blockSignals(False)
self._on_channel_changed()
# ------------------------------------------------------------------
# Progress dialog helpers
# ------------------------------------------------------------------
def _show_progress(self, message: str):
if self._progress_dlg is not None:
self._progress_dlg.setLabelText(message)
return
dlg = QProgressDialog(message, "", 0, 0, self)
dlg.setWindowTitle("Please wait…")
dlg.setCancelButton(None)
dlg.setWindowModality(Qt.WindowModality.WindowModal)
dlg.setMinimumDuration(300) # only appears if operation takes > 300 ms
dlg.show()
self._progress_dlg = dlg
def _close_progress(self):
if self._progress_dlg is not None:
self._progress_dlg.close()
self._progress_dlg = None
# ------------------------------------------------------------------
def closeEvent(self, event):
for attr in ("_load_thread", "_compute_thread", "_template_thread"):
t = getattr(self, attr, None)
if t is not None:
t.quit()
t.wait(2000)
if self._diag_window is not None:
try:
self._diag_window.close()
except RuntimeError:
pass
super().closeEvent(event)
# ---------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
initial = sys.argv[1] if len(sys.argv) > 1 else None
window = SrasViewerWindow(initial_path=initial)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()