Merge remote-tracking branch 'origin/main' into main

# Conflicts:
#	sras_compute.py
#	sras_viewer.py
#	sras_workers.py
#	tools/make_test_sras.py
#	tools/test_refactor.py
This commit is contained in:
Thomas Ales [M S E]
2026-08-10 18:58:21 -05:00
34 changed files with 10768 additions and 5738 deletions
+221 -18
View File
@@ -60,7 +60,15 @@ PREC_FLAG_BG_SUB = 0x01
CACH_MAGIC = b"CACH"
CACH_HDR_FMT = ">4sBB" # magic, cach_version, block_flags
CACH_HDR_SIZE = struct.calcsize(CACH_HDR_FMT)
CACH_VERSION = 1
CACH_VERSION = 3 # written on every fresh write
CACH_VERSIONS_READABLE = (1, 2, 3) # accepted on read — see
# _read_sfft_block. Each bump only
# appended a field, and every older
# tail has a well-defined reading:
# v1 predates row-averaged FFT
# caching (row_avg_n=0) and v1/v2
# predate padded caching, so both
# are natural-resolution (pad 1).
CACH_FLAG_DC = 0x01
CACH_FLAG_FFT = 0x02
@@ -69,9 +77,19 @@ SDCB_HDR_FMT = ">4sBH" # magic, reserved, n_stored
SDCB_HDR_SIZE = struct.calcsize(SDCB_HDR_FMT)
SFFT_MAGIC = b"SFFT"
SFFT_HDR_FMT = ">4sBH" # magic, flags, n_stored
SFFT_HDR_FMT_V1 = ">4sBH" # magic, flags, n_stored (cach_version 1)
SFFT_HDR_FMT_V2 = ">4sBHB" # + row_avg_n (cach_version 2)
SFFT_HDR_FMT = ">4sBHBH" # + pad_factor (cach_version 3)
SFFT_HDR_SIZE_V1 = struct.calcsize(SFFT_HDR_FMT_V1)
SFFT_HDR_SIZE_V2 = struct.calcsize(SFFT_HDR_FMT_V2)
SFFT_HDR_SIZE = struct.calcsize(SFFT_HDR_FMT)
MAX_PAD_FACTOR = 0xFFFF # the H field above
SFFT_FLAG_BG_SUB = 0x01
SFFT_FLAG_ROW_AVG = 0x02 # peak_freq_mhz came from same-row,
# distance-weighted averaged CH1
# waveforms, not raw per-pixel ones;
# row_avg_n is the neighbor half-width
# (pixels) used. Bits 2-7 reserved.
# Fixed channel indices into the .sras data array (CH1=RF, CH3/CH4=Bias DC)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
@@ -169,7 +187,10 @@ class SrasFile:
``precomputed_dc3_mv`` / ``precomputed_dc4_mv`` / ``precomputed_freq_mhz``,
always as ragged per-angle lists (``list[np.ndarray | None]``, one entry
per angle, ``None`` where that angle was never stored) regardless of
source version.
source version. The scalars ``precomputed_bg_sub`` /
``precomputed_row_avg_n`` / ``precomputed_pad_factor`` record the
settings the stored FFT images were computed under, so a reader can tell
whether they answer the question it is actually asking.
"""
def __init__(self, path: str):
@@ -226,6 +247,54 @@ class SrasFile:
self.precomputed_dc4_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_dc3_mv: list[np.ndarray | None] = [None] * n_angles
self.precomputed_bg_sub: bool = False
self.precomputed_row_avg_n: int = 0
# Zero-padding factor the stored peak_freq_mhz images were resolved
# at: 1 = natural resolution (n_fft == samples_per_frame). A padded
# FFT resolves peaks a padded view would, and only such a view can
# be served from it — see sras_compute.cached_rf_image.
self.precomputed_pad_factor: int = 1
def encoded_preambles(self) -> bytes:
"""This file's Preamble Blocks section, as bytes a writer can emit.
v6/v7 files kept the on-disk span verbatim, which is both cheaper and
lossless; legacy files did not keep it, and a v2 file has no preambles
at all, so those are re-encoded from the parsed strings (empty ones for
v2). Empty is not a silent downgrade: _parse_preamble("") returns {} and
_set_calibration falls back to the hardcoded scope constants, which is
exactly the calibration a v2 file already gets, so mV values round-trip
unchanged.
Lives here rather than at each writer so the version fan-out sits next
to the parser that creates it, and no writer has to probe the object
to find out which shape it got.
"""
raw = getattr(self, "preambles_raw", None)
if raw is not None:
return raw
out = bytearray()
for s in self.preambles or [""] * self.n_channels:
encoded = s.encode("utf-8")
out += struct.pack(">H", len(encoded)) + encoded
return bytes(out)
def encoded_background(self) -> bytes:
"""This file's Background Block, as bytes a writer can emit.
When there is none (v2/v3), this is samples_per_frame zeros rather than
a zero-length block. Every consumer guards on `background is not None`
and then subtracts it from a (spf,)-shaped row, so a length-0 array
would broadcast-fail at the first background-subtracted FFT; zeros make
the subtraction a correct no-op instead.
"""
raw = getattr(self, "background_raw", None)
if raw is not None:
return raw
if self.background is None:
samples = np.zeros(self.samples_per_frame, dtype=np.int8)
else:
samples = np.rint(self.background).astype(np.int8)
return struct.pack(">I", samples.size) + samples.tobytes()
def cached_dc_mv(self, angle_idx: int, ch_idx: int) -> np.ndarray | None:
"""A stored DC image (already in mV) for (angle, channel), or None."""
@@ -257,6 +326,15 @@ class SrasFile:
self.scan_aborted = False
self.n_angles_declared = n_angles
# Pre-v6 files carry no nominal ROI. Defined as None rather than
# left absent so the object's shape does not depend on its version
# and writers can ask instead of probing with hasattr.
self.x_start_nominal_mm = None
self.y_start_nominal_mm = None
self.x_delta_nominal_mm = None
self.y_delta_nominal_mm = None
self.row_spacing_mm = None
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
y_pos = np.frombuffer(f.read(n_rows * 4), dtype=">f4").astype(np.float32)
@@ -375,21 +453,34 @@ class SrasFile:
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4").astype(np.float32)
x_start = np.empty(n_angles, dtype=np.float64)
x_delta = 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 = _read_struct(f, GEO_FMT_V6)
x_start[a], n_frames[a], n_rows[a] = xs, nf, nr
x_start[a], x_delta[a], n_frames[a], n_rows[a] = xs, xd, nf, nr
y_pos_per_angle = [
np.frombuffer(f.read(int(n_rows[a]) * 4), dtype=">f4").astype(np.float32)
for a in range(n_angles)
]
# Verbatim on-disk spans of the preamble and background sections,
# kept so file-rewriting tools (sras_edit_scans) can carry them
# over byte-for-byte without re-parsing.
span_start = f.tell()
self._set_calibration(_read_preambles(f, n_ch), n_ch)
self.background = _read_background(f)
span_end = f.tell()
f.seek(span_start)
self.preambles_raw = f.read(span_end - span_start)
data_offset = f.tell()
span_start = span_end
self.background = _read_background(f)
span_end = f.tell()
f.seek(span_start)
self.background_raw = f.read(span_end - span_start)
data_offset = span_end
self._data_offset = data_offset
@@ -428,6 +519,7 @@ class SrasFile:
self.scan_aborted = n_complete < n_angles_declared
self.angles_deg = angles[:n_complete]
self.x_start_mm = x_start[:n_complete]
self.x_delta_mm_per_angle = x_delta[: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]
@@ -436,6 +528,37 @@ class SrasFile:
if self.version == 7 and offset < file_size:
self._parse_cach_section(offset)
# ------------------------------------------------------------------
# Byte-layout accessors (public: used by file-rewriting tools)
# ------------------------------------------------------------------
@property
def data_offset(self) -> int:
"""File offset where the waveform data begins (headers end)."""
return self._data_offset
@property
def y_pos_per_angle(self) -> list[np.ndarray]:
"""Per-angle Y row positions (mm). The list and its arrays are the
live parsed state — tools that reproject may replace entries."""
return self._y_pos_per_angle
@y_pos_per_angle.setter
def y_pos_per_angle(self, value: list[np.ndarray]):
self._y_pos_per_angle = value
def iter_angle_blocks(self):
"""Yields (angle_idx, byte_offset, byte_count) for each complete
angle's waveform block. Works for every version: legacy files have
uniform per-angle geometry, so the same walk applies."""
offset = self._data_offset
for a in range(self.n_angles):
nbytes = (int(self.n_rows[a]) * self.n_channels
* int(self.n_frames[a]) * self.samples_per_frame
* self.bytes_per_sample)
yield a, offset, nbytes
offset += nbytes
# ------------------------------------------------------------------
# v7 cache tail (CACH section: precomputed DC / FFT images)
# ------------------------------------------------------------------
@@ -445,12 +568,10 @@ class SrasFile:
start), derived purely from the header + Per-Angle Geometry Table —
independent of whether a cache tail is actually present. Used by
both the parser and the in-place writer."""
waveform_bytes = sum(
int(self.n_rows[a]) * self.n_channels * int(self.n_frames[a])
* self.samples_per_frame * self.bytes_per_sample
for a in range(self.n_angles)
)
return self._data_offset + int(waveform_bytes)
end = self._data_offset
for _, offset, nbytes in self.iter_angle_blocks():
end = offset + nbytes
return end
def _read_cache_block(self, f, hdr_fmt: str, magic: bytes,
stores: list[list]) -> int | None:
@@ -474,6 +595,40 @@ class SrasFile:
store[angle_idx] = _read_f32_image(f, shape)
return flags
def _read_sfft_block(self, f, cach_version: int) -> tuple[int, int, int] | None:
"""Read the SFFT block header — its layout depends on cach_version,
since each bump appended a trailing field (v2 row_avg_n, v3
pad_factor) — then n_stored per-angle peak_freq_mhz entries
(unchanged across versions).
Returns (flags, row_avg_n, pad_factor), or None if the block is
malformed. The absent fields of an older tail take the value that
describes what such a tail can only have been: row_avg_n=0 for v1,
which predates row-averaged FFT caching, and pad_factor=1 for v1/v2,
which predate padded caching and so are natural-resolution.
"""
hdr_fmt = {1: SFFT_HDR_FMT_V1, 2: SFFT_HDR_FMT_V2}.get(
cach_version, SFFT_HDR_FMT)
raw = f.read(struct.calcsize(hdr_fmt))
if len(raw) < struct.calcsize(hdr_fmt):
return None
row_avg_n, pad_factor = 0, 1
if cach_version == 1:
magic, flags, n_stored = struct.unpack(hdr_fmt, raw)
elif cach_version == 2:
magic, flags, n_stored, row_avg_n = struct.unpack(hdr_fmt, raw)
else:
magic, flags, n_stored, row_avg_n, pad_factor = struct.unpack(hdr_fmt, raw)
if magic != SFFT_MAGIC:
return None
for _ in range(n_stored):
(angle_idx,) = _read_struct(f, ">H")
if angle_idx >= self.n_angles:
break
self.precomputed_freq_mhz[angle_idx] = _read_f32_image(
f, self.image_shape(angle_idx))
return flags, row_avg_n, pad_factor
def _parse_cach_section(self, offset: int):
"""Parse the v7 CACH tail that holds precomputed DC/FFT images."""
with open(self.path, "rb") as f:
@@ -482,7 +637,7 @@ class SrasFile:
if len(header_raw) < CACH_HDR_SIZE:
return
magic, cach_version, block_flags = struct.unpack(CACH_HDR_FMT, header_raw)
if magic != CACH_MAGIC or cach_version != CACH_VERSION:
if magic != CACH_MAGIC or cach_version not in CACH_VERSIONS_READABLE:
return
if block_flags & CACH_FLAG_DC:
@@ -492,17 +647,21 @@ class SrasFile:
return
if block_flags & CACH_FLAG_FFT:
flags = self._read_cache_block(
f, SFFT_HDR_FMT, SFFT_MAGIC, [self.precomputed_freq_mhz])
if flags is None:
result = self._read_sfft_block(f, cach_version)
if result is None:
return
flags, row_avg_n, pad_factor = result
self.precomputed_bg_sub = bool(flags & SFFT_FLAG_BG_SUB)
self.precomputed_row_avg_n = row_avg_n if (flags & SFFT_FLAG_ROW_AVG) else 0
self.precomputed_pad_factor = max(1, pad_factor)
def write_v7_cache(self, *,
new_dc3_mv: list[np.ndarray | None] | None = None,
new_dc4_mv: list[np.ndarray | None] | None = None,
new_freq_mhz: list[np.ndarray | None] | None = None,
new_bg_sub: bool | None = None):
new_bg_sub: bool | None = None,
new_row_avg_n: int | None = None,
new_pad_factor: int | None = None):
"""Store computed DC and/or FFT images into this file's CACH tail,
in place, converting a v6 source to v7 (or updating an existing v7
file). Only the block(s) passed in are recomputed; whichever block
@@ -510,6 +669,18 @@ class SrasFile:
``SrasFile`` already has in memory (from parsing, or a prior write
in this same session) — its bytes are never re-read from disk.
*new_row_avg_n* is the same-row neighbor half-width (pixels) the
passed *new_freq_mhz* was averaged over before its FFT, 0 for a raw
(unaveraged) compute — carried forward like *new_bg_sub* when None.
It describes the whole stored FFT block, not per-angle, mirroring
how bg-sub has never been tracked per-angle either.
*new_pad_factor* is the zero-padding factor the passed *new_freq_mhz*
was resolved at (1 = natural resolution), carried forward the same
way. Like row_avg_n it is provenance, not a hint: a view at a
different pad resolves different peaks, so recording it is what lets
a reader refuse the cache instead of showing the wrong numbers.
The waveform data itself is never touched: the cache tail always
starts at ``_cache_tail_offset()``, a fixed offset derived from the
header and geometry table alone.
@@ -522,6 +693,15 @@ class SrasFile:
final_dc4 = new_dc4_mv if new_dc4_mv is not None else self.precomputed_dc4_mv
final_freq = new_freq_mhz if new_freq_mhz is not None else self.precomputed_freq_mhz
final_bg_sub = new_bg_sub if new_bg_sub is not None else self.precomputed_bg_sub
final_row_avg_n = (new_row_avg_n if new_row_avg_n is not None
else self.precomputed_row_avg_n)
final_pad_factor = (new_pad_factor if new_pad_factor is not None
else self.precomputed_pad_factor)
if not (0 <= final_row_avg_n <= 255):
raise ValueError(f"row_avg_n must fit in a byte (0-255), got {final_row_avg_n}")
if not (1 <= final_pad_factor <= MAX_PAD_FACTOR):
raise ValueError(
f"pad_factor must be 1-{MAX_PAD_FACTOR}, got {final_pad_factor}")
# dc3/dc4 are always populated together by every current caller, but
# guard the per-angle pairing explicitly rather than assume it: an
@@ -547,7 +727,10 @@ class SrasFile:
if fft_entries:
fft_flags = SFFT_FLAG_BG_SUB if final_bg_sub else 0
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags, len(fft_entries))
fft_flags |= SFFT_FLAG_ROW_AVG if final_row_avg_n else 0
payload += struct.pack(SFFT_HDR_FMT, SFFT_MAGIC, fft_flags,
len(fft_entries), final_row_avg_n,
final_pad_factor)
for a in fft_entries:
payload += struct.pack(">H", a)
payload += final_freq[a].astype(">f4").tobytes()
@@ -585,6 +768,8 @@ class SrasFile:
self.precomputed_dc4_mv = final_dc4
self.precomputed_freq_mhz = final_freq
self.precomputed_bg_sub = final_bg_sub
self.precomputed_row_avg_n = final_row_avg_n
self.precomputed_pad_factor = final_pad_factor
# ------------------------------------------------------------------
# Axes helpers
@@ -601,6 +786,24 @@ class SrasFile:
def y_positions_mm(self, angle_idx: int) -> np.ndarray:
return self._y_pos_per_angle[angle_idx]
def angles_share_raw_grid(self) -> bool:
"""True iff every angle's raw (x, y) pixel grid is literally the same
array as angle 0's -- the case for a .sras file the viewer's own
Alignment Wizard exported (see scan_format.md, "Files written by the
viewer's Alignment Wizard"): the writer packs one Per-Angle Geometry
record and one Row Table span and repeats those same bytes for every
angle, so re-parsed arrays are bit-identical copies rather than
independently re-derived numbers -- a bare np.array_equal is the
correct test here, no tolerance needed.
"""
if self.n_angles <= 1:
return True
x0 = self.x_axis_mm(0)
y0 = self.y_positions_mm(0)
return all(np.array_equal(self.x_axis_mm(a), x0)
and np.array_equal(self.y_positions_mm(a), y0)
for a in range(1, self.n_angles))
def time_axis_ns(self) -> np.ndarray:
return np.arange(self.samples_per_frame) / self.sample_rate_hz * 1e9