Per-angle background capture: v7/v11 .sras layout
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.
Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.
Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.
v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.
Two fixes made along the way:
* QtScanController never accepted file_version, so every scan launched
from the app raised TypeError at construction.
* angle_status() left its cursor parked at the frontier, so every angle
past it reported the frontier's own data_offset — which handed a resumed
scan the same write position for several angles. Two recorded offsets in
tests/golden/sras_expected.json are corrected accordingly.
The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+24
-12
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SRAS Scan File Viewer
|
||||
PyQt6 application for visualizing channel data from v6 .sras scan files.
|
||||
PyQt6 application for visualizing channel data from .sras scan files.
|
||||
|
||||
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
|
||||
CH1 — RF Acoustic Packet: FFT → peak frequency
|
||||
@@ -76,11 +76,18 @@ class LoadedScan:
|
||||
def __init__(self, path: str):
|
||||
self.sras = SrasFile(Path(path))
|
||||
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
|
||||
bg = np.frombuffer(self.sras.background, dtype=np.int8)
|
||||
self.background = bg.astype(np.float32) if len(bg) else None
|
||||
# One background per angle (v7/v11), captured just before that angle
|
||||
# was scanned. A legacy v6/v10 file has a single one, which SrasFile
|
||||
# repeats for every angle, so nothing here branches on the version.
|
||||
self.backgrounds = [self.sras.background_array(ai)
|
||||
for ai in range(self.sras.header.n_angles)]
|
||||
# Rows actually on disk per angle (aborted/resumed scans)
|
||||
self.rows_available = [s.n_rows_available for s in self.sras.angle_status()]
|
||||
|
||||
def background(self, angle_idx: int) -> np.ndarray | None:
|
||||
"""That angle's background waveform, or None if it has none."""
|
||||
return self.backgrounds[angle_idx]
|
||||
|
||||
def angle_view(self, angle_idx: int) -> np.ndarray:
|
||||
"""Available rows of one angle: (rows, n_ch, n_frames, spf) int8 view."""
|
||||
return self.sras.load_angle(angle_idx, n_rows=self.rows_available[angle_idx])
|
||||
@@ -123,7 +130,7 @@ def compute_image(scan: LoadedScan, angle_idx: int, ch_idx: int,
|
||||
if view.shape[0] == 0:
|
||||
return np.zeros((0, 0), dtype=np.float32)
|
||||
sras = scan.sras
|
||||
bg = scan.background if apply_bg_sub else None
|
||||
bg = scan.background(angle_idx) if apply_bg_sub else None
|
||||
|
||||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||||
return compute_rf_image(
|
||||
@@ -232,7 +239,7 @@ class WaveformCanvas(FigureCanvasQTAgg):
|
||||
dc4_val = float(sras.load_row(angle_idx, row_idx, CH4_IDX)[frame_idx]
|
||||
.mean(dtype=np.float32))
|
||||
|
||||
bg = scan.background if apply_bg_sub else None
|
||||
bg = scan.background(angle_idx) if apply_bg_sub else None
|
||||
waveform_plot = waveform - bg if bg is not None else waveform
|
||||
|
||||
self.ax_wave.cla()
|
||||
@@ -696,7 +703,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
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"
|
||||
"Subtract this angle's stored background waveform from each CH1 frame\n"
|
||||
"before computing the FFT."
|
||||
)
|
||||
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
|
||||
@@ -1043,7 +1050,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._on_view_changed()
|
||||
|
||||
def _update_angle_info(self):
|
||||
"""Per-angle info fields (v6 geometry is ragged across angles)."""
|
||||
"""Per-angle info fields (geometry is ragged across angles)."""
|
||||
scan = self._scan
|
||||
if scan is None:
|
||||
return
|
||||
@@ -1060,8 +1067,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
if avail < pa.n_rows:
|
||||
notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk "
|
||||
"(scan was aborted or is still running)")
|
||||
if scan.background is not None:
|
||||
notes.append(f"Background waveform: {len(scan.background)} samples")
|
||||
bg = scan.background(ai)
|
||||
if bg is not None:
|
||||
notes.append(f"Background waveform (angle {ai}): {len(bg)} samples")
|
||||
self.lbl_frame_warn.setText("\n".join(notes))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1083,7 +1091,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
|
||||
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||||
self.spin_threshold_mv.setEnabled(is_ch1)
|
||||
has_bg = has_file and scan.background is not None
|
||||
has_bg = has_file and scan.background(self.spin_angle.value()) is not None
|
||||
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
|
||||
# Time gate only for FFT modes (the SAW pipeline has its own gating)
|
||||
self.chk_gate.setEnabled(is_fft)
|
||||
@@ -1197,6 +1205,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
idx = self.spin_angle.value()
|
||||
self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)")
|
||||
self._update_angle_info()
|
||||
# Backgrounds are per angle, so whether there is one to subtract can
|
||||
# change with the angle (a truncated file may be missing later ones).
|
||||
self._update_controls_enabled(True)
|
||||
self._request_compute()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1382,7 +1393,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
angle_idx = self.spin_angle.value()
|
||||
n_shots_req = self.spin_saw_n_shots.value()
|
||||
row_sel, frame_sel = self._last_row, self._last_frame
|
||||
apply_bg = scan.background is not None and self.chk_bg_sub.isChecked()
|
||||
background = scan.background(angle_idx)
|
||||
apply_bg = background is not None and self.chk_bg_sub.isChecked()
|
||||
|
||||
if row_sel is not None and frame_sel is not None:
|
||||
src_desc = f"selected pixel (row={row_sel}, frame={frame_sel})"
|
||||
@@ -1405,7 +1417,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
rows, frames = np.divmod(flat, n_frames)
|
||||
shots = view[rows, CH1_IDX, frames].astype(np.float32)
|
||||
if apply_bg:
|
||||
shots -= scan.background
|
||||
shots -= background
|
||||
pipeline.build_template(shots)
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user