Files
scanengine-3/sras_scan_manager.py
Thomas Ales aa06fa1460 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>
2026-09-04 12:44:25 -05:00

434 lines
17 KiB
Python
Executable File

#!/opt/srasenv/bin/python3
"""
SRAS Scan Manager
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), 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 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
import struct
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core.sras_format import (
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, SrasFile,
)
@dataclass
class AngleEntry:
index: int
angle_deg: float
x_start: float
x_delta: float
n_frames: int
n_rows_declared: int
y_positions: list # declared length; may exceed what's actually on disk
row_bytes: int
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
@property
def data_size_declared(self) -> int:
return self.row_bytes * self.n_rows_declared
class SrasScanFile:
"""Parsed view of a .sras file's header/tables plus per-angle data offsets."""
def __init__(self, path: Path):
self.path = Path(path)
self._parse()
def _parse(self):
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
self.y_delta_nominal = h.y_delta_nominal
self.row_spacing_mm = h.row_spacing
self.velocity_mm_s = h.velocity
self.laser_freq_hz = h.laser_freq
self.samples_per_frame = h.samples_per_frame
self.sample_rate_hz = h.sample_rate
self.bytes_per_sample = h.bytes_per_sample
self.n_channels = h.n_channels
self.preambles_raw = sras.preambles_raw
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
self.angles = [
AngleEntry(
index=st.index, angle_deg=st.angle_deg,
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,
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, bg in zip(sras.per_angle, sras.angle_status(),
sras.backgrounds, strict=True)
]
def get(self, index: int) -> AngleEntry:
return self.angles[index]
# ---------------------------------------------------------------------------
# Export / delete
# ---------------------------------------------------------------------------
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
"""Write a new .sras file containing only the given angle indices
(in the given order). Returns a list of warning strings (e.g. for
angles that were truncated on disk and thus exported with fewer rows
than declared).
"""
warnings = []
selected = [sf.get(i) for i in indices]
header = struct.pack(
HDR_FMT, MAGIC, sf.version, len(selected),
sf.x_start_nominal, sf.y_start_nominal,
sf.x_delta_nominal, sf.y_delta_nominal,
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,
sf.samples_per_frame, sf.sample_rate_hz,
sf.bytes_per_sample, sf.n_channels,
)
with open(sf.path, "rb") as src, open(dst_path, "wb") as dst:
dst.write(header)
dst.write(struct.pack(f">{len(selected)}f", *[e.angle_deg for e in selected]))
for e in selected:
if e.n_rows_available != e.n_rows_declared:
warnings.append(
f"angle[{e.index}] ({e.angle_deg:.2f} deg): declared "
f"{e.n_rows_declared} rows but only {e.n_rows_available} "
f"present on disk — exporting truncated")
dst.write(struct.pack(GEOM_FMT, e.x_start, e.x_delta, e.n_frames,
e.n_rows_available))
for e in selected:
ys = e.y_positions[:e.n_rows_available]
dst.write(struct.pack(f">{len(ys)}f", *ys))
for praw in sf.preambles_raw:
dst.write(struct.pack(">H", len(praw)))
dst.write(praw)
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
while remaining > 0:
chunk = src.read(min(chunk_size, remaining))
if not chunk:
break
dst.write(chunk)
remaining -= len(chunk)
return warnings
def export_angles(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
if not indices:
raise ValueError("no angles selected to export")
return _write_subset(sf, indices, dst_path)
def delete_angles(sf: SrasScanFile, indices_to_delete: list, backup: bool = True) -> Path | None:
"""Rewrite sf.path in place, keeping every angle NOT in indices_to_delete.
Returns the backup file path if one was made, else None.
"""
keep = [e.index for e in sf.angles if e.index not in set(indices_to_delete)]
if not keep:
raise ValueError("refusing to delete every angle — a .sras file needs at least one")
tmp_path = sf.path.with_suffix(sf.path.suffix + ".tmp")
_write_subset(sf, keep, tmp_path)
backup_path = None
if backup:
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
backup_path = sf.path.with_name(f"{sf.path.stem}.bak-{stamp}{sf.path.suffix}")
sf.path.rename(backup_path)
tmp_path.replace(sf.path)
return backup_path
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def _human_size(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} PB"
def parse_index_spec(spec: str, max_index: int) -> list:
"""Parse '0,2,4-6' or 'all' into a sorted list of unique in-range indices."""
spec = spec.strip().lower()
if spec in ("all", "*"):
return list(range(max_index + 1))
if not spec:
return []
out = set()
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
lo, hi = part.split("-", 1)
lo, hi = int(lo), int(hi)
if lo > hi:
lo, hi = hi, lo
for i in range(lo, hi + 1):
out.add(i)
else:
out.add(int(part))
bad = [i for i in out if i < 0 or i > max_index]
if bad:
raise ValueError(f"index out of range (0-{max_index}): {sorted(bad)}")
return sorted(out)
def print_summary(sf: SrasScanFile, selected: set):
print()
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 "
f"row_spacing={sf.row_spacing_mm:.4f} mm")
print(f"Velocity={sf.velocity_mm_s:.2f} mm/s Laser={sf.laser_freq_hz:.1f} Hz "
f"Samples/frame={sf.samples_per_frame} Sample rate={sf.sample_rate_hz:.3e} Hz "
f"Channels={sf.n_channels} Bytes/sample={sf.bytes_per_sample}")
print()
hdr = f"{'':>2} {'#':>3} {'Angle(deg)':>10} {'Rows':>7} {'Frames/row':>10} {'x_start':>9} {'x_delta':>9} {'Data size':>11} Status"
print(hdr)
print("-" * len(hdr))
for e in sf.angles:
mark = "*" if e.index in selected else " "
if e.complete:
status = "OK"
elif e.n_rows_available == 0:
status = "MISSING (no data on disk)"
else:
status = f"TRUNCATED ({e.n_rows_available}/{e.n_rows_declared} rows on disk)"
print(f"{mark:>2} {e.index:>3} {e.angle_deg:>10.2f} {e.n_rows_declared:>7} "
f"{e.n_frames:>10} {e.x_start:>9.3f} {e.x_delta:>9.3f} "
f"{_human_size(e.data_size_available):>11} {status}")
print()
# ---------------------------------------------------------------------------
# Interactive TUI
# ---------------------------------------------------------------------------
def interactive_loop(path: Path):
sf = SrasScanFile(path)
selected: set = set()
help_text = (
" [l] list show the angle table again\n"
" [s] select <spec> set selection, e.g. '0,2,4-6' or 'all' or 'none'\n"
" [e] export <path> write selected angles to a new .sras file\n"
" [d] delete remove selected angles from this file in place\n"
" [r] reload re-read the file from disk (after external changes)\n"
" [h] help show this help\n"
" [q] quit"
)
print_summary(sf, selected)
print(help_text)
while True:
try:
cmd_line = input("\nsras> ").strip()
except EOFError:
print()
break
if not cmd_line:
continue
parts = cmd_line.split(None, 1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
try:
if cmd in ("q", "quit", "exit"):
break
elif cmd in ("h", "help", "?"):
print(help_text)
elif cmd in ("l", "list"):
print_summary(sf, selected)
elif cmd in ("s", "select"):
if not arg:
arg = input("Angles to select (e.g. 0,2,4-6 / all / none): ").strip()
if arg.lower() == "none":
selected = set()
else:
selected = set(parse_index_spec(arg, len(sf.angles) - 1))
print(f"Selected {len(selected)} angle(s): {sorted(selected)}")
elif cmd in ("e", "export"):
if not selected:
print("Nothing selected — use 's' first.")
continue
dst = arg or input("Output path: ").strip()
if not dst:
print("Export cancelled — no path given.")
continue
dst_path = Path(dst)
if dst_path.exists():
ans = input(f"{dst_path} exists — overwrite? [y/N] ").strip().lower()
if ans != "y":
print("Export cancelled.")
continue
warnings = export_angles(sf, sorted(selected), dst_path)
print(f"Exported {len(selected)} angle(s) -> {dst_path}")
for w in warnings:
print(f" warning: {w}")
elif cmd in ("d", "delete"):
if not selected:
print("Nothing selected — use 's' first.")
continue
print(f"About to delete {len(selected)} angle(s) from {sf.path}: {sorted(selected)}")
ans = input("Type 'yes' to confirm (a timestamped .bak copy will be kept): ").strip()
if ans != "yes":
print("Delete cancelled.")
continue
backup_path = delete_angles(sf, sorted(selected), backup=True)
print(f"Deleted. Backup saved to {backup_path}")
sf = SrasScanFile(sf.path)
selected = set()
print_summary(sf, selected)
elif cmd in ("r", "reload"):
sf = SrasScanFile(sf.path)
selected = set()
print_summary(sf, selected)
else:
print(f"Unknown command: {cmd!r} (type 'h' for help)")
except Exception as exc:
print(f"Error: {exc}")
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
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'")
ap.add_argument("--output", metavar="PATH", type=Path, help="destination path for --export")
ap.add_argument("--delete", metavar="SPEC", help="angle index spec to delete in place, e.g. '1,3'")
ap.add_argument("--no-backup", action="store_true", help="skip the .bak copy when using --delete")
ap.add_argument("--yes", action="store_true", help="don't prompt for confirmation on --delete")
args = ap.parse_args()
if not args.file.exists():
print(f"error: {args.file} does not exist", file=sys.stderr)
sys.exit(1)
try:
sf = SrasScanFile(args.file)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
non_interactive = args.list or args.export or args.delete
if args.list:
print_summary(sf, set())
try:
if args.export:
indices = parse_index_spec(args.export, len(sf.angles) - 1)
if not args.output:
print("error: --export requires --output", file=sys.stderr)
sys.exit(1)
if args.output.exists() and not args.yes:
ans = input(f"{args.output} exists — overwrite? [y/N] ").strip().lower()
if ans != "y":
print("Export cancelled.")
sys.exit(1)
warnings = export_angles(sf, indices, args.output)
print(f"Exported {len(indices)} angle(s) -> {args.output}")
for w in warnings:
print(f" warning: {w}")
if args.delete:
indices = parse_index_spec(args.delete, len(sf.angles) - 1)
if not indices:
print("error: --delete requires a non-empty angle spec", file=sys.stderr)
sys.exit(1)
if not args.yes:
print(f"About to delete {len(indices)} angle(s) from {sf.path}: {indices}")
ans = input("Type 'yes' to confirm: ").strip()
if ans != "yes":
print("Delete cancelled.")
sys.exit(1)
backup_path = delete_angles(sf, indices, backup=not args.no_backup)
if backup_path:
print(f"Deleted. Backup saved to {backup_path}")
else:
print("Deleted (no backup kept).")
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(1)
if not non_interactive:
interactive_loop(args.file)
if __name__ == "__main__":
main()