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:
+37
-19
@@ -1,20 +1,22 @@
|
||||
#!/opt/srasenv/bin/python3
|
||||
"""
|
||||
SRAS Scan Manager
|
||||
Command-line / interactive TUI for inspecting v6 .sras files.
|
||||
Command-line / interactive TUI for inspecting .sras files.
|
||||
|
||||
A .sras file (see scan_format.md) holds one acquisition run across several
|
||||
GR rotation angles, each with its own geometry (x_start, x_delta, n_frames,
|
||||
n_rows) and waveform data block. This tool lists those per-angle sub-scans
|
||||
and lets you export a subset to a new .sras file, or delete a subset from
|
||||
the file in place — both operations rewrite the angle/geometry/row tables
|
||||
and stream-copy only the selected angles' waveform data, producing a file
|
||||
that is itself a valid .sras readable by sras_viewer.py-style tools
|
||||
(once updated for v6) or sc3_aui_app.py.
|
||||
n_rows), background waveform, and waveform data block. This tool lists those
|
||||
per-angle sub-scans and lets you export a subset to a new .sras file, or
|
||||
delete a subset from the file in place — both operations rewrite the
|
||||
angle/geometry/row tables, carry each kept angle's background across, and
|
||||
stream-copy only the selected angles' waveform data, producing a file that is
|
||||
itself a valid .sras readable by sras_viewer.py or sc3_aui_app.py.
|
||||
|
||||
Format versions 6 (full scan) and 10 (middle-row SAW check) are supported.
|
||||
A subset keeps the version of the file it came from — a v10 check exports as
|
||||
a v10 check, since dropping angles from one leaves it one row per angle.
|
||||
Format versions 7 (full scan) and 11 (middle-row SAW check) are supported,
|
||||
as are their pre-per-angle-background predecessors 6 and 10. A subset keeps
|
||||
the version — and therefore the background layout — of the file it came
|
||||
from: a v11 check exports as a v11 check, since dropping angles from one
|
||||
leaves it one row per angle.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -26,7 +28,9 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from core.sras_format import GEOM_FMT, HDR_FMT, MAGIC, VERSION_SAW_CHECK, SrasFile
|
||||
from core.sras_format import (
|
||||
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, SrasFile,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -39,7 +43,8 @@ class AngleEntry:
|
||||
n_rows_declared: int
|
||||
y_positions: list # declared length; may exceed what's actually on disk
|
||||
row_bytes: int
|
||||
data_offset: int # byte offset into the file where this angle's data starts
|
||||
background: bytes # this angle's own background (v7/v11)
|
||||
data_offset: int # byte offset into the file where this angle's rows start
|
||||
n_rows_available: int = 0
|
||||
data_size_available: int = 0
|
||||
complete: bool = True
|
||||
@@ -60,6 +65,7 @@ class SrasScanFile:
|
||||
sras = SrasFile(self.path)
|
||||
h = sras.header
|
||||
self.version = sras.version
|
||||
self.is_saw_check = sras.is_saw_check
|
||||
self.x_start_nominal = h.x_start_nominal
|
||||
self.y_start_nominal = h.y_start_nominal
|
||||
self.x_delta_nominal = h.x_delta_nominal
|
||||
@@ -72,7 +78,10 @@ class SrasScanFile:
|
||||
self.bytes_per_sample = h.bytes_per_sample
|
||||
self.n_channels = h.n_channels
|
||||
self.preambles_raw = sras.preambles_raw
|
||||
self.background_raw = sras.background
|
||||
self.legacy_layout = sras.is_legacy_layout
|
||||
# v6/v10 keep one background ahead of the data block; v7/v11 keep one
|
||||
# per angle inside it. Either way sras.backgrounds is per angle.
|
||||
self.shared_background = sras.backgrounds[0] if sras.is_legacy_layout else b""
|
||||
self.data_start_offset = sras.data_start_offset
|
||||
self.file_size = sras.file_size
|
||||
|
||||
@@ -82,12 +91,13 @@ class SrasScanFile:
|
||||
x_start=pa.x_start, x_delta=pa.x_delta,
|
||||
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
|
||||
y_positions=pa.y_positions, row_bytes=st.row_bytes,
|
||||
data_offset=st.data_offset,
|
||||
background=bg, data_offset=st.data_offset,
|
||||
n_rows_available=st.n_rows_available,
|
||||
data_size_available=st.n_rows_available * st.row_bytes,
|
||||
complete=st.complete,
|
||||
)
|
||||
for pa, st in zip(sras.per_angle, sras.angle_status(), strict=True)
|
||||
for pa, st, bg in zip(sras.per_angle, sras.angle_status(),
|
||||
sras.backgrounds, strict=True)
|
||||
]
|
||||
|
||||
def get(self, index: int) -> AngleEntry:
|
||||
@@ -137,10 +147,18 @@ def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||
dst.write(struct.pack(">H", len(praw)))
|
||||
dst.write(praw)
|
||||
|
||||
dst.write(struct.pack(">I", len(sf.background_raw)))
|
||||
dst.write(sf.background_raw)
|
||||
if sf.legacy_layout:
|
||||
dst.write(struct.pack(BG_LEN_FMT, len(sf.shared_background)))
|
||||
dst.write(sf.shared_background)
|
||||
|
||||
for e in selected:
|
||||
if not sf.legacy_layout:
|
||||
if not e.background:
|
||||
warnings.append(
|
||||
f"angle[{e.index}] ({e.angle_deg:.2f} deg): no background "
|
||||
"on disk — exported with an empty background block")
|
||||
dst.write(struct.pack(BG_LEN_FMT, len(e.background)))
|
||||
dst.write(e.background)
|
||||
src.seek(e.data_offset)
|
||||
remaining = e.data_size_available
|
||||
chunk_size = 1 << 20
|
||||
@@ -224,7 +242,7 @@ def parse_index_spec(spec: str, max_index: int) -> list:
|
||||
|
||||
def print_summary(sf: SrasScanFile, selected: set):
|
||||
print()
|
||||
kind = " SAW check" if sf.version == VERSION_SAW_CHECK else ""
|
||||
kind = " SAW check" if sf.is_saw_check else ""
|
||||
print(f"File: {sf.path} (v{sf.version}{kind}, {_human_size(sf.file_size)})")
|
||||
print(f"Nominal ROI: x_start={sf.x_start_nominal:.4f} x_delta={sf.x_delta_nominal:.4f} "
|
||||
f"y_start={sf.y_start_nominal:.4f} y_delta={sf.y_delta_nominal:.4f} mm "
|
||||
@@ -346,7 +364,7 @@ def interactive_loop(path: Path):
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Inspect, export, or delete per-angle sub-scans in a v6 .sras file.")
|
||||
description="Inspect, export, or delete per-angle sub-scans in a .sras file.")
|
||||
ap.add_argument("file", type=Path, help="path to a .sras file")
|
||||
ap.add_argument("--list", action="store_true", help="print the angle table and exit")
|
||||
ap.add_argument("--export", metavar="SPEC", help="angle index spec to export, e.g. '0,2,4-6' or 'all'")
|
||||
|
||||
Reference in New Issue
Block a user