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:
Thomas Ales
2026-09-04 12:44:25 -05:00
parent 83744c3337
commit aa06fa1460
21 changed files with 823 additions and 241 deletions
+12 -5
View File
@@ -6,7 +6,7 @@ without the other:
*Acquisition* — ``middle_row_plan`` reduces a full ScanPlan to a single row
per angle, the row-wise middle of the ROI. ScanEngine runs the result
exactly like any other scan and writes it as a v10 .sras file
exactly like any other scan and writes it as a v11 .sras file
(``sras_format.VERSION_SAW_CHECK``), so a check costs one row-time per angle
instead of the hours a full multi-angle scan takes.
@@ -55,7 +55,7 @@ def middle_row_plan(plan: ScanPlan) -> ScanPlan:
An even row count has no exact middle; the upper of the two central rows
is taken (``n_rows // 2``), which is also the row the viewer picks when it
reads the middle row out of a full v6 scan.
reads the middle row out of a full scan.
"""
if plan.n_angles == 0:
raise ScanGeometryError("Cannot build a SAW check from a plan with no angles")
@@ -181,18 +181,24 @@ class AlignmentSummary:
def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
background: np.ndarray | None = None,
subtract_background: bool = False,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None,
calib: ChannelCalibration | None = None,
on_progress=lambda done, total: None) -> list[AngleTrace]:
"""Peak SAW frequency along the middle row of every angle in ``sras``.
Works on a v10 check (one row per angle, so the middle row is the only
row) and on a full v6 scan alike — the same middle row the check would
Works on a v11 check (one row per angle, so the middle row is the only
row) and on a full scan alike — the same middle row the check would
have acquired is pulled out of the scan, which is what lets a finished
scan be re-examined with the check's own read-out.
``subtract_background`` takes each angle's own background out of its
frames (v6/v10 files have only the one, which every angle then shares).
Doing it per angle is the point of the per-angle capture: comparing
angles is exactly what this read-out is for, so they must not be
referenced against one background taken at whichever angle came first.
Angles with nothing on disk (an aborted file) are skipped rather than
reported as flat zero.
"""
@@ -210,6 +216,7 @@ def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
row = middle_row_index(st.n_rows_available)
view = sras.load_angle(st.index, n_rows=st.n_rows_available)[row:row + 1]
background = sras.background_array(st.index) if subtract_background else None
img = compute_rf_image(view, calib, freq_axis, dc_threshold_mv,
background=background,
gate_start_ns=gate_start_ns, gate_end_ns=gate_end_ns,
+71 -27
View File
@@ -18,7 +18,10 @@ from typing import Callable
from core import scope_burst, scope_sras
from core.rotation import RotationAxis
from core.scan_geometry import ScanPlan, validate_plan
from core.sras_format import SCAN_CHANNELS, VERSION, create_scan_file
from core.sras_format import (
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
write_background_block,
)
logger = logging.getLogger(__name__)
@@ -42,8 +45,15 @@ class ScanAborted(Exception):
@dataclass
class ResumeTarget:
"""One angle selected for (re)acquisition in an existing file."""
"""One angle selected for (re)acquisition in an existing file.
``bg_offset`` is where the angle's background block starts and
``data_offset`` where its rows do; the gap between them is the room the
file already has for a background, which a re-acquired angle must fill
exactly or every row behind it would shift.
"""
angle_idx: int
bg_offset: int
data_offset: int
n_rows: int
angle_deg: float
@@ -280,27 +290,14 @@ class ScanEngine:
if self._resume is None:
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
self._prompt(
"Background Capture",
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
"then click OK to capture the background waveform."
)
self._background = scope_sras.capture_background(
self._scope, should_abort=self._abort.is_set,
on_status=self._cb.on_status)
self._prompt(
"Begin Scanning",
"Background captured successfully.\n\n"
"Please ensure the Genesis laser is back ON,\n"
"then click OK to begin scanning."
)
else:
# Resuming: the file's existing background waveform and channel
# preambles are reused as-is (the format has no way to replace
# them without rewriting the whole file), so background capture is
# skipped. Sanity-check that this scope still produces the same
# record length the file was started with — a mismatch would
# silently corrupt the ragged per-row byte layout on append.
# Resuming: the file's channel preambles are reused as-is (the
# format has no way to replace them without rewriting the whole
# file). Each re-acquired angle still captures its own fresh
# background, which is rewritten in place over the old one.
# Sanity-check that this scope still produces the same record
# length the file was started with — a mismatch would silently
# corrupt the ragged per-row byte layout on append.
if samples_per_frame != self._resume.samples_per_frame:
raise RuntimeError(
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
@@ -314,8 +311,8 @@ class ScanEngine:
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
"Please re-home the GR axis to 0° before continuing — the scan "
"will rotate it directly from angle to angle before scanning resumes.\n\n"
"Please ensure the Genesis laser is ON,\n"
"then click OK to continue scanning."
"Each angle begins with its own background capture, so you will "
"be asked to switch the Genesis laser off and on again per angle."
)
scope_sras.configure_scan_trigger(self._scope)
@@ -341,7 +338,7 @@ class ScanEngine:
return open(self._resume.path, "r+b")
return create_scan_file(
self._out_path, self._plan, samples_per_frame,
scope_sras.SAMPLE_RATE_HZ, self._preambles, self._background,
scope_sras.SAMPLE_RATE_HZ, self._preambles,
version=self._file_version,
)
@@ -363,11 +360,12 @@ class ScanEngine:
continue # not selected for (re)acquisition
self._pause_point()
if targets_by_ai is not None:
target = None if targets_by_ai is None else targets_by_ai[ai]
if target is not None:
# Interior angles may already have valid data on either side,
# so seek to this angle's fixed offset rather than relying on
# the file's current position.
scan_file.seek(targets_by_ai[ai].data_offset)
scan_file.seek(target.bg_offset)
if self._rotator is not None and self._rotator.is_available:
delta = pa.angle_deg - self._rotator.current_deg
@@ -376,6 +374,9 @@ class ScanEngine:
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
self._write_angle_background(scan_file, ai, n_angles,
pa.angle_deg, target)
if self._burst_mode:
# Burst mode sizes the FastFrame count from the scope's whole
# capacity instead (see scope_burst.start_burst), so there is
@@ -391,6 +392,49 @@ class ScanEngine:
result.angles_acquired.append(ai)
def _write_angle_background(self, scan_file, ai: int, n_angles: int,
angle_deg: float, target: ResumeTarget | None):
"""Capture this angle's background and write it ahead of its rows.
The Genesis laser has to be off for the capture and back on for the
scan, so every angle costs two operator prompts and one averaged
record. That buys a background taken minutes from the data it will
be subtracted from, instead of one taken hours earlier at angle 1.
On resume the block is overwritten in place, so it has to be exactly
as long as the one already there — anything else would shift every
row behind it. Checked before the write, not after.
"""
scope = self._scope
scope_sras.configure_background_trigger(scope)
self._prompt(
f"Background Capture — Angle {ai + 1}/{n_angles}",
f"Angle {ai + 1} of {n_angles} ({angle_deg:.1f}°) starts with its "
"own background capture.\n\n"
"Please switch the Genesis laser OFF — leave the Helios laser ON —\n"
"then click OK to capture the background waveform."
)
background = scope_sras.capture_background(
scope, should_abort=self._abort.is_set, on_status=self._cb.on_status)
self._prompt(
f"Begin Angle {ai + 1}/{n_angles}",
"Background captured successfully.\n\n"
"Please switch the Genesis laser back ON,\n"
f"then click OK to scan angle {ai + 1} of {n_angles}."
)
scope_sras.configure_scan_trigger(scope)
if target is not None:
room = target.data_offset - target.bg_offset
if BG_LEN_SIZE + len(background) != room:
raise RuntimeError(
f"Angle {ai + 1}: the new background block is "
f"{BG_LEN_SIZE + len(background)} bytes but the file has room "
f"for {room} — writing it would shift every row behind it, "
"so the scan stops here."
)
write_background_block(scan_file, background)
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,
+12 -5
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from core.scan_engine import ResumeState, ResumeTarget
from core.sras_format import SrasFile
from core.sras_format import WRITABLE_VERSIONS, SrasFile
@dataclass
@@ -44,8 +44,9 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
final = set(selected)
targets = [
ResumeTarget(angle_idx=s.index, data_offset=s.data_offset,
n_rows=s.n_rows, angle_deg=s.angle_deg)
ResumeTarget(angle_idx=s.index, bg_offset=s.bg_offset,
data_offset=s.data_offset, n_rows=s.n_rows,
angle_deg=s.angle_deg)
for s in statuses if s.index in final
]
return ResumePlan(targets=targets,
@@ -55,9 +56,15 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
def is_compatible(sras: SrasFile, *, velocity: float, laser_freq: float,
sample_rate: float, n_channels: int) -> bool:
"""Whether appending to this file with the current settings is safe."""
"""Whether appending to this file with the current settings is safe.
A legacy v6/v10 file is not: it has one background for the whole scan,
and every angle this engine acquires writes a background block of its
own, which the older layout has no room for.
"""
h = sras.header
return (h.bytes_per_sample == 1
return (sras.version in WRITABLE_VERSIONS
and h.bytes_per_sample == 1
and h.n_channels == n_channels
and abs(h.velocity - velocity) <= 1e-3
and abs(h.laser_freq - laser_freq) <= 1e-3
+11 -4
View File
@@ -51,11 +51,13 @@ def configure_channels(scope, profiles=None) -> None:
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
def configure_acquisition(scope) -> int:
"""Program the edge trigger and timebase; returns samples per frame.
def configure_background_trigger(scope) -> None:
"""Program the edge trigger used for a background capture.
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays
off here so the background capture runs as a single record.
Edge trigger on the rising edge of CH2 (laser pulse), FastFrame off so
the capture runs as a single record. Every angle starts with a fresh
background, so the scan comes back here between angles from the
logic-AND trigger configure_scan_trigger leaves behind.
"""
scope.write("TRIGger:A:TYPe EDGE")
scope.set_trigger_source(2)
@@ -64,6 +66,11 @@ def configure_acquisition(scope) -> int:
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(False)
def configure_acquisition(scope) -> int:
"""Program the edge trigger and timebase; returns samples per frame."""
configure_background_trigger(scope)
# Pin the transfer format instead of inheriting front-panel state — the
# file header hardcodes bytes_per_sample=1, and a scope left on 2 bytes
# would corrupt every frame written.
+147 -43
View File
@@ -1,4 +1,5 @@
"""SRAS binary scan-file format (v6 and v10) — the single implementation.
"""SRAS binary scan-file format (v7/v11, reading v6/v10 too) — the single
implementation.
Full byte-level spec: scan_format.md. Summary:
@@ -9,20 +10,33 @@ Full byte-level spec: scan_format.md. Summary:
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
row tables (ragged) per angle: n_rows × >f (y positions)
preambles n_channels × (>H length + utf-8 WFMOutpre string)
background block >I length + raw int8 CH1 average
waveform data angle-major, row-minor, channel-inner:
for each angle, for each row, for each channel,
data block per angle: [background][rows]
background = >I length + raw int8 CH1 average
rows = for each row, for each channel,
n_frames × samples_per_frame × bytes_per_sample
Every angle carries its own background: the operator switches the Genesis
laser off before each angle and the engine averages a fresh CH1 record, so
the reference a reader subtracts was taken minutes — not hours — from the
data it is subtracted from. That is the whole difference between v7 and v6,
which held a single background for the entire file, ahead of the data block.
Incomplete files are valid: the data block is one contiguous append-only
stream, so the readable prefix defines a single frontier past which nothing
has been written yet (see ``SrasFile.angle_status``).
has been written yet (see ``SrasFile.angle_status``). Because the background
blocks are length-prefixed, the per-angle offsets come from a walk of that
stream at parse time rather than from arithmetic on the geometry table.
Version 10 is the SAW quality check (core.saw_check): byte layout identical
to v6, but every angle declares exactly one row — the row-wise middle of the
Version 11 is the SAW quality check (core.saw_check): byte layout identical
to v7, but every angle declares exactly one row — the row-wise middle of the
ROI. The version byte is the whole difference, and it exists so a reader can
tell a one-row-per-angle check from a full scan that was aborted after its
first row. ``create_scan_file`` enforces the one-row rule at write time.
v6 and v10 are the pre-per-angle-background versions of the same two files.
They are still read (every scan taken before this change is one); nothing
writes them any more, and a v6 file cannot be resumed into, since appending
v7 blocks to it would shift its data.
"""
from __future__ import annotations
@@ -37,14 +51,21 @@ import numpy as np
from core.scan_geometry import AngleGeometry, ScanPlan
MAGIC = b"SRAS"
VERSION = 6
VERSION = 7
# One row per angle, taken from the middle of the ROI — see core.saw_check.
VERSION_SAW_CHECK = 10
SUPPORTED_VERSIONS = (VERSION, VERSION_SAW_CHECK)
VERSION_SAW_CHECK = 11
# v6/v10: the same two files with one background for the whole scan, written
# ahead of the data block instead of once per angle. Read-only.
LEGACY_VERSIONS = (6, 10)
WRITABLE_VERSIONS = (VERSION, VERSION_SAW_CHECK)
SUPPORTED_VERSIONS = tuple(sorted(WRITABLE_VERSIONS + LEGACY_VERSIONS))
SAW_CHECK_VERSIONS = (10, VERSION_SAW_CHECK)
HDR_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
BG_LEN_FMT = ">I"
BG_LEN_SIZE = struct.calcsize(BG_LEN_FMT) # 4 bytes
# Oscilloscope channels recorded, in on-disk order.
SCAN_CHANNELS = [1, 3, 4]
@@ -78,7 +99,8 @@ class AngleStatus:
angle_deg: float
n_rows: int # declared
row_bytes: int
data_offset: int
bg_offset: int # start of this angle's background block
data_offset: int # start of its rows, i.e. just past that block
n_rows_available: int
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
@@ -86,33 +108,38 @@ class AngleStatus:
def complete(self) -> bool:
return self.status == STATUS_OK
@property
def bg_bytes(self) -> int:
"""Size of the background block ahead of the rows (0 on v6/v10)."""
return self.data_offset - self.bg_offset
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
sample_rate: float, preambles: list[str],
background_waveform: bytes,
version: int = VERSION) -> BinaryIO:
"""Create a new .sras file and write the header + tables.
``version`` selects which kind of file this is — VERSION for a full scan,
VERSION_SAW_CHECK for a middle-row quality check. The layout is the same
either way; the one-row-per-angle rule that gives v10 its meaning is
checked here, since nothing downstream can recover from a v10 file that
either way; the one-row-per-angle rule that gives v11 its meaning is
checked here, since nothing downstream can recover from a v11 file that
breaks it.
Returns an open binary file positioned at the start of the data block;
the caller appends waveform rows and must close it (try/finally).
Returns an open binary file positioned at the start of the data block.
The caller writes each angle as ``write_background_block()`` followed by
that angle's rows, and must close the file (try/finally).
"""
if version not in SUPPORTED_VERSIONS:
if version not in WRITABLE_VERSIONS:
raise ValueError(
f"Cannot write SRAS format version {version} "
f"(supported: {', '.join(str(v) for v in SUPPORTED_VERSIONS)})"
f"(writable: {', '.join(str(v) for v in WRITABLE_VERSIONS)})"
)
if version == VERSION_SAW_CHECK:
bad = [f"{pa.angle_deg:.1f}° has {pa.n_rows}"
for pa in plan.per_angle if pa.n_rows != 1]
if bad:
raise ValueError(
"A v10 SAW-check file holds exactly one row per angle, but "
"A v11 SAW-check file holds exactly one row per angle, but "
+ ", ".join(bad) + " — build the plan with "
"core.saw_check.middle_row_plan()."
)
@@ -140,19 +167,33 @@ def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
enc = p.encode("utf-8")
f.write(struct.pack(">H", len(enc)))
f.write(enc)
f.write(struct.pack(">I", len(background_waveform)))
f.write(background_waveform)
return f
def write_background_block(f: BinaryIO, waveform: bytes) -> int:
"""Write one angle's background block; returns the bytes written.
Every angle's rows are preceded by one of these, so a reader walking the
data block knows where that angle's frames start.
"""
f.write(struct.pack(BG_LEN_FMT, len(waveform)))
f.write(waveform)
return BG_LEN_SIZE + len(waveform)
@dataclass
class SrasFile:
"""Parsed .sras file (v6 or v10): header, tables, and lazy (memmap) access.
"""Parsed .sras file (v7/v11, or legacy v6/v10): header, tables, and lazy
(memmap) access.
Parsing reads only the header/tables — never the waveform block — so
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
read-only numpy views backed by a shared mmap; no data is copied until
the caller computes on it.
Parsing reads only the header/tables and the per-angle background blocks
— never the waveform block — so opening a multi-GB file is cheap.
``load_angle``/``load_row`` return read-only numpy views backed by a
shared mmap; no data is copied until the caller computes on it.
``backgrounds[i]`` is angle *i*'s own background (v7/v11) or the file's
single background repeated for every angle (v6/v10), so a reader never
has to branch on the version to subtract the right one.
"""
path: Path
version: int = field(init=False)
@@ -160,7 +201,7 @@ class SrasFile:
per_angle: list[AngleGeometry] = field(init=False)
preambles: list[str] = field(init=False)
preambles_raw: list[bytes] = field(init=False)
background: bytes = field(init=False)
backgrounds: list[bytes] = field(init=False)
data_start_offset: int = field(init=False)
file_size: int = field(init=False)
@@ -215,15 +256,65 @@ class SrasFile:
self.preambles_raw.append(f.read(plen))
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
(n_bg,) = struct.unpack(">I", f.read(4))
self.background = f.read(n_bg)
shared_bg = None
if self.is_legacy_layout:
# v6/v10: one background for the whole file, ahead of the data.
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
shared_bg = f.read(n_bg)
self.data_start_offset = f.tell()
self._walk_data_block(f, shared_bg)
def _walk_data_block(self, f, shared_bg: bytes | None):
"""Locate every angle's background block and the rows behind it.
v7 interleaves a length-prefixed background ahead of each angle's
rows, so the offsets are no longer pure arithmetic over the geometry
table — the walk reads each prefix as it goes. Past the frontier of
a partial file there is nothing to read, so the remaining offsets are
predicted from the block a writer would have produced (a full record),
which is exactly where a resumed scan writes.
"""
expected_bg = BG_LEN_SIZE + self.header.samples_per_frame
self.backgrounds, self._bg_present = [], []
self._bg_offsets, self._data_offsets = [], []
cursor = self.data_start_offset
for ai, pa in enumerate(self.per_angle):
if shared_bg is not None:
bg, bg_bytes, present = shared_bg, 0, True
else:
bg, bg_bytes, present = self._read_background(f, cursor, expected_bg)
self.backgrounds.append(bg)
self._bg_present.append(present)
self._bg_offsets.append(cursor)
cursor += bg_bytes
self._data_offsets.append(cursor)
cursor += self.row_bytes(ai) * pa.n_rows
def _read_background(self, f, offset: int, expected_bytes: int):
"""One angle's background block as (waveform, block_bytes, present).
A block that runs past the end of the file was never written: the
walk keeps going with the size a writer would have used, and the
angle is reported MISSING.
"""
if offset + BG_LEN_SIZE > self.file_size:
return b"", expected_bytes, False
f.seek(offset)
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
if offset + BG_LEN_SIZE + n_bg > self.file_size:
return b"", expected_bytes, False
return f.read(n_bg), BG_LEN_SIZE + n_bg, True
@property
def is_saw_check(self) -> bool:
"""True for a v10 middle-row SAW quality check rather than a scan."""
return self.version == VERSION_SAW_CHECK
"""True for a middle-row SAW quality check rather than a scan."""
return self.version in SAW_CHECK_VERSIONS
@property
def is_legacy_layout(self) -> bool:
"""True for v6/v10: one background for the file, not one per angle."""
return self.version in LEGACY_VERSIONS
# ── Frontier / truncation analysis ───────────────────────────────────────
@@ -237,40 +328,48 @@ class SrasFile:
Because the data is one contiguous append-only stream, once an angle
is found short every later angle is necessarily absent too — there is
a single frontier past which nothing has been written yet.
a single frontier past which nothing has been written yet. An angle
whose background block never made it to disk is short by definition,
even though no row of it was due yet.
"""
statuses = []
cursor = self.data_start_offset
frontier_seen = False
for ai, pa in enumerate(self.per_angle):
row_bytes = self.row_bytes(ai)
data_offset = cursor
if frontier_seen:
data_offset = self._data_offsets[ai]
if frontier_seen or not self._bg_present[ai]:
n_rows_available = 0
status = STATUS_MISSING
frontier_seen = True
else:
declared_bytes = row_bytes * pa.n_rows
if row_bytes > 0 and cursor + declared_bytes <= self.file_size:
if row_bytes > 0 and data_offset + declared_bytes <= self.file_size:
n_rows_available = pa.n_rows
status = STATUS_OK
cursor += declared_bytes
else:
remaining = max(0, self.file_size - cursor)
remaining = max(0, self.file_size - data_offset)
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
frontier_seen = True
statuses.append(AngleStatus(
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
row_bytes=row_bytes, data_offset=data_offset,
row_bytes=row_bytes, bg_offset=self._bg_offsets[ai],
data_offset=data_offset,
n_rows_available=n_rows_available, status=status,
))
return statuses
def angle_data_offset(self, angle_idx: int) -> int:
offset = self.data_start_offset
for ai in range(angle_idx):
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
return offset
"""Where angle ``angle_idx``'s rows start (past its background)."""
return self._data_offsets[angle_idx]
def angle_block_offset(self, angle_idx: int) -> int:
"""Where angle ``angle_idx``'s block starts, background included.
Equal to ``angle_data_offset`` on v6/v10, which have no per-angle
background block.
"""
return self._bg_offsets[angle_idx]
# ── Lazy data access ─────────────────────────────────────────────────────
@@ -286,6 +385,11 @@ class SrasFile:
def _dtype(self) -> np.dtype:
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
def background_array(self, angle_idx: int) -> np.ndarray | None:
"""One angle's background as float32 ADC counts, or None if absent."""
bg = np.frombuffer(self.backgrounds[angle_idx], dtype=self._dtype())
return bg.astype(np.float32) if bg.size else None
def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
"""Read-only view of one angle's data block, shape
(n_rows, n_channels, n_frames, samples_per_frame).