From 4a4e588097ef8137b6bd97acb7c45f11a4dd0bb0 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Thu, 30 Jul 2026 19:17:38 -0500 Subject: [PATCH] Remove Time Gate and SAW pipeline; add SRAS v6 format support Drops the legacy Time Gate FFT-windowing feature and the SAW matched-filter pipeline (SawPipeline, SawDiagnosticWindow, TemplateBuildWorker, and the associated UI panels/channel modes) to simplify the viewer. Adds native support for the SRAS v6 file format (scan_format.md), which scans a different bounding box per angle instead of a uniform AABB. Scan geometry (n_rows, n_frames, x_start) is now exposed per-angle in SrasFile, with v2-v5 files populating those arrays uniformly so both formats share one code path. Waveform data is memory-mapped per angle to handle v6's ragged layout, and aborted/truncated v6 scans are handled gracefully. "Pre-process and Save as v5" is disabled for v6 files since the flat v5 layout can't represent per-angle geometry. Co-Authored-By: Claude Sonnet 5 --- scan_format.md | 2 +- sras_viewer.py | 1252 ++++++++++++------------------------------------ 2 files changed, 296 insertions(+), 958 deletions(-) diff --git a/scan_format.md b/scan_format.md index dc6af21..c864ff4 100644 --- a/scan_format.md +++ b/scan_format.md @@ -206,5 +206,5 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not | 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. | | 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. | | 5 | (skipped) | -| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). | +| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with pre-v6 readers** that assume uniform geometry. `sras_viewer.py` reads v6 natively (per-angle `n_rows`/`n_frames`/`x_start`); the "Pre-process and Save as v5" fast-path export is not offered for v6 files since the flat v5 layout cannot represent per-angle geometry. | diff --git a/sras_viewer.py b/sras_viewer.py index 5d22fd4..87071ca 100644 --- a/sras_viewer.py +++ b/sras_viewer.py @@ -14,6 +14,12 @@ 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. + +Scan geometry: v6 files scan a different bounding box per angle (x_start, +x_delta, n_frames, n_rows all vary by angle), so geometry is exposed per-angle +via SrasFile.n_rows / n_frames / x_start_mm arrays and the x_axis_mm() / +y_positions_mm() methods. v2–v5 files have uniform geometry across angles, so +those arrays simply repeat the same value n_angles times. """ import re @@ -21,10 +27,7 @@ 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 -import scipy.fft as scipy_fft from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, @@ -52,6 +55,8 @@ try: except ImportError: pass +import scipy.fft as scipy_fft + # Runtime-mutable settings changed via FftOptionsDialog _fft_backend = "numpy" # "numpy" or "pyfftw" @@ -64,198 +69,22 @@ def _do_rfft(x: np.ndarray, n: int | None = None, axis: int = -1, return scipy_fft.rfft(x, n=n, axis=axis, workers=workers) -# --------------------------------------------------------------------------- -# 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 - # ------------------------------------------------------------------ - # 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: - n = len(proc) - nfft = 1 << (n + len(self.template) - 1).bit_length() - S = scipy_fft.rfft(proc.astype(np.float64), n=nfft, workers=1) - T = scipy_fft.rfft(self.template.astype(np.float64), n=nfft, workers=1) - mf_out = scipy_fft.irfft(S * np.conj(T), n=nfft, workers=1)[:n].astype(np.float32) - env = np.abs(hilbert(mf_out)).astype(np.float32) - 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 # --------------------------------------------------------------------------- +# v2–v5: fixed header, uniform geometry across angles (43 bytes) HDR_FMT = ">4sBHHffffIIdBB" HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes +# v6: fixed header, per-angle geometry in a separate table (49 bytes) +HDR_FMT_V6 = ">4sBHfffffffIdBB" +HDR_SIZE_V6 = struct.calcsize(HDR_FMT_V6) # 49 bytes + +# v6: per-angle geometry table record (x_start, x_delta, n_frames, n_rows) +GEO_FMT_V6 = ">ffIH" +GEO_SIZE_V6 = struct.calcsize(GEO_FMT_V6) # 14 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 @@ -265,18 +94,13 @@ CH_LABELS = [ "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"] +CH_NAMES = ["CH1", "CH3", "CH4", "VEL"] -# Combo indices for derived modes (all use CH1_IDX data) +# Combo index for the derived velocity mode (uses 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 +CH1_DERIVED_MODES = (CH1_IDX, VELOCITY_MODE_IDX) # Fallback scope calibration used only when reading v2 files without embedded # preambles. v3+ files carry the WFMOutpre string so these are not used. @@ -320,34 +144,54 @@ def adc_to_mv(adc: float, ymult_mv: float = _FALLBACK_YMULT_MV, # --------------------------------------------------------------------------- class SrasFile: - """Parsed in-memory representation of a v2/v3/v4 .sras file.""" + """Parsed in-memory representation of a v2–v6 .sras file. + + Scan geometry (rows, frames, x_start) is exposed per-angle via the + ``n_rows`` / ``n_frames`` / ``x_start_mm`` arrays and the ``x_axis_mm()`` + / ``y_positions_mm()`` methods, since v6 files scan a different bounding + box per angle. v2–v5 files have uniform geometry, so these arrays just + repeat the same value ``n_angles`` times. Waveform data is likewise + exposed as ``data[angle_idx]``, an array of shape + ``(n_rows[a], n_channels, n_frames[a], samples_per_frame)``. + """ def __init__(self, path: str): self.path = Path(path) self._parse() def _parse(self): + with open(self.path, "rb") as f: + magic = f.read(4) + if magic != b"SRAS": + raise ValueError(f"Bad magic bytes: {magic!r}") + (version,) = struct.unpack(">B", f.read(1)) + + self.version = version + if version in (2, 3, 4, 5): + self._parse_legacy() + elif version == 6: + self._parse_v6() + else: + raise ValueError(f"Unsupported version: {version}") + + # ------------------------------------------------------------------ + # v2–v5 parsing (uniform geometry, flat waveform block) + # ------------------------------------------------------------------ + + def _parse_legacy(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, 5): - 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 + self.n_angles = n_angles + 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 # Precomputed-image cache (populated when reading a v5 file). # These are (n_angles, n_rows, n_frames) float32 arrays or None. @@ -355,6 +199,8 @@ class SrasFile: self.precomputed_dc4_mv: np.ndarray | None = None self.precomputed_dc3_mv: np.ndarray | None = None self.precomputed_bg_sub: bool = False + self.scan_aborted = False + self.n_angles_declared = n_angles with open(self.path, "rb") as f: f.seek(HDR_SIZE) @@ -404,10 +250,6 @@ class SrasFile: # Upper bound: bytes from data_offset to end of file available_bytes = file_size - data_offset - # Detect v5 PREC tail: if ver == 5, the tail size is known once we - # know n_frames. We resolve this by computing n_frames from the - # waveform-only region. For a v5 file the writer stores the exact - # actual frame count in n_frames_hdr, so we trust it. if ver == 5: actual_n_frames = n_frames_hdr remainder = 0 @@ -416,7 +258,6 @@ class SrasFile: 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 self.frame_count_mismatch = (actual_n_frames != n_frames_hdr) self.n_frames_remainder = remainder @@ -425,16 +266,25 @@ class SrasFile: # memmap lets the OS page only the bytes that are actually touched. waveform_dtype = np.int8 if bps == 1 else ">i2" waveform_shape = (n_angles, n_rows, n_ch, actual_n_frames, spf) - self.data = np.memmap( + data5d = np.memmap( str(self.path), dtype=waveform_dtype, mode="r", offset=data_offset, shape=waveform_shape, ) + # Expose as a list of per-angle views so downstream code shares one + # indexing convention with v6: sras.data[a][row, ch, frame, sample] + self.data = [data5d[a] for a in range(n_angles)] + + # Uniform per-angle geometry, repeated so callers don't need to + # special-case legacy vs. v6 files. + self.n_rows = np.full(n_angles, n_rows, dtype=np.int64) + self.n_frames = np.full(n_angles, actual_n_frames, dtype=np.int64) + self.x_start_mm = np.full(n_angles, float(x_start), dtype=np.float64) + self._y_pos_per_angle = [y_pos] * n_angles self.angles_deg = angles - self.y_positions_mm = y_pos # ---- Read v5 precomputed section if present -------------------- if ver >= 5: @@ -481,6 +331,120 @@ class SrasFile: self.precomputed_dc4_mv = dc4_buf self.precomputed_dc3_mv = dc3_buf + # ------------------------------------------------------------------ + # v6 parsing (per-angle geometry, ragged waveform blocks) + # ------------------------------------------------------------------ + + def _parse_v6(self): + with open(self.path, "rb") as f: + fields = struct.unpack(HDR_FMT_V6, f.read(HDR_SIZE_V6)) + (magic, ver, n_angles, x_start_nom, y_start_nom, x_delta_nom, + y_delta_nom, row_spacing, vel, freq, spf, sr, bps, n_ch) = fields + + n_angles_declared = n_angles + + self.velocity_mm_s = float(vel) + self.laser_freq_hz = float(freq) + self.samples_per_frame = spf + self.sample_rate_hz = float(sr) + self.bytes_per_sample = bps + self.n_channels = n_ch + + # Reference-only fields: the ROI as entered before per-angle + # bounding-box expansion. Actual per-angle geometry used for + # rendering comes from the Per-Angle Geometry Table below. + self.x_start_nominal_mm = float(x_start_nom) + self.y_start_nominal_mm = float(y_start_nom) + self.x_delta_nominal_mm = float(x_delta_nom) + self.y_delta_nominal_mm = float(y_delta_nom) + self.row_spacing_mm = float(row_spacing) + + self.n_frames_header = None + self.frame_count_mismatch = False + self.n_frames_remainder = 0 + + self.precomputed_freq_mhz: np.ndarray | None = None + self.precomputed_dc4_mv: np.ndarray | None = None + self.precomputed_dc3_mv: np.ndarray | None = None + self.precomputed_bg_sub: bool = False + + angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32) + + x_start = np.empty(n_angles, dtype=np.float64) + n_frames = np.empty(n_angles, dtype=np.int64) + n_rows = np.empty(n_angles, dtype=np.int64) + for a in range(n_angles): + xs, xd, nf, nr = struct.unpack(GEO_FMT_V6, f.read(GEO_SIZE_V6)) + x_start[a] = xs + n_frames[a] = nf + n_rows[a] = nr + + y_pos_per_angle = [] + for a in range(n_angles): + nr = int(n_rows[a]) + y_pos_per_angle.append( + np.frombuffer(f.read(nr * 4), dtype=">f4").astype(np.float32)) + + 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) + self.ch_ymult_mv.append(cal.get("YMULT", _FALLBACK_YMULT_MV / 1000) * 1000) + self.ch_yoff_adc.append(cal.get("YOFF", _FALLBACK_YOFF_ADC)) + self.ch_yzero_mv.append(cal.get("YZERO", 0.0) * 1000) + + (n_bg,) = struct.unpack(">I", f.read(4)) + self.background = np.frombuffer(f.read(n_bg), dtype=np.int8).astype(np.float32) + + data_offset = f.tell() + + # ---- Memory-map each angle's ragged waveform block ------------- + # v6 gives each angle its own row/frame count, so waveform data is + # no longer one uniform (n_angles, n_rows, ...) block — each angle's + # block sits at a different offset with its own shape. An aborted + # scan truncates the file mid-angle; per the format spec we keep + # whatever complete angles are present rather than refusing to open + # the file. + file_size = self.path.stat().st_size + waveform_dtype = np.int8 if bps == 1 else ">i2" + + data = [] + offset = data_offset + n_complete = 0 + for a in range(n_angles): + nr = int(n_rows[a]) + nf = int(n_frames[a]) + nbytes = nr * n_ch * nf * spf * bps + if offset + nbytes > file_size: + break + data.append(np.memmap( + str(self.path), dtype=waveform_dtype, mode="r", + offset=offset, shape=(nr, n_ch, nf, spf), + )) + offset += nbytes + n_complete += 1 + + if n_complete == 0: + raise ValueError( + "v6 file has no complete angle blocks — scan was aborted " + "before the first angle finished.") + + self.data = data + self.n_angles = n_complete + self.n_angles_declared = n_angles_declared + self.scan_aborted = n_complete < n_angles_declared + self.angles_deg = angles[:n_complete] + self.x_start_mm = x_start[:n_complete] + self.n_frames = n_frames[:n_complete] + self.n_rows = n_rows[:n_complete] + self._y_pos_per_angle = y_pos_per_angle[:n_complete] + # ------------------------------------------------------------------ # v5 writer # ------------------------------------------------------------------ @@ -501,17 +465,28 @@ class SrasFile: shape (n_angles, n_rows, n_frames) float32. *progress_cb*: optional callable(fraction: float) for UI updates. + + Only valid for v2–v5 source files, which have uniform per-angle + geometry. v6 files scan a different bounding box per angle and + cannot be losslessly represented in the flat v5 layout. """ + if self.version == 6: + raise NotImplementedError( + "Pre-process to v5 is not supported for v6 source files " + "(per-angle geometry does not fit the flat v5 layout).") + import shutil dest = Path(dest_path) src = self.path + n_rows0 = int(self.n_rows[0]) + n_frames0 = int(self.n_frames[0]) # --- Copy the source file verbatim, then patch the header ------- shutil.copy2(str(src), str(dest)) - waveform_bytes = (self.n_angles * self.n_rows * self.n_channels - * self.n_frames * self.samples_per_frame + waveform_bytes = (self.n_angles * n_rows0 * self.n_channels + * n_frames0 * self.samples_per_frame * self.bytes_per_sample) with open(str(dest), "r+b") as f: @@ -524,7 +499,7 @@ class SrasFile: # then x_start(4)+x_delta(4)+vel(4)+freq(4) = 16, total = 25 # then n_frames_hdr is at offset 25 as ">I" (4 bytes) f.seek(25) - f.write(struct.pack(">I", self.n_frames)) + f.write(struct.pack(">I", n_frames0)) # Truncate anything after the waveform data (e.g. old PREC tail) # and seek to the append position. @@ -558,12 +533,12 @@ class SrasFile: fields = struct.unpack(HDR_FMT, f.read(HDR_SIZE)) ver = fields[1] n_ch = fields[12] - n_bg_bytes = 0 + n_rows0 = int(self.n_rows[0]) with open(self.path, "rb") as f: f.seek(HDR_SIZE) f.read(self.n_angles * 4) # angles - f.read(self.n_rows * 4) # y_pos + f.read(n_rows0 * 4) # y_pos if ver >= 3: for _ in range(n_ch): (length,) = struct.unpack(">H", f.read(2)) @@ -581,8 +556,12 @@ class SrasFile: 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 x_axis_mm(self, angle_idx: int) -> np.ndarray: + n = int(self.n_frames[angle_idx]) + return self.x_start_mm[angle_idx] + np.arange(n) * self.pixel_x_mm + + def y_positions_mm(self, angle_idx: int) -> np.ndarray: + return self._y_pos_per_angle[angle_idx] def time_axis_ns(self) -> np.ndarray: return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9 @@ -597,7 +576,6 @@ class SrasFile: # --------------------------------------------------------------------------- - _CHUNK_ROWS = 32 # rows processed per FFT/DC chunk; tune for RAM vs. throughput @@ -607,12 +585,14 @@ def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray: Processes in row chunks so the float32 working buffer is bounded to ``_CHUNK_ROWS × n_frames × spf × 4`` bytes regardless of scan size. """ - n_rows, n_frames = sras.n_rows, sras.n_frames + n_rows = int(sras.n_rows[angle_idx]) + n_frames = int(sras.n_frames[angle_idx]) + data = sras.data[angle_idx] img = np.empty((n_rows, n_frames), dtype=np.float32) for r0 in range(0, n_rows, _CHUNK_ROWS): r1 = min(r0 + _CHUNK_ROWS, n_rows) img[r0:r1] = ( - sras.data[angle_idx, r0:r1, ch_idx, :, :] + data[r0:r1, ch_idx, :, :] .astype(np.float32) .mean(axis=-1) ) @@ -622,28 +602,26 @@ def compute_dc_image(sras: SrasFile, angle_idx: int, ch_idx: int) -> np.ndarray: 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, n_fft: int | 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. Fast path: if the file contains v5 precomputed peak-frequency images, - and time-gating and zero-padding are not active, and the bg-sub flag - matches, the stored images are used directly — no FFT is run. + and zero-padding is not active, and the bg-sub flag matches, the stored + images are used directly — no FFT is run. Otherwise, data is processed in row chunks of ``_CHUNK_ROWS`` to bound peak RAM to roughly ``_CHUNK_ROWS × n_frames × max(spf, n_fft) × 24`` bytes regardless of scan size. """ - n_rows, n_frames = sras.n_rows, sras.n_frames + n_rows = int(sras.n_rows[angle_idx]) + n_frames = int(sras.n_frames[angle_idx]) + data = sras.data[angle_idx] # ---- Fast path: v5 precomputed images ---------------------------------- can_use_precomputed = ( sras.precomputed_freq_mhz is not None - and gate_start_ns is None - and gate_end_ns is None and n_fft is None # no custom zero-padding and sras.precomputed_bg_sub == (apply_bg_sub and sras.background is not None) ) @@ -658,20 +636,11 @@ def compute_rf_image(sras: SrasFile, angle_idx: int, img = np.zeros((n_rows, n_frames), dtype=np.float32) _n_workers = os.cpu_count() or 4 - gate_keep: np.ndarray | None = None - if gate_start_ns is not None or gate_end_ns is not None: - t_ns = sras.time_axis_ns() - gate_keep = np.ones(len(t_ns), dtype=bool) - if gate_start_ns is not None: - gate_keep &= t_ns >= gate_start_ns - if gate_end_ns is not None: - gate_keep &= t_ns <= gate_end_ns - for r0 in range(0, n_rows, _CHUNK_ROWS): r1 = min(r0 + _CHUNK_ROWS, n_rows) # DC mask for this chunk (float32 expansion is only chunk-sized) - dc4_raw = sras.data[angle_idx, r0:r1, CH4_IDX, :, :].astype(np.float32) + dc4_raw = data[r0:r1, CH4_IDX, :, :].astype(np.float32) dc4_mv = adc_to_mv(dc4_raw.mean(axis=-1), sras.ch_ymult_mv[CH4_IDX], sras.ch_yoff_adc[CH4_IDX], @@ -682,14 +651,11 @@ def compute_rf_image(sras: SrasFile, angle_idx: int, if not valid.any(): continue - waveforms = sras.data[angle_idx, r0:r1, CH1_IDX, :, :].astype(np.float32) + waveforms = data[r0:r1, CH1_IDX, :, :].astype(np.float32) if apply_bg_sub and sras.background is not None: waveforms -= sras.background # background is 1-D (spf,) - if gate_keep is not None: - waveforms[:, :, ~gate_keep] = 0.0 - valid_waves = waveforms[valid] # (n_valid, spf) del waveforms @@ -704,45 +670,6 @@ def compute_rf_image(sras: SrasFile, angle_idx: int, 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 # --------------------------------------------------------------------------- @@ -771,9 +698,6 @@ class ComputeWorker(QObject): ch_idx: int, dc_threshold_mv: float, grating_um: float = 25, apply_bg_sub: bool = True, - gate_start_ns: float | None = None, - gate_end_ns: float | None = None, - saw_pipeline: "SawPipeline | None" = None, n_fft: int | None = None): super().__init__() self._sras = sras @@ -782,34 +706,18 @@ class ComputeWorker(QObject): 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 self._n_fft = n_fft 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, - n_fft=self._n_fft) + self._apply_bg_sub, n_fft=self._n_fft) 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, - n_fft=self._n_fft) + self._apply_bg_sub, n_fft=self._n_fft) 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) @@ -822,30 +730,12 @@ class ComputeWorker(QObject): 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)) - - class PreprocessWorker(QObject): """Compute all-angle FFT and DC images and write a v5 file. Emits ``progress(int)`` (0–100) as each angle completes and ``finished(str)`` with an empty string on success or an error message - on failure. + on failure. Only used for v2–v5 source files (uniform geometry). """ progress = pyqtSignal(int) # 0–100 finished = pyqtSignal(str) # empty = success, else error message @@ -860,7 +750,9 @@ class PreprocessWorker(QObject): try: sras = self._sras n = sras.n_angles - freq_images = np.empty((n, sras.n_rows, sras.n_frames), dtype=np.float32) + n_rows0 = int(sras.n_rows[0]) + n_frames0 = int(sras.n_frames[0]) + freq_images = np.empty((n, n_rows0, n_frames0), dtype=np.float32) dc4_images = np.empty_like(freq_images) dc3_images = np.empty_like(freq_images) @@ -1257,9 +1149,7 @@ class WaveformCanvas(FigureCanvasQTAgg): 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): + apply_bg_sub: bool = True): """CH1 RF: time-domain + FFT spectrum. If apply_bg_sub is True and sras.background is not None, the background @@ -1267,11 +1157,12 @@ class WaveformCanvas(FigureCanvasQTAgg): 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) + data = sras.data[angle_idx] + waveform = data[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() + dc3_val = data[row_idx, CH3_IDX, frame_idx, :].astype(np.float32).mean() + dc4_val = data[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 @@ -1290,20 +1181,6 @@ class WaveformCanvas(FigureCanvasQTAgg): 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 "" @@ -1343,7 +1220,7 @@ class WaveformCanvas(FigureCanvasQTAgg): 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) + 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], @@ -1375,221 +1252,6 @@ class WaveformCanvas(FigureCanvasQTAgg): 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 -# --------------------------------------------------------------------------- - # --------------------------------------------------------------------------- # FFT Options dialog # --------------------------------------------------------------------------- @@ -1718,6 +1380,8 @@ class FftOptionsDialog(QDialog): return max(1, self._spin_pad.value()) +# --------------------------------------------------------------------------- +# Main window # --------------------------------------------------------------------------- class SrasViewerWindow(QMainWindow): @@ -1738,9 +1402,6 @@ class SrasViewerWindow(QMainWindow): self._pending_threshold: float = 50.0 # mV self._pending_grating_um: float = 25 # µ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 # FFT settings (configured via FFT Options dialog) @@ -1749,13 +1410,6 @@ class SrasViewerWindow(QMainWindow): self._preprocess_thread: QThread | 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: @@ -1861,58 +1515,17 @@ class SrasViewerWindow(QMainWindow): tl.addWidget(self.lbl_threshold_adc) vl.addWidget(self.grp_threshold) - # Background subtraction (v4 files only) + # 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)." + "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.editingFinished.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.editingFinished.connect(self._on_gate_changed) - gate_end_row.addWidget(self.spin_gate_end) - gl.addLayout(gate_end_row) - - # (grp_gate will be added to the right panel below) - # Velocity settings (visible only in velocity mode) self.grp_velocity = QGroupBox("Velocity Settings (CH1 only)") vel_l = QVBoxLayout(self.grp_velocity) @@ -1985,105 +1598,6 @@ class SrasViewerWindow(QMainWindow): rl.addWidget(lbl) panel_layout.addWidget(grp_roi) - - # ---- 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() # ---- Display Options group (added to right panel below) ------------ @@ -2144,14 +1658,7 @@ class SrasViewerWindow(QMainWindow): 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) @@ -2163,10 +1670,8 @@ class SrasViewerWindow(QMainWindow): right_panel_layout = QVBoxLayout(right_panel) right_panel_layout.setContentsMargins(0, 0, 0, 0) right_panel_layout.setSpacing(6) - right_panel_layout.addWidget(self.grp_gate) right_panel_layout.addWidget(self.grp_velocity) right_panel_layout.addWidget(grp_display) - right_panel_layout.addWidget(grp_saw) right_panel_layout.addStretch() root.addWidget(right_panel) @@ -2184,7 +1689,7 @@ class SrasViewerWindow(QMainWindow): self._preprocess_act = QAction("&Pre-process and Save as v5…", self) self._preprocess_act.setStatusTip( "Compute FFT and DC images for all angles and save to a v5 file " - "for instant re-opening (no FFT on load).") + "for instant re-opening (no FFT on load). Not available for v6 files.") self._preprocess_act.setEnabled(False) self._preprocess_act.triggered.connect(self._on_preprocess) fft_menu.addAction(self._preprocess_act) @@ -2243,32 +1748,10 @@ class SrasViewerWindow(QMainWindow): # geometry, so discard it on every load. self.image_canvas.clear_roi() - 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") - if s.precomputed_freq_mhz is not None: - bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)" - notes.append(f"v5: precomputed images present{bg_note} — display is instant") - self.lbl_frame_warn.setText("\n".join(notes)) + self.lbl_filename.setText(sras.path.name) self.spin_angle.blockSignals(True) - self.spin_angle.setRange(0, max(0, s.n_angles - 1)) + self.spin_angle.setRange(0, max(0, sras.n_angles - 1)) self.spin_angle.setValue(0) self.spin_angle.blockSignals(False) @@ -2277,6 +1760,43 @@ class SrasViewerWindow(QMainWindow): self._on_threshold_changed() self._on_view_changed() + # ------------------------------------------------------------------ + # Scan info panel + # ------------------------------------------------------------------ + + def _update_scan_info_labels(self): + s = self._sras + if s is None: + return + angle_idx = self.spin_angle.value() + self._info["Angles"].setText(f"Angles: {s.n_angles}") + self._info["Rows"].setText(f"Rows: {s.n_rows[angle_idx]}") + self._info["Frames / row"].setText(f"Frames / row: {s.n_frames[angle_idx]}") + 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[angle_idx]:.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[angle_idx]} (scanner bug — corrected)" + ) + if s.scan_aborted: + notes.append( + f"! Scan aborted: {s.n_angles}/{s.n_angles_declared} angles complete" + ) + if s.background is not None: + notes.append(f"Background waveform: {len(s.background)} samples") + if s.precomputed_freq_mhz is not None: + bg_note = " (bg-sub)" if s.precomputed_bg_sub else " (no bg-sub)" + notes.append(f"v5: precomputed images present{bg_note} — display is instant") + if s.version == 6: + notes.append("v6 format: rows / frames / x_start are per-angle") + self.lbl_frame_warn.setText("\n".join(notes)) + # ------------------------------------------------------------------ # Controls # ------------------------------------------------------------------ @@ -2292,51 +1812,31 @@ class SrasViewerWindow(QMainWindow): 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) # ROI: always usable once a file is loaded (independent of channel) - self.btn_draw_roi.setEnabled(has_file) - # Pre-process: available whenever a file is loaded and not already running - self._preprocess_act.setEnabled(has_file and self._preprocess_thread is None) + self.btn_draw_roi.setEnabled(enabled and s is not None) + # Pre-process: available for v2–v5 files when loaded and not already running + can_preprocess = (enabled and s is not None and s.version != 6 + and self._preprocess_thread is None) + self._preprocess_act.setEnabled(can_preprocess) self._update_roi_ui() 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) @@ -2348,20 +1848,6 @@ class SrasViewerWindow(QMainWindow): 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() @@ -2424,8 +1910,8 @@ class SrasViewerWindow(QMainWindow): npix = 0 if self._sras is not None: try: - mask = roi.mask_for_grid(self._sras.x_axis_mm(), - self._sras.y_positions_mm) + mask = roi.mask_for_grid(self._sras.x_axis_mm(self._current_angle), + self._sras.y_positions_mm(self._current_angle)) npix = int(mask.sum()) except Exception: npix = 0 @@ -2449,8 +1935,8 @@ class SrasViewerWindow(QMainWindow): self.statusBar().showMessage("No ROI — draw one first") return s = self._sras - x_axis = s.x_axis_mm() - y_axis = s.y_positions_mm + x_axis = s.x_axis_mm(self._current_angle) + y_axis = s.y_positions_mm(self._current_angle) X, Y = np.meshgrid(np.asarray(x_axis, dtype=np.float64), np.asarray(y_axis, dtype=np.float64)) mask = roi.mask_for_grid(x_axis, y_axis) @@ -2529,6 +2015,7 @@ class SrasViewerWindow(QMainWindow): return idx = self.spin_angle.value() self.lbl_angle_deg.setText(f"({self._sras.angles_deg[idx]:.1f}°)") + self._update_scan_info_labels() self._start_compute() # ------------------------------------------------------------------ @@ -2546,9 +2033,6 @@ class SrasViewerWindow(QMainWindow): 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 pad_factor = self._fft_pad_factor n_fft = (self._sras.samples_per_frame * pad_factor if pad_factor > 1 else None) @@ -2558,9 +2042,6 @@ class SrasViewerWindow(QMainWindow): 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._pending_fft_pad_factor = pad_factor self.statusBar().showMessage("Computing image…") @@ -2568,8 +2049,6 @@ class SrasViewerWindow(QMainWindow): 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, n_fft=n_fft, ) self._compute_thread = QThread() @@ -2590,16 +2069,11 @@ class SrasViewerWindow(QMainWindow): 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._fft_pad_factor) != ( 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._pending_fft_pad_factor): self._start_compute() @@ -2614,8 +2088,9 @@ class SrasViewerWindow(QMainWindow): def _redraw_image(self, img: np.ndarray): s = self._sras - x_axis = s.x_axis_mm() - y_axis = s.y_positions_mm + angle_idx = self._current_angle + x_axis = s.x_axis_mm(angle_idx) + y_axis = s.y_positions_mm(angle_idx) 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 @@ -2650,14 +2125,6 @@ class SrasViewerWindow(QMainWindow): 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)" @@ -2685,145 +2152,17 @@ class SrasViewerWindow(QMainWindow): 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 @@ -2854,6 +2193,10 @@ class SrasViewerWindow(QMainWindow): if self._sras is None: return s = self._sras + if s.version == 6: + self.statusBar().showMessage( + "Pre-process to v5 is not supported for v6 files (per-angle geometry).") + return default_name = s.path.stem + "_v5" + s.path.suffix dest, _ = QFileDialog.getSaveFileName( self, "Save Pre-processed v5 File", @@ -2866,11 +2209,11 @@ class SrasViewerWindow(QMainWindow): apply_bg = self.chk_bg_sub.isChecked() and s.background is not None n_total = s.n_angles - n_px = s.n_rows * s.n_frames + n_px = int(s.n_rows[0]) * int(s.n_frames[0]) approx_mb = n_total * n_px * 3 * 4 / 1e6 self._show_progress( f"Pre-processing {n_total} angle(s) " - f"({s.n_rows}×{s.n_frames} px each, ~{approx_mb:.0f} MB output)…" + f"({int(s.n_rows[0])}×{int(s.n_frames[0])} px each, ~{approx_mb:.0f} MB output)…" ) self._preprocess_worker = PreprocessWorker(s, dest, apply_bg) @@ -2897,7 +2240,8 @@ class SrasViewerWindow(QMainWindow): def _on_preprocess_thread_finished(self): self._preprocess_thread = None - self._preprocess_act.setEnabled(self._sras is not None) + self._preprocess_act.setEnabled( + self._sras is not None and self._sras.version != 6) # ------------------------------------------------------------------ # FFT Options @@ -2925,17 +2269,11 @@ class SrasViewerWindow(QMainWindow): # ------------------------------------------------------------------ def closeEvent(self, event): - for attr in ("_load_thread", "_compute_thread", - "_template_thread", "_preprocess_thread"): + for attr in ("_load_thread", "_compute_thread", "_preprocess_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)