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:
+221
-18
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user