Merge dev-per-angle-background: one background per angle in the data block

This commit is contained in:
Thomas Ales
2026-09-04 12:44:29 -05:00
21 changed files with 823 additions and 241 deletions
+16
View File
@@ -48,3 +48,19 @@ rig, check whether the camera apps run without the shim; if they do, delete
`lib/ueye_loader.{c,so}`. Either way, record in SETUP.md where `lib/ueye_loader.{c,so}`. Either way, record in SETUP.md where
`libueye_api64.so.3.82` came from (IDS SDK version) and how the loader is `libueye_api64.so.3.82` came from (IDS SDK version) and how the loader is
meant to be used. meant to be used.
## Per-angle background: trigger round trip mid-scan
Every angle now captures its own background, so the scope switches from the
scan-time logic-AND trigger back to the single-record edge trigger and
returns to it once per angle (`core/scope_sras.py`:
`configure_background_trigger` → `capture_background` →
`configure_scan_trigger`). Before this, that transition happened once per
scan, with the stage idle and nothing depending on how long it took.
**Bench check:** run a multi-angle scan and watch the first row after each
background. If frames go missing at the start of an angle, the 0.2 s settle
in `configure_scan_trigger` is not enough for FastFrame to re-arm after an
AVERAGE-mode sequence, and the row-packing warning ("N frames acquired, M
expected") will say so in the log. Raise the settle rather than the ramp
buffer — the stage geometry is not what changed.
+6 -4
View File
@@ -12,10 +12,12 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
- **Laser Systems**: Helios pulsed laser and Genesis CW laser control - **Laser Systems**: Helios pulsed laser and Genesis CW laser control
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support - **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
- **Scan Planning**: Automated raster scan generation and execution - **Scan Planning**: Automated raster scan generation and execution
- **Per-Angle Background**: every angle opens with its own background
capture (Genesis off, Helios on), stored ahead of that angle's data
- **Angle Inspection**: Park the rig at random points across a plan's angles - **Angle Inspection**: Park the rig at random points across a plan's angles
to check the SAW response on the scope before committing to a long scan to check the SAW response on the scope before committing to a long scan
- **SAW Quality Check**: Acquire one row per angle — the row-wise middle of - **SAW Quality Check**: Acquire one row per angle — the row-wise middle of
the ROI — as a v10 `.sras`, then compare every angle's SAW frequency on one the ROI — as a v11 `.sras`, then compare every angle's SAW frequency on one
graph to judge the alignment before a full run graph to judge the alignment before a full run
- **Real-time Monitoring**: Live status updates and progress tracking - **Real-time Monitoring**: Live status updates and progress tracking
@@ -65,7 +67,7 @@ scanengine-3/
│ ├── angle_inspect.py # AngleInspector — park on a point per angle │ ├── angle_inspect.py # AngleInspector — park on a point per angle
│ ├── saw_check.py # Middle-row SAW check: plan + alignment read-out │ ├── saw_check.py # Middle-row SAW check: plan + alignment read-out
│ ├── rotation.py # GR rotation axis settings + moves │ ├── rotation.py # GR rotation axis settings + moves
│ ├── sras_format.py # v6/v10 .sras writer/reader (memory-mapped) │ ├── sras_format.py # v7/v11 .sras writer, v6/v10 reader (mmap)
│ ├── sras_analysis.py # Image reducers + SAW matched filter │ ├── sras_analysis.py # Image reducers + SAW matched filter
│ └── config.py # ScanDefaults ⇄ aui_defaults.json │ └── config.py # ScanDefaults ⇄ aui_defaults.json
│ │
@@ -98,7 +100,7 @@ scanengine-3/
├── sc3-aui-*.ui # Qt Designer files loaded at runtime ├── sc3-aui-*.ui # Qt Designer files loaded at runtime
│ │
├── tests/ # pytest suite ├── tests/ # pytest suite
│ ├── golden/ # v6 .sras + geometry fixtures │ ├── golden/ # legacy v6 .sras + geometry fixtures
│ ├── fakes.py # Recording fake stage/scope/rotator │ ├── fakes.py # Recording fake stage/scope/rotator
│ └── test_*.py │ └── test_*.py
│ │
@@ -220,7 +222,7 @@ print(f"wrote {result.rows_written} rows to {result.path}")
### Running a SAW quality check ### Running a SAW quality check
Same engine, same hardware sequence — the plan is reduced to one row per Same engine, same hardware sequence — the plan is reduced to one row per
angle and the result is tagged v10 so the viewer knows it is a check rather angle and the result is tagged v11 so the viewer knows it is a check rather
than a scan cut short: than a scan cut short:
```python ```python
+12 -5
View File
@@ -6,7 +6,7 @@ without the other:
*Acquisition* — ``middle_row_plan`` reduces a full ScanPlan to a single row *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 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 (``sras_format.VERSION_SAW_CHECK``), so a check costs one row-time per angle
instead of the hours a full multi-angle scan takes. 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 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 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: if plan.n_angles == 0:
raise ScanGeometryError("Cannot build a SAW check from a plan with no angles") 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, 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_start_ns: float | None = None,
gate_end_ns: float | None = None, gate_end_ns: float | None = None,
calib: ChannelCalibration | None = None, calib: ChannelCalibration | None = None,
on_progress=lambda done, total: None) -> list[AngleTrace]: on_progress=lambda done, total: None) -> list[AngleTrace]:
"""Peak SAW frequency along the middle row of every angle in ``sras``. """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 Works on a v11 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 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 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. 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 Angles with nothing on disk (an aborted file) are skipped rather than
reported as flat zero. 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) row = middle_row_index(st.n_rows_available)
view = sras.load_angle(st.index, n_rows=st.n_rows_available)[row:row + 1] 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, img = compute_rf_image(view, calib, freq_axis, dc_threshold_mv,
background=background, background=background,
gate_start_ns=gate_start_ns, gate_end_ns=gate_end_ns, 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 import scope_burst, scope_sras
from core.rotation import RotationAxis from core.rotation import RotationAxis
from core.scan_geometry import ScanPlan, validate_plan 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__) logger = logging.getLogger(__name__)
@@ -42,8 +45,15 @@ class ScanAborted(Exception):
@dataclass @dataclass
class ResumeTarget: 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 angle_idx: int
bg_offset: int
data_offset: int data_offset: int
n_rows: int n_rows: int
angle_deg: float angle_deg: float
@@ -280,27 +290,14 @@ class ScanEngine:
if self._resume is None: if self._resume is None:
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS) 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: else:
# Resuming: the file's existing background waveform and channel # Resuming: the file's channel preambles are reused as-is (the
# preambles are reused as-is (the format has no way to replace # format has no way to replace them without rewriting the whole
# them without rewriting the whole file), so background capture is # file). Each re-acquired angle still captures its own fresh
# skipped. Sanity-check that this scope still produces the same # background, which is rewritten in place over the old one.
# record length the file was started with — a mismatch would # Sanity-check that this scope still produces the same record
# silently corrupt the ragged per-row byte layout on append. # 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: if samples_per_frame != self._resume.samples_per_frame:
raise RuntimeError( raise RuntimeError(
f"Oscilloscope record length ({samples_per_frame} samples/frame) " 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" f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
"Please re-home the GR axis to 0° before continuing — the scan " "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" "will rotate it directly from angle to angle before scanning resumes.\n\n"
"Please ensure the Genesis laser is ON,\n" "Each angle begins with its own background capture, so you will "
"then click OK to continue scanning." "be asked to switch the Genesis laser off and on again per angle."
) )
scope_sras.configure_scan_trigger(self._scope) scope_sras.configure_scan_trigger(self._scope)
@@ -341,7 +338,7 @@ class ScanEngine:
return open(self._resume.path, "r+b") return open(self._resume.path, "r+b")
return create_scan_file( return create_scan_file(
self._out_path, self._plan, samples_per_frame, 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, version=self._file_version,
) )
@@ -363,11 +360,12 @@ class ScanEngine:
continue # not selected for (re)acquisition continue # not selected for (re)acquisition
self._pause_point() 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, # Interior angles may already have valid data on either side,
# so seek to this angle's fixed offset rather than relying on # so seek to this angle's fixed offset rather than relying on
# the file's current position. # 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: if self._rotator is not None and self._rotator.is_available:
delta = pa.angle_deg - self._rotator.current_deg delta = pa.angle_deg - self._rotator.current_deg
@@ -376,6 +374,9 @@ class ScanEngine:
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …") f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg) self._rotator.rotate_to(pa.angle_deg)
self._write_angle_background(scan_file, ai, n_angles,
pa.angle_deg, target)
if self._burst_mode: if self._burst_mode:
# Burst mode sizes the FastFrame count from the scope's whole # Burst mode sizes the FastFrame count from the scope's whole
# capacity instead (see scope_burst.start_burst), so there is # capacity instead (see scope_burst.start_burst), so there is
@@ -391,6 +392,49 @@ class ScanEngine:
result.angles_acquired.append(ai) 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) ─────────────── # ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int, 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 dataclasses import dataclass, field
from core.scan_engine import ResumeState, ResumeTarget from core.scan_engine import ResumeState, ResumeTarget
from core.sras_format import SrasFile from core.sras_format import WRITABLE_VERSIONS, SrasFile
@dataclass @dataclass
@@ -44,8 +44,9 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
final = set(selected) final = set(selected)
targets = [ targets = [
ResumeTarget(angle_idx=s.index, data_offset=s.data_offset, ResumeTarget(angle_idx=s.index, bg_offset=s.bg_offset,
n_rows=s.n_rows, angle_deg=s.angle_deg) data_offset=s.data_offset, n_rows=s.n_rows,
angle_deg=s.angle_deg)
for s in statuses if s.index in final for s in statuses if s.index in final
] ]
return ResumePlan(targets=targets, 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, def is_compatible(sras: SrasFile, *, velocity: float, laser_freq: float,
sample_rate: float, n_channels: int) -> bool: 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 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 h.n_channels == n_channels
and abs(h.velocity - velocity) <= 1e-3 and abs(h.velocity - velocity) <= 1e-3
and abs(h.laser_freq - laser_freq) <= 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) scope.set_channel_bandwidth(ch, p.bandwidth_hz)
def configure_acquisition(scope) -> int: def configure_background_trigger(scope) -> None:
"""Program the edge trigger and timebase; returns samples per frame. """Program the edge trigger used for a background capture.
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays Edge trigger on the rising edge of CH2 (laser pulse), FastFrame off so
off here so the background capture runs as a single record. 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.write("TRIGger:A:TYPe EDGE")
scope.set_trigger_source(2) 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_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
scope.set_acquire_mode("SAMPLE") scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(False) 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 # 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 # file header hardcodes bytes_per_sample=1, and a scope left on 2 bytes
# would corrupt every frame written. # 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: 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) geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
row tables (ragged) per angle: n_rows × >f (y positions) row tables (ragged) per angle: n_rows × >f (y positions)
preambles n_channels × (>H length + utf-8 WFMOutpre string) preambles n_channels × (>H length + utf-8 WFMOutpre string)
background block >I length + raw int8 CH1 average data block per angle: [background][rows]
waveform data angle-major, row-minor, channel-inner: background = >I length + raw int8 CH1 average
for each angle, for each row, for each channel, rows = for each row, for each channel,
n_frames × samples_per_frame × bytes_per_sample 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 Incomplete files are valid: the data block is one contiguous append-only
stream, so the readable prefix defines a single frontier past which nothing 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 Version 11 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 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 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 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. 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 from __future__ import annotations
@@ -37,14 +51,21 @@ import numpy as np
from core.scan_geometry import AngleGeometry, ScanPlan from core.scan_geometry import AngleGeometry, ScanPlan
MAGIC = b"SRAS" MAGIC = b"SRAS"
VERSION = 6 VERSION = 7
# One row per angle, taken from the middle of the ROI — see core.saw_check. # One row per angle, taken from the middle of the ROI — see core.saw_check.
VERSION_SAW_CHECK = 10 VERSION_SAW_CHECK = 11
SUPPORTED_VERSIONS = (VERSION, VERSION_SAW_CHECK) # 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_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH" GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes 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. # Oscilloscope channels recorded, in on-disk order.
SCAN_CHANNELS = [1, 3, 4] SCAN_CHANNELS = [1, 3, 4]
@@ -78,7 +99,8 @@ class AngleStatus:
angle_deg: float angle_deg: float
n_rows: int # declared n_rows: int # declared
row_bytes: int 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 n_rows_available: int
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
@@ -86,33 +108,38 @@ class AngleStatus:
def complete(self) -> bool: def complete(self) -> bool:
return self.status == STATUS_OK 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, def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
sample_rate: float, preambles: list[str], sample_rate: float, preambles: list[str],
background_waveform: bytes,
version: int = VERSION) -> BinaryIO: version: int = VERSION) -> BinaryIO:
"""Create a new .sras file and write the header + tables. """Create a new .sras file and write the header + tables.
``version`` selects which kind of file this is — VERSION for a full scan, ``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 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 either way; the one-row-per-angle rule that gives v11 its meaning is
checked here, since nothing downstream can recover from a v10 file that checked here, since nothing downstream can recover from a v11 file that
breaks it. breaks it.
Returns an open binary file positioned at the start of the data block; Returns an open binary file positioned at the start of the data block.
the caller appends waveform rows and must close it (try/finally). 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( raise ValueError(
f"Cannot write SRAS format version {version} " 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: if version == VERSION_SAW_CHECK:
bad = [f"{pa.angle_deg:.1f}° has {pa.n_rows}" bad = [f"{pa.angle_deg:.1f}° has {pa.n_rows}"
for pa in plan.per_angle if pa.n_rows != 1] for pa in plan.per_angle if pa.n_rows != 1]
if bad: if bad:
raise ValueError( 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 " + ", ".join(bad) + " — build the plan with "
"core.saw_check.middle_row_plan()." "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") enc = p.encode("utf-8")
f.write(struct.pack(">H", len(enc))) f.write(struct.pack(">H", len(enc)))
f.write(enc) f.write(enc)
f.write(struct.pack(">I", len(background_waveform)))
f.write(background_waveform)
return f 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 @dataclass
class SrasFile: 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 Parsing reads only the header/tables and the per-angle background blocks
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return — never the waveform block — so opening a multi-GB file is cheap.
read-only numpy views backed by a shared mmap; no data is copied until ``load_angle``/``load_row`` return read-only numpy views backed by a
the caller computes on it. 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 path: Path
version: int = field(init=False) version: int = field(init=False)
@@ -160,7 +201,7 @@ class SrasFile:
per_angle: list[AngleGeometry] = field(init=False) per_angle: list[AngleGeometry] = field(init=False)
preambles: list[str] = field(init=False) preambles: list[str] = field(init=False)
preambles_raw: list[bytes] = 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) data_start_offset: int = field(init=False)
file_size: 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_raw.append(f.read(plen))
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw] self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
(n_bg,) = struct.unpack(">I", f.read(4)) shared_bg = None
self.background = f.read(n_bg) 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.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 @property
def is_saw_check(self) -> bool: def is_saw_check(self) -> bool:
"""True for a v10 middle-row SAW quality check rather than a scan.""" """True for a middle-row SAW quality check rather than a scan."""
return self.version == VERSION_SAW_CHECK 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 ─────────────────────────────────────── # ── Frontier / truncation analysis ───────────────────────────────────────
@@ -237,40 +328,48 @@ class SrasFile:
Because the data is one contiguous append-only stream, once an angle Because the data is one contiguous append-only stream, once an angle
is found short every later angle is necessarily absent too — there is 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 = [] statuses = []
cursor = self.data_start_offset
frontier_seen = False frontier_seen = False
for ai, pa in enumerate(self.per_angle): for ai, pa in enumerate(self.per_angle):
row_bytes = self.row_bytes(ai) row_bytes = self.row_bytes(ai)
data_offset = cursor data_offset = self._data_offsets[ai]
if frontier_seen: if frontier_seen or not self._bg_present[ai]:
n_rows_available = 0 n_rows_available = 0
status = STATUS_MISSING status = STATUS_MISSING
frontier_seen = True
else: else:
declared_bytes = row_bytes * pa.n_rows 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 n_rows_available = pa.n_rows
status = STATUS_OK status = STATUS_OK
cursor += declared_bytes
else: 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 n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
frontier_seen = True frontier_seen = True
statuses.append(AngleStatus( statuses.append(AngleStatus(
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows, 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, n_rows_available=n_rows_available, status=status,
)) ))
return statuses return statuses
def angle_data_offset(self, angle_idx: int) -> int: def angle_data_offset(self, angle_idx: int) -> int:
offset = self.data_start_offset """Where angle ``angle_idx``'s rows start (past its background)."""
for ai in range(angle_idx): return self._data_offsets[angle_idx]
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
return offset 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 ───────────────────────────────────────────────────── # ── Lazy data access ─────────────────────────────────────────────────────
@@ -286,6 +385,11 @@ class SrasFile:
def _dtype(self) -> np.dtype: def _dtype(self) -> np.dtype:
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8) 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: def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
"""Read-only view of one angle's data block, shape """Read-only view of one angle's data block, shape
(n_rows, n_channels, n_frames, samples_per_frame). (n_rows, n_channels, n_frames, samples_per_frame).
+4 -2
View File
@@ -12,6 +12,7 @@ import traceback
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine
from core.sras_format import VERSION
class QtScanController(QObject): class QtScanController(QObject):
@@ -33,7 +34,7 @@ class QtScanController(QObject):
def __init__(self, stage, scope, rotator, plan, out_path, def __init__(self, stage, scope, rotator, plan, out_path,
resume=None, on_scan_active=None, burst_mode=False, resume=None, on_scan_active=None, burst_mode=False,
strict_rows=False): strict_rows=False, file_version=VERSION):
super().__init__() super().__init__()
self._prompt_event = threading.Event() self._prompt_event = threading.Event()
self._on_scan_active = on_scan_active self._on_scan_active = on_scan_active
@@ -50,7 +51,8 @@ class QtScanController(QObject):
self._engine = ScanEngine(stage, scope, rotator, plan, out_path, self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
resume=resume, callbacks=callbacks, resume=resume, callbacks=callbacks,
burst_mode=burst_mode, burst_mode=burst_mode,
strict_rows=strict_rows) strict_rows=strict_rows,
file_version=file_version)
# ── Engine control (called from the GUI thread) ─────────────────────────── # ── Engine control (called from the GUI thread) ───────────────────────────
+11 -7
View File
@@ -2,7 +2,7 @@
""" """
SAW Check Viewer — every angle's frequency on one graph. SAW Check Viewer — every angle's frequency on one graph.
Opens a v10 middle-row SAW check (written by the main app's "SAW Quality Opens a middle-row SAW check (written by the main app's "SAW Quality
Check") and plots the peak SAW frequency along each angle's row, all angles Check") and plots the peak SAW frequency along each angle's row, all angles
on the same axes. ``core.saw_check`` explains why that answers an alignment on the same axes. ``core.saw_check`` explains why that answers an alignment
question: every angle's middle row crosses the same ROI centre, so the angles question: every angle's middle row crosses the same ROI centre, so the angles
@@ -17,7 +17,7 @@ Two readings share the window:
* the summary — each angle's median with ±1σ, plotted against angle, plus * the summary — each angle's median with ±1σ, plotted against angle, plus
the same numbers per angle in a table. the same numbers per angle in a table.
A full v6 scan opens too: the same middle row is pulled out of it, so a scan A full scan opens too: the same middle row is pulled out of it, so a scan
can be re-examined with the check's own read-out after the fact. can be re-examined with the check's own read-out after the fact.
""" """
@@ -91,14 +91,17 @@ class LoadedCheck:
def __init__(self, path: Path): def __init__(self, path: Path):
self.sras = SrasFile(path) self.sras = SrasFile(path)
self.calib = ChannelCalibration.from_preambles(self.sras.preambles) self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
bg = np.frombuffer(self.sras.background, dtype=np.int8) # Each angle carries its own background (v7/v11); the read-out pulls
self.background = bg.astype(np.float32) if len(bg) else None # the right one per angle, so the viewer only needs to know whether
# there is anything to subtract at all.
self.has_background = any(self.sras.background_array(ai) is not None
for ai in range(self.sras.header.n_angles))
self.traces = [] self.traces = []
self.summary = None self.summary = None
def describe(self) -> str: def describe(self) -> str:
h = self.sras.header h = self.sras.header
kind = ("v10 SAW check" if self.sras.is_saw_check kind = (f"v{self.sras.version} SAW check" if self.sras.is_saw_check
else f"v{self.sras.version} scan — middle row of each angle") else f"v{self.sras.version} scan — middle row of each angle")
return (f"{self.sras.path.name}\n{kind}\n" return (f"{self.sras.path.name}\n{kind}\n"
f"{h.n_angles} angle(s) · {h.samples_per_frame} samples/frame · " f"{h.n_angles} angle(s) · {h.samples_per_frame} samples/frame · "
@@ -468,10 +471,11 @@ class SawCheckWindow(QMainWindow):
self._check = check self._check = check
self.setWindowTitle(f"SAW Check Viewer — {path.name}") self.setWindowTitle(f"SAW Check Viewer — {path.name}")
self.lbl_file.setText(check.describe()) self.lbl_file.setText(check.describe())
self.chk_bg_sub.setEnabled(check.has_background)
if not check.sras.is_saw_check: if not check.sras.is_saw_check:
self.lbl_status.setText( self.lbl_status.setText(
"Not a v10 check — reading the middle row of each angle " "Not a SAW check file — reading the middle row of each angle "
"out of this scan instead.") "out of this scan instead.")
else: else:
self.lbl_status.setText("") self.lbl_status.setText("")
@@ -504,7 +508,7 @@ class SawCheckWindow(QMainWindow):
gated = self.chk_gate.isChecked() gated = self.chk_gate.isChecked()
kwargs = dict( kwargs = dict(
dc_threshold_mv=self.spin_threshold_mv.value(), dc_threshold_mv=self.spin_threshold_mv.value(),
background=check.background if self.chk_bg_sub.isChecked() else None, subtract_background=self.chk_bg_sub.isChecked(),
gate_start_ns=self.spin_gate_start.value() if gated else None, gate_start_ns=self.spin_gate_start.value() if gated else None,
gate_end_ns=self.spin_gate_end.value() if gated else None, gate_end_ns=self.spin_gate_end.value() if gated else None,
calib=check.calib, calib=check.calib,
+17 -2
View File
@@ -38,7 +38,8 @@ from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
from core.scan_resume import is_compatible, plan_resume from core.scan_resume import is_compatible, plan_resume
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import ( from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header, SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
plan_from_header,
) )
from gui.jog_panel import ( from gui.jog_panel import (
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM, BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
@@ -1369,7 +1370,9 @@ class MainWindow(QMainWindow):
self, "SAW Quality Check", self, "SAW Quality Check",
f"Acquire the middle row of the ROI at {check_plan.n_angles} angle(s)?\n\n" f"Acquire the middle row of the ROI at {check_plan.n_angles} angle(s)?\n\n"
f"{rows}\n\n" f"{rows}\n\n"
f"Save → {out_path.name}{overwrite}", "Each angle starts with its own background capture, so you will be "
"asked to switch the Genesis laser off and back on at every angle."
f"\n\nSave → {out_path.name}{overwrite}",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
@@ -1519,6 +1522,18 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}") QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
return return
if sras.version not in WRITABLE_VERSIONS:
QMessageBox.warning(
self, "Cannot Resume Scan",
f"{path.name} is a v{sras.version} file, written before each "
"angle carried its own background waveform. Every angle this "
"app acquires now writes a background block that the older "
"layout has no room for, so resuming would shift the file's "
"data. Start a new scan instead — the old file still opens in "
"the viewer."
)
return
if not is_compatible(sras, velocity=SCAN_VELOCITY_MM_S, if not is_compatible(sras, velocity=SCAN_VELOCITY_MM_S,
laser_freq=LASER_FREQ_HZ, sample_rate=SAMPLE_RATE_HZ, laser_freq=LASER_FREQ_HZ, sample_rate=SAMPLE_RATE_HZ,
n_channels=len(SCAN_CHANNELS)): n_channels=len(SCAN_CHANNELS)):
+107 -44
View File
@@ -1,17 +1,24 @@
# SRAS Scan Binary Format — Versions 6 and 10 # SRAS Scan Binary Format — Versions 7 and 11 (reading 6 and 10)
Each `.sras` file contains **one complete scan**: all GR rotation angles and all Each `.sras` file contains **one complete scan**: all GR rotation angles and all
Y rows. Files are named `{prefix}.sras`. Y rows. Files are named `{prefix}.sras`.
Two versions share this layout byte for byte — only the version field differs, Two versions are written, sharing this layout byte for byte — only the version
and with it what the file means: field differs, and with it what the file means:
| Version | Meaning | Rows per angle | | Version | Meaning | Rows per angle |
|---------|---------|----------------| |---------|---------|----------------|
| 6 | A full scan. | Whatever the ROI needs. | | 7 | A full scan. | Whatever the ROI needs. |
| 10 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. | | 11 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
See [SAW Quality Check (v10)](#saw-quality-check-v10) below. See [SAW Quality Check (v11)](#saw-quality-check-v11) below.
**Versions 6 and 10** are the same two files as they were written before each
angle carried its own background: a single background block sat between the
preambles and the data block, and the data block held nothing but rows. They
are still read — see [Legacy layout (v6/v10)](#legacy-layout-v6v10) — but
nothing writes them any more, and a v6 file cannot be resumed into, since the
background block a resumed angle writes has no room in it.
Starting in v6, each angle only scans the **bounding box of the nominal ROI Starting in v6, each angle only scans the **bounding box of the nominal ROI
rotated by that specific angle** — not the worst case across all angles — so rotated by that specific angle** — not the worst case across all angles — so
@@ -30,10 +37,14 @@ instead of forcing every angle to the largest bounding box.
[Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)] [Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)]
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)] [Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)] [Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
[Data Block (ragged) — per angle: [Background Block][Waveform Data]]
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes] [Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
[Waveform Data (ragged) — per angle: n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes] [Waveform Data — n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes]
``` ```
So the data block reads `[background][scan][background][scan] …`, one pair per
angle, in angle-table order.
All multi-byte integers and floats use **big-endian** byte order All multi-byte integers and floats use **big-endian** byte order
(`>` in Python's `struct` module). (`>` in Python's `struct` module).
@@ -44,7 +55,7 @@ All multi-byte integers and floats use **big-endian** byte order
| Offset | Size | Type | Field | Description | | Offset | Size | Type | Field | Description |
|--------|------|-----------|--------------------|--------------------------------------------------| |--------|------|-----------|--------------------|--------------------------------------------------|
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) | | 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
| 4 | 1 | `uint8` | `version` | Format version — `6` (scan) or `10` (SAW check) | | 4 | 1 | `uint8` | `version` | Format version — `7` (scan) or `11` (SAW check) |
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles | | 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
| 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm | | 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm |
| 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm | | 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm |
@@ -127,54 +138,77 @@ to convert raw ADC values to volts.
--- ---
## Background Block ## Data Block (ragged)
Immediately after the preamble blocks: a single CH1 waveform captured with the Immediately after the preamble blocks, and running to the end of the file:
**Helios (generation) laser enabled** and the **Genesis (detection) laser for each angle in angle-table order, that angle's **background block**
disabled**. This provides a noise/background reference for subtraction during followed by that angle's **waveform data**.
post-processing.
```
for angle a in 0 … n_angles-1:
uint32 n_bg_samples # background block
int8[] bg_data
for row in 0 … n_rows[a]-1: # waveform data
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
for frame in 0 … n_frames[a]-1:
samples[0 … samples_per_frame-1] # bps bytes each
```
### Background block
One CH1 waveform captured with the **Helios (generation) laser enabled** and
the **Genesis (detection) laser disabled**, averaged over 1024 shots
(`core/scope_sras.py`, `BACKGROUND_AVERAGES`). It is a noise/background
reference for subtraction during post-processing.
``` ```
uint32 n_bg_samples — number of samples in the background waveform uint32 n_bg_samples — number of samples in the background waveform
int8[] bg_data — raw ADC samples (same encoding as waveform data) int8[] bg_data — raw ADC samples (same encoding as waveform data)
``` ```
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings. `n_bg_samples` equals `samples_per_frame` under normal acquisition settings,
but is **not** assumed to: readers take the per-angle offsets from a walk of
the data block, reading each length prefix as they go, rather than from
arithmetic over the geometry table alone.
--- Every angle carries its own. The operator is prompted to switch the Genesis
laser off before each angle and back on after the capture, so the reference is
taken minutes from the data it will be subtracted from — a multi-angle scan
runs for hours, and one background captured at the first angle has drifted by
the last. It also makes the angles comparable, which is the entire point of a
multi-angle scan: each is referenced against its own noise floor rather than
against whichever angle happened to be scanned first.
## Waveform Data (ragged) ### Waveform data
Immediately after the background block. Data is stored in **angle-major, Stored in **angle-major, row-minor** order, and each angle contributes a
row-minor** order, but unlike earlier versions each angle contributes a
different number of rows (`n_rows[a]`) and a different number of frames per different number of rows (`n_rows[a]`) and a different number of frames per
row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table
entry. Within each row, channels are interleaved in ascending channel-index entry. Within each row, channels are interleaved in ascending channel-index
order, with each channel's FastFrame data written in frame order. order, with each channel's FastFrame data written in frame order.
```
for angle a in 0 … n_angles-1:
for row in 0 … n_rows[a]-1:
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
for frame in 0 … n_frames[a]-1:
samples[0 … samples_per_frame-1] # bps bytes each
```
Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian **int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
int16**. int16**.
Total data size: Total data-block size:
``` ```
sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample sum over angles a of: 4 + n_bg_samples[a]
+ n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
``` ```
> **Incomplete files:** If a scan is aborted the file is closed immediately and > **Incomplete files:** If a scan is aborted the file is closed immediately and
> the data block will be shorter than the expected size. Readers should > the data block will be shorter than the expected size. Readers should walk
> reconstruct the expected per-angle byte offsets from the Per-Angle Geometry > the data block from its start — background length prefix, then that angle's
> Table and check `file_size` against the running total before reshaping — > declared row bytes from the Per-Angle Geometry Table — checking `file_size`
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not > against the running total before reshaping. A fixed
> work since row/frame counts are no longer uniform across angles. > `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not work since
> row/frame counts are no longer uniform across angles.
>
> An angle whose background block is not fully on disk has nothing of itself
> written yet: it is *missing*, not truncated, and the walk continues past it
> assuming the block a writer would have produced
> (`4 + samples_per_frame` bytes), which is where a resumed scan writes.
--- ---
@@ -197,8 +231,9 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
| Parameter | Value | | Parameter | Value |
|-----------------------|------------------------------------------| |-----------------------|------------------------------------------|
| Setup trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`) | | Background trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`), FastFrame off |
| Scan trigger | Logic AND, CH2 HIGH ∧ CH3 HIGH, 0.500 V | | Scan trigger | Logic AND, CH2 HIGH ∧ CH3 HIGH, 0.500 V |
| Background average | 1024 shots (`BACKGROUND_AVERAGES`), once per angle |
| Horizontal position | 30 (`HORizontal:POSition`) | | Horizontal position | 30 (`HORizontal:POSition`) |
| Sample rate | 6.25 GS/s (160 ps/sample) | | Sample rate | 6.25 GS/s (160 ps/sample) |
| Transfer format | `DATa:ENCdg RIBinary`, `DATa:WIDth 1` | | Transfer format | `DATa:ENCdg RIBinary`, `DATa:WIDth 1` |
@@ -226,6 +261,10 @@ is not recorded in the file.
| Curve transfers | one per channel per row | one per channel per burst | | Curve transfers | one per channel per row | one per channel per burst |
| Stage X trigger out | armed for the whole scan | armed per acquiring pass, dropped for the flyback | | Stage X trigger out | armed for the whole scan | armed per acquiring pass, dropped for the flyback |
Both paths take the same per-angle background: the scope returns to the
single-record edge trigger for the capture and back to the logic-AND trigger
before the angle's rows, so the two paths still produce byte-identical files.
Burst mode runs a single acquisition across several rows, so the return move Burst mode runs a single acquisition across several rows, so the return move
must not trigger: the trigger output is dropped before each flyback and must not trigger: the trigger output is dropped before each flyback and
re-armed for each acquiring pass. Row boundaries inside the burst come from re-armed for each acquiring pass. Row boundaries inside the burst come from
@@ -252,21 +291,22 @@ the file always ends on a whole-row boundary.
--- ---
## SAW Quality Check (v10) ## SAW Quality Check (v11)
A full multi-angle scan takes hours, and a rig whose angles disagree produces A full multi-angle scan takes hours, and a rig whose angles disagree produces
all of them before anyone finds out. The SAW quality check acquires **one row all of them before anyone finds out. The SAW quality check acquires **one row
per angle — the row-wise middle of the ROI** — and writes it as a v10 file. per angle — the row-wise middle of the ROI** — and writes it as a v11 file.
The cost is one row-time per angle instead of `n_rows` of them. The cost is one row-time per angle instead of `n_rows` of them.
Nothing about the byte layout changes. A v10 file is a v6 file in which every Nothing about the byte layout changes. A v11 file is a v7 file in which every
angle's Per-Angle Geometry Table entry declares `n_rows = 1`, and its Row Table angle's Per-Angle Geometry Table entry declares `n_rows = 1`, and its Row Table
holds that angle's single middle Y position. Every v6 reader that works from holds that angle's single middle Y position. Every v7 reader that works from
the geometry table (rather than assuming a uniform shape) reads a v10 file the geometry table (rather than assuming a uniform shape) reads a v11 file
unchanged. unchanged. Each angle still carries its own background, so a check costs the
same two operator prompts per angle a scan does.
The version byte earns its keep because the two are otherwise The version byte earns its keep because the two are otherwise
indistinguishable: **a v6 scan aborted after its first row is not a check**, indistinguishable: **a v7 scan aborted after its first row is not a check**,
even though both hold one row per angle. A reader that guessed from the row even though both hold one row per angle. A reader that guessed from the row
count would treat a failed scan as a deliberate measurement. count would treat a failed scan as a deliberate measurement.
@@ -278,12 +318,33 @@ of the rig — which is what makes it an alignment check. `saw_check_viewer.py`
plots every angle's frequency on one graph for exactly that comparison. plots every angle's frequency on one graph for exactly that comparison.
Writers must honour the one-row rule; `core.sras_format.create_scan_file` Writers must honour the one-row rule; `core.sras_format.create_scan_file`
refuses a v10 write for any plan that breaks it. Producing the plan is refuses a v11 write for any plan that breaks it. Producing the plan is
`core.saw_check.middle_row_plan(plan)`, and `n_rows // 2` is the middle-row `core.saw_check.middle_row_plan(plan)`, and `n_rows // 2` is the middle-row
rule (the upper of the two central rows when the count is even). rule (the upper of the two central rows when the count is even).
--- ---
## Legacy layout (v6/v10)
A v6 or v10 file differs in one place: the background block sits **once**,
between the preamble blocks and the data block, and the data block is
waveform data alone.
```
[Preamble Blocks]
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
[Waveform Data (ragged) — per angle: rows, as above, with no background between]
```
Everything else — header, tables, row order, spatial mapping — is identical,
which is why `core.sras_format.SrasFile` reads both: it hands the one legacy
background to every angle, so a reader that asks for angle *a*'s background
never has to know which layout it is looking at. Nothing writes v6/v10 any
more, and a resume refuses them, because a re-acquired angle writes a
background block the layout has no room for.
---
## Version History ## Version History
| Version | Change | | Version | Change |
@@ -294,5 +355,7 @@ rule (the upper of the two central rows when the count is even).
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. | | 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
| 5 | (skipped) | | 5 | (skipped) |
| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). | | 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). |
| 7–9 | (skipped) | | 7 | Background moved into the data block, one per angle: the block now reads `[background][scan][background][scan] …`. Each angle is preceded by its own `uint32` + `int8[]` background, captured (Genesis off, Helios on) just before that angle is scanned, so the reference is contemporary with the data and the angles are comparable to each other. Per-angle offsets therefore come from a walk of the data block rather than arithmetic over the geometry table. **v6 files still read; v6 files cannot be resumed into.** |
| 10 | Middle-row SAW quality check. Byte layout identical to v6, with every angle declaring exactly one row — the row-wise middle of the ROI. A v6 reader that derives its shape from the Per-Angle Geometry Table reads these unchanged; the version byte exists so a check is not confused with a scan aborted after its first row. Written by the main app's *SAW Quality Check*, read by `saw_check_viewer.py`. | | 8–9 | (skipped) |
| 10 | Middle-row SAW quality check on the v6 layout. Byte layout identical to v6, with every angle declaring exactly one row — the row-wise middle of the ROI. Superseded by v11; still read. |
| 11 | Middle-row SAW quality check on the v7 layout: identical to v7 with every angle declaring exactly one row, per-angle backgrounds included. The version byte exists so a check is not confused with a scan aborted after its first row. Written by the main app's *SAW Quality Check*, read by `saw_check_viewer.py`. |
+37 -19
View File
@@ -1,20 +1,22 @@
#!/opt/srasenv/bin/python3 #!/opt/srasenv/bin/python3
""" """
SRAS Scan Manager 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 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, 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 n_rows), background waveform, and waveform data block. This tool lists those
and lets you export a subset to a new .sras file, or delete a subset from per-angle sub-scans and lets you export a subset to a new .sras file, or
the file in place — both operations rewrite the angle/geometry/row tables delete a subset from the file in place — both operations rewrite the
and stream-copy only the selected angles' waveform data, producing a file angle/geometry/row tables, carry each kept angle's background across, and
that is itself a valid .sras readable by sras_viewer.py-style tools stream-copy only the selected angles' waveform data, producing a file that is
(once updated for v6) or sc3_aui_app.py. 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. Format versions 7 (full scan) and 11 (middle-row SAW check) are supported,
A subset keeps the version of the file it came from — a v10 check exports as as are their pre-per-angle-background predecessors 6 and 10. A subset keeps
a v10 check, since dropping angles from one leaves it one row per angle. 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 argparse
@@ -26,7 +28,9 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) 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 @dataclass
@@ -39,7 +43,8 @@ class AngleEntry:
n_rows_declared: int n_rows_declared: int
y_positions: list # declared length; may exceed what's actually on disk y_positions: list # declared length; may exceed what's actually on disk
row_bytes: int 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 n_rows_available: int = 0
data_size_available: int = 0 data_size_available: int = 0
complete: bool = True complete: bool = True
@@ -60,6 +65,7 @@ class SrasScanFile:
sras = SrasFile(self.path) sras = SrasFile(self.path)
h = sras.header h = sras.header
self.version = sras.version self.version = sras.version
self.is_saw_check = sras.is_saw_check
self.x_start_nominal = h.x_start_nominal self.x_start_nominal = h.x_start_nominal
self.y_start_nominal = h.y_start_nominal self.y_start_nominal = h.y_start_nominal
self.x_delta_nominal = h.x_delta_nominal self.x_delta_nominal = h.x_delta_nominal
@@ -72,7 +78,10 @@ class SrasScanFile:
self.bytes_per_sample = h.bytes_per_sample self.bytes_per_sample = h.bytes_per_sample
self.n_channels = h.n_channels self.n_channels = h.n_channels
self.preambles_raw = sras.preambles_raw 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.data_start_offset = sras.data_start_offset
self.file_size = sras.file_size self.file_size = sras.file_size
@@ -82,12 +91,13 @@ class SrasScanFile:
x_start=pa.x_start, x_delta=pa.x_delta, x_start=pa.x_start, x_delta=pa.x_delta,
n_frames=pa.n_frames, n_rows_declared=pa.n_rows, n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
y_positions=pa.y_positions, row_bytes=st.row_bytes, 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, n_rows_available=st.n_rows_available,
data_size_available=st.n_rows_available * st.row_bytes, data_size_available=st.n_rows_available * st.row_bytes,
complete=st.complete, 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: 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(struct.pack(">H", len(praw)))
dst.write(praw) dst.write(praw)
dst.write(struct.pack(">I", len(sf.background_raw))) if sf.legacy_layout:
dst.write(sf.background_raw) dst.write(struct.pack(BG_LEN_FMT, len(sf.shared_background)))
dst.write(sf.shared_background)
for e in selected: 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) src.seek(e.data_offset)
remaining = e.data_size_available remaining = e.data_size_available
chunk_size = 1 << 20 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): def print_summary(sf: SrasScanFile, selected: set):
print() 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"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} " 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"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(): def main():
ap = argparse.ArgumentParser( 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("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("--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("--export", metavar="SPEC", help="angle index spec to export, e.g. '0,2,4-6' or 'all'")
+24 -12
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SRAS Scan File Viewer SRAS Scan File Viewer
PyQt6 application for visualizing channel data from v6 .sras scan files. PyQt6 application for visualizing channel data from .sras scan files.
Channel semantics (fixed by sc3_aui_app.py acquisition settings): Channel semantics (fixed by sc3_aui_app.py acquisition settings):
CH1 — RF Acoustic Packet: FFT → peak frequency CH1 — RF Acoustic Packet: FFT → peak frequency
@@ -76,11 +76,18 @@ class LoadedScan:
def __init__(self, path: str): def __init__(self, path: str):
self.sras = SrasFile(Path(path)) self.sras = SrasFile(Path(path))
self.calib = ChannelCalibration.from_preambles(self.sras.preambles) self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
bg = np.frombuffer(self.sras.background, dtype=np.int8) # One background per angle (v7/v11), captured just before that angle
self.background = bg.astype(np.float32) if len(bg) else None # was scanned. A legacy v6/v10 file has a single one, which SrasFile
# repeats for every angle, so nothing here branches on the version.
self.backgrounds = [self.sras.background_array(ai)
for ai in range(self.sras.header.n_angles)]
# Rows actually on disk per angle (aborted/resumed scans) # Rows actually on disk per angle (aborted/resumed scans)
self.rows_available = [s.n_rows_available for s in self.sras.angle_status()] self.rows_available = [s.n_rows_available for s in self.sras.angle_status()]
def background(self, angle_idx: int) -> np.ndarray | None:
"""That angle's background waveform, or None if it has none."""
return self.backgrounds[angle_idx]
def angle_view(self, angle_idx: int) -> np.ndarray: def angle_view(self, angle_idx: int) -> np.ndarray:
"""Available rows of one angle: (rows, n_ch, n_frames, spf) int8 view.""" """Available rows of one angle: (rows, n_ch, n_frames, spf) int8 view."""
return self.sras.load_angle(angle_idx, n_rows=self.rows_available[angle_idx]) return self.sras.load_angle(angle_idx, n_rows=self.rows_available[angle_idx])
@@ -123,7 +130,7 @@ def compute_image(scan: LoadedScan, angle_idx: int, ch_idx: int,
if view.shape[0] == 0: if view.shape[0] == 0:
return np.zeros((0, 0), dtype=np.float32) return np.zeros((0, 0), dtype=np.float32)
sras = scan.sras sras = scan.sras
bg = scan.background if apply_bg_sub else None bg = scan.background(angle_idx) if apply_bg_sub else None
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX): if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
return compute_rf_image( return compute_rf_image(
@@ -232,7 +239,7 @@ class WaveformCanvas(FigureCanvasQTAgg):
dc4_val = float(sras.load_row(angle_idx, row_idx, CH4_IDX)[frame_idx] dc4_val = float(sras.load_row(angle_idx, row_idx, CH4_IDX)[frame_idx]
.mean(dtype=np.float32)) .mean(dtype=np.float32))
bg = scan.background if apply_bg_sub else None bg = scan.background(angle_idx) if apply_bg_sub else None
waveform_plot = waveform - bg if bg is not None else waveform waveform_plot = waveform - bg if bg is not None else waveform
self.ax_wave.cla() self.ax_wave.cla()
@@ -696,7 +703,7 @@ class SrasViewerWindow(QMainWindow):
self.chk_bg_sub.setChecked(True) self.chk_bg_sub.setChecked(True)
self.chk_bg_sub.setEnabled(False) self.chk_bg_sub.setEnabled(False)
self.chk_bg_sub.setToolTip( self.chk_bg_sub.setToolTip(
"Subtract the stored background waveform from each CH1 frame\n" "Subtract this angle's stored background waveform from each CH1 frame\n"
"before computing the FFT." "before computing the FFT."
) )
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled) self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
@@ -1043,7 +1050,7 @@ class SrasViewerWindow(QMainWindow):
self._on_view_changed() self._on_view_changed()
def _update_angle_info(self): def _update_angle_info(self):
"""Per-angle info fields (v6 geometry is ragged across angles).""" """Per-angle info fields (geometry is ragged across angles)."""
scan = self._scan scan = self._scan
if scan is None: if scan is None:
return return
@@ -1060,8 +1067,9 @@ class SrasViewerWindow(QMainWindow):
if avail < pa.n_rows: if avail < pa.n_rows:
notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk " notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk "
"(scan was aborted or is still running)") "(scan was aborted or is still running)")
if scan.background is not None: bg = scan.background(ai)
notes.append(f"Background waveform: {len(scan.background)} samples") if bg is not None:
notes.append(f"Background waveform (angle {ai}): {len(bg)} samples")
self.lbl_frame_warn.setText("\n".join(notes)) self.lbl_frame_warn.setText("\n".join(notes))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1083,7 +1091,7 @@ class SrasViewerWindow(QMainWindow):
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX) is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
self.spin_threshold_mv.setEnabled(is_ch1) self.spin_threshold_mv.setEnabled(is_ch1)
has_bg = has_file and scan.background is not None has_bg = has_file and scan.background(self.spin_angle.value()) is not None
self.chk_bg_sub.setEnabled(has_bg and is_ch1) self.chk_bg_sub.setEnabled(has_bg and is_ch1)
# Time gate only for FFT modes (the SAW pipeline has its own gating) # Time gate only for FFT modes (the SAW pipeline has its own gating)
self.chk_gate.setEnabled(is_fft) self.chk_gate.setEnabled(is_fft)
@@ -1197,6 +1205,9 @@ class SrasViewerWindow(QMainWindow):
idx = self.spin_angle.value() idx = self.spin_angle.value()
self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)") self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)")
self._update_angle_info() self._update_angle_info()
# Backgrounds are per angle, so whether there is one to subtract can
# change with the angle (a truncated file may be missing later ones).
self._update_controls_enabled(True)
self._request_compute() self._request_compute()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1382,7 +1393,8 @@ class SrasViewerWindow(QMainWindow):
angle_idx = self.spin_angle.value() angle_idx = self.spin_angle.value()
n_shots_req = self.spin_saw_n_shots.value() n_shots_req = self.spin_saw_n_shots.value()
row_sel, frame_sel = self._last_row, self._last_frame row_sel, frame_sel = self._last_row, self._last_frame
apply_bg = scan.background is not None and self.chk_bg_sub.isChecked() background = scan.background(angle_idx)
apply_bg = background is not None and self.chk_bg_sub.isChecked()
if row_sel is not None and frame_sel is not None: if row_sel is not None and frame_sel is not None:
src_desc = f"selected pixel (row={row_sel}, frame={frame_sel})" src_desc = f"selected pixel (row={row_sel}, frame={frame_sel})"
@@ -1405,7 +1417,7 @@ class SrasViewerWindow(QMainWindow):
rows, frames = np.divmod(flat, n_frames) rows, frames = np.divmod(flat, n_frames)
shots = view[rows, CH1_IDX, frames].astype(np.float32) shots = view[rows, CH1_IDX, frames].astype(np.float32)
if apply_bg: if apply_bg:
shots -= scan.background shots -= background
pipeline.build_template(shots) pipeline.build_template(shots)
return None return None
+14 -1
View File
@@ -99,6 +99,16 @@ class FakeStage:
round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S)) round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
def background_record(n: int, samples_per_frame: int) -> bytes:
"""The waveform FakeScope returns from its n-th background capture.
Every angle captures its own, so the tests need to tell one from the
next: a background that landed under the wrong angle would otherwise
look exactly like the right one.
"""
return bytes((n * 17 + s) % 256 for s in range(samples_per_frame))
class FakeScope: class FakeScope:
"""Stands in for TektronixOscilloscopeBase. """Stands in for TektronixOscilloscopeBase.
@@ -113,6 +123,7 @@ class FakeScope:
self._acq_polls = 0 self._acq_polls = 0
self._running = False self._running = False
self._acquired = 0 self._acquired = 0
self._backgrounds_taken = 0
# Per-channel running frame index. Frame content is a function of # Per-channel running frame index. Frame content is a function of
# (channel, index) alone, so the same total frame sequence yields the # (channel, index) alone, so the same total frame sequence yields the
# same bytes however it is chopped into transfers. # same bytes however it is chopped into transfers.
@@ -194,7 +205,9 @@ class FakeScope:
def transfer_curve(self): def transfer_curve(self):
self._t.record("transfer_curve") self._t.record("transfer_curve")
return bytes(range(self.samples_per_frame)) n = self._backgrounds_taken
self._backgrounds_taken += 1
return background_record(n, self.samples_per_frame)
def _frames(self, ch, count): def _frames(self, ch, count):
spf = self.samples_per_frame spf = self.samples_per_frame
+2 -2
View File
@@ -141,7 +141,7 @@
"angle_deg": -180.0, "angle_deg": -180.0,
"n_rows": 3, "n_rows": 3,
"row_bytes": 96, "row_bytes": 96,
"data_offset": 265, "data_offset": 553,
"n_rows_available": 0, "n_rows_available": 0,
"status": "MISSING" "status": "MISSING"
} }
@@ -161,7 +161,7 @@
"angle_deg": -180.0, "angle_deg": -180.0,
"n_rows": 3, "n_rows": 3,
"row_bytes": 96, "row_bytes": 96,
"data_offset": 265, "data_offset": 553,
"n_rows_available": 0, "n_rows_available": 0,
"status": "MISSING" "status": "MISSING"
} }
+41 -4
View File
@@ -1,9 +1,18 @@
"""Shared constants for the golden .sras fixtures. """Shared constants and writers for the .sras test fixtures.
These mirror the values tests/gen_goldens.py used when the fixtures were The constants mirror the values tests/gen_goldens.py used when the committed
generated against the pre-refactor code (commit d185676); they must never golden files were generated against the pre-refactor code (commit d185676);
change, or the byte-identical comparisons stop meaning anything. they must never change, or the comparisons against those files stop meaning
anything. Those goldens are legacy v6 files — the one background per file
layout — and are now read-only fixtures for the parser.
``write_v7`` builds the current layout (one background per angle, inside the
data block) over the same geometry, for the tests that need a file this
version of the app could actually have written.
""" """
from core.scan_geometry import build_plan
from core.sras_format import VERSION, create_scan_file, write_background_block
SPF = 8 SPF = 8
SAMPLE_RATE = 6.25e9 SAMPLE_RATE = 6.25e9
CHANNELS = [1, 3, 4] CHANNELS = [1, 3, 4]
@@ -19,3 +28,31 @@ VELOCITY_MM_S = 100.0
def synthetic_frame(ai, ri, ci, fi): def synthetic_frame(ai, ri, ci, fi):
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF)) return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
def tiny_plan():
"""The fixture geometry: 2 angles × 3 rows × 4 frames."""
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
velocity_mm_s=VELOCITY_MM_S)
def angle_background(ai):
"""A background that differs per angle, so tests can tell them apart."""
return bytes((ai * 11 + s) % 256 for s in range(SPF))
def write_v7(path, plan=None, version=None):
"""Write a complete current-format file: [background][rows] per angle."""
plan = plan if plan is not None else tiny_plan()
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
version=VERSION if version is None else version)
try:
for ai, pa in enumerate(plan.per_angle):
write_background_block(f, angle_background(ai))
for ri in range(pa.n_rows):
for ci in range(len(CHANNELS)):
for fi in range(pa.n_frames):
f.write(synthetic_frame(ai, ri, ci, fi))
finally:
f.close()
return plan
+48 -16
View File
@@ -1,7 +1,7 @@
"""Middle-row SAW quality check: plan reduction, the v10 file, and the read-out. """Middle-row SAW quality check: plan reduction, the v11 file, and the read-out.
The acquisition half runs on the same fake rig as the scan tests; the The acquisition half runs on the same fake rig as the scan tests; the
analysis half runs on a synthetic v10 file whose CH1 is a pure sine at a analysis half runs on a synthetic v11 file whose CH1 is a pure sine at a
known FFT bin, so the frequency a trace reports is a number the test knows known FFT bin, so the frequency a trace reports is a number the test knows
in advance rather than one it copies from the implementation. in advance rather than one it copies from the implementation.
""" """
@@ -19,6 +19,7 @@ from core.scan_engine import ScanCallbacks, ScanEngine
from core.scan_geometry import ScanGeometryError, build_plan from core.scan_geometry import ScanGeometryError, build_plan
from core.sras_format import ( from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file, SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file,
write_background_block,
) )
from fakes import FakeScope, FakeStage, FakeT3R, Trace from fakes import FakeScope, FakeStage, FakeT3R, Trace
@@ -51,13 +52,19 @@ def sine_frame(k: int) -> bytes:
return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes() return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes()
def write_check(path, bins, n_masked_frames=0, plan=None): def write_check(path, bins, n_masked_frames=0, plan=None, backgrounds=None):
"""A synthetic v10 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`.""" """A synthetic v11 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`.
``backgrounds`` supplies each angle's own background block; the default
is a flat zero one per angle, which subtracts to nothing.
"""
plan = plan if plan is not None else middle_row_plan(full_plan(len(bins))) plan = plan if plan is not None else middle_row_plan(full_plan(len(bins)))
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF), f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
version=VERSION_SAW_CHECK) version=VERSION_SAW_CHECK)
try: try:
for ai, pa in enumerate(plan.per_angle): for ai, pa in enumerate(plan.per_angle):
write_background_block(
f, bytes(SPF) if backgrounds is None else backgrounds[ai])
wave = sine_frame(bins[ai]) wave = sine_frame(bins[ai])
for ch in SCAN_CHANNELS: for ch in SCAN_CHANNELS:
for fi in range(pa.n_frames): for fi in range(pa.n_frames):
@@ -127,9 +134,9 @@ def test_middle_row_plan_rejects_an_angle_with_no_rows():
middle_row_plan(plan) middle_row_plan(plan)
# ── The v10 file ───────────────────────────────────────────────────────────── # ── The v11 file ─────────────────────────────────────────────────────────────
def test_v10_write_read_roundtrip(tmp_path): def test_saw_check_write_read_roundtrip(tmp_path):
out = tmp_path / "check.sras" out = tmp_path / "check.sras"
plan = write_check(out, bins=(8, 8, 8)) plan = write_check(out, bins=(8, 8, 8))
@@ -141,24 +148,25 @@ def test_v10_write_read_roundtrip(tmp_path):
sras.close() sras.close()
def test_v10_rejects_a_multi_row_plan(tmp_path): def test_saw_check_rejects_a_multi_row_plan(tmp_path):
plan = full_plan(num_angles=2) plan = full_plan(num_angles=2)
assert any(pa.n_rows > 1 for pa in plan.per_angle) assert any(pa.n_rows > 1 for pa in plan.per_angle)
with pytest.raises(ValueError, match="exactly one row per angle"): with pytest.raises(ValueError, match="exactly one row per angle"):
create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE, create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE,
PREAMBLES, bytes(SPF), version=VERSION_SAW_CHECK) PREAMBLES, version=VERSION_SAW_CHECK)
assert not (tmp_path / "bad.sras").exists() assert not (tmp_path / "bad.sras").exists()
def test_unknown_version_rejected_at_write(tmp_path): def test_unknown_version_rejected_at_write(tmp_path):
with pytest.raises(ValueError, match="version 7"): with pytest.raises(ValueError, match="version 99"):
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)), create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF), version=7) SPF, SAMPLE_RATE, PREAMBLES, version=99)
def test_v6_file_is_not_a_saw_check(): def test_a_scan_is_not_a_saw_check():
sras = SrasFile("tests/golden/complete.sras") """Legacy and current scans alike: only the check versions say check."""
assert sras.version == VERSION and not sras.is_saw_check assert not SrasFile("tests/golden/complete.sras").is_saw_check
assert VERSION not in (10, VERSION_SAW_CHECK)
# ── Acquisition through the engine ─────────────────────────────────────────── # ── Acquisition through the engine ───────────────────────────────────────────
@@ -176,7 +184,7 @@ def run_engine(tmp_path, num_angles=3):
return engine.run(), plan, check, trace return engine.run(), plan, check, trace
def test_engine_writes_a_complete_v10_check(tmp_path): def test_engine_writes_a_complete_saw_check(tmp_path):
result, plan, check, _ = run_engine(tmp_path) result, plan, check, _ = run_engine(tmp_path)
assert not result.aborted assert not result.aborted
@@ -200,7 +208,7 @@ def test_engine_visits_each_middle_row_once(tmp_path):
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle] assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
def test_engine_still_writes_v6_by_default(tmp_path): def test_engine_still_writes_a_full_scan_by_default(tmp_path):
trace = Trace() trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF) scope = FakeScope(trace, samples_per_frame=SPF)
stage = FakeStage(trace, scope=scope) stage = FakeStage(trace, scope=scope)
@@ -229,6 +237,30 @@ def test_traces_report_the_injected_frequency(tmp_path):
assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6) assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6)
def test_background_subtraction_uses_each_angles_own(tmp_path):
"""Every angle is referenced against its own background, not angle 1's.
Each angle's background here is a copy of that angle's own CH1 wave, so
subtracting the right one leaves nothing to read at any angle — where
reusing angle 1's everywhere would leave angles 2 and 3 reporting their
sine unchanged.
"""
out = tmp_path / "check.sras"
bins = (8, 9, 10)
backgrounds = [sine_frame(k) for k in bins]
write_check(out, bins=bins, backgrounds=backgrounds)
with SrasFile(out) as sras:
assert sras.backgrounds == backgrounds
plain = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
subtracted = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV,
subtract_background=True)
assert [t.median_mhz for t in plain] == [pytest.approx(bin_mhz(k)) for k in bins]
for trace in subtracted:
assert np.isnan(trace.freq_mhz).all()
def test_masked_pixels_become_nan_not_zero(tmp_path): def test_masked_pixels_become_nan_not_zero(tmp_path):
out = tmp_path / "check.sras" out = tmp_path / "check.sras"
write_check(out, bins=(8, 8, 8), n_masked_frames=2) write_check(out, bins=(8, 8, 8), n_masked_frames=2)
+76 -9
View File
@@ -14,8 +14,8 @@ from core.scan_engine import (
ResumeTarget, ResumeTarget,
) )
from core.scan_geometry import ScanGeometryError, build_plan from core.scan_geometry import ScanGeometryError, build_plan
from core.sras_format import SCAN_CHANNELS, SrasFile from core.sras_format import BG_LEN_SIZE, SCAN_CHANNELS, SrasFile
from fakes import FakeScope, FakeStage, FakeT3R, Trace from fakes import FakeScope, FakeStage, FakeT3R, Trace, background_record
SPF = 8 SPF = 8
@@ -57,7 +57,41 @@ def test_single_angle_scan_writes_readable_file(tmp_path):
# File is complete: every declared row present on disk # File is complete: every declared row present on disk
assert [s.status for s in sras.angle_status()] == ["OK"] assert [s.status for s in sras.angle_status()] == ["OK"]
assert len(sras.preambles) == 3 assert len(sras.preambles) == 3
assert sras.background == bytes(range(SPF)) assert sras.backgrounds == [background_record(0, SPF)]
def test_each_angle_captures_and_stores_its_own_background(tmp_path):
"""One background per angle, taken after the rotation, kept ahead of it."""
prompts = []
engine, trace, plan = build(
tmp_path, num_angles=3,
callbacks=ScanCallbacks(prompt=lambda title, msg: prompts.append(title)))
engine.run()
sras = SrasFile(tmp_path / "out.sras")
assert trace.count("transfer_curve") == 3
assert sras.backgrounds == [background_record(i, SPF) for i in range(3)]
for st in sras.angle_status():
assert st.bg_bytes == BG_LEN_SIZE + SPF
assert st.status == "OK"
# Each capture follows the rotation to the angle it belongs to (the last
# rotation is the return to home after the final angle).
assert [c[0] for c in trace.calls
if c[0] in ("t3r_rotate", "transfer_curve")] == [
"transfer_curve", "t3r_rotate", "transfer_curve",
"t3r_rotate", "transfer_curve", "t3r_rotate"]
# Two prompts per angle: Genesis off for the capture, back on to scan.
assert prompts == [title for i in range(3) for title in
(f"Background Capture — Angle {i + 1}/3",
f"Begin Angle {i + 1}/3")]
# The scope goes back to the scan trigger after every capture, not just
# once at the start — the capture needs the single-record edge trigger.
cmds = [c[1] for c in trace.of("write")]
assert cmds.count("TRIGger:A:TYPe EDGE") == 4 # prepare + one per angle
assert cmds.count("TRIGger:A:TYPe LOGIc") == 4
def test_command_sequence_order(tmp_path): def test_command_sequence_order(tmp_path):
@@ -236,8 +270,9 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
target = statuses[1] target = statuses[1]
resume = ResumeState( resume = ResumeState(
path=path, path=path,
targets=[ResumeTarget(target.index, target.data_offset, targets=[ResumeTarget(target.index, target.bg_offset,
target.n_rows, target.angle_deg)], target.data_offset, target.n_rows,
target.angle_deg)],
samples_per_frame=SPF, samples_per_frame=SPF,
) )
@@ -249,19 +284,51 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
assert result.rows_written == plan.per_angle[1].n_rows assert result.rows_written == plan.per_angle[1].n_rows
rewritten = path.read_bytes() rewritten = path.read_bytes()
assert len(rewritten) == len(original) assert len(rewritten) == len(original)
# Angle 0's block is untouched; angle 1's changed (fresh frame data) # Angle 0's block is untouched; angle 1's changed — background included,
a1_start, a1_end = target.data_offset, target.data_offset + target.row_bytes * target.n_rows # since a re-acquired angle captures a fresh one over the old.
a1_start = target.bg_offset
a1_end = target.data_offset + target.row_bytes * target.n_rows
assert rewritten[:a1_start] == original[:a1_start] assert rewritten[:a1_start] == original[:a1_start]
assert rewritten[a1_start:a1_end] != original[a1_start:a1_end] assert rewritten[a1_start:a1_end] != original[a1_start:a1_end]
assert rewritten[a1_end:] == original[a1_end:] assert rewritten[a1_end:] == original[a1_end:]
# The new background is angle 1's own, written in place of its old one.
reread = SrasFile(path)
assert reread.backgrounds[1] == background_record(0, SPF)
assert reread.backgrounds[0] == SrasFile(path).backgrounds[0]
assert [s.status for s in reread.angle_status()] == ["OK"] * 3
def test_resume_rejects_a_background_that_would_shift_the_file(tmp_path):
"""A re-acquired angle's background must fit the room the file has.
Nothing else in the file records where an angle's rows begin, so a longer
or shorter background would push every row behind it out of position.
"""
engine, _, _ = build(tmp_path, num_angles=2)
engine.run()
path = tmp_path / "out.sras"
target = SrasFile(path).angle_status()[0]
resume = ResumeState(
path=path,
targets=[ResumeTarget(0, target.bg_offset, target.data_offset,
target.n_rows, target.angle_deg)],
samples_per_frame=SPF,
)
engine2, _, _ = build(tmp_path, num_angles=2, resume=resume)
# A scope that hands back a longer record than the file was written with
engine2._scope.transfer_curve = lambda: bytes(SPF + 4)
with pytest.raises(RuntimeError, match="shift every row"):
engine2.run()
def test_resume_record_length_mismatch_rejected(tmp_path): def test_resume_record_length_mismatch_rejected(tmp_path):
engine, trace, plan = build(tmp_path) engine, trace, plan = build(tmp_path)
engine.run() engine.run()
path = tmp_path / "out.sras" path = tmp_path / "out.sras"
resume = ResumeState(path=path, resume = ResumeState(path=path,
targets=[ResumeTarget(0, 0, 1, 0.0)], targets=[ResumeTarget(0, 0, 0, 1, 0.0)],
samples_per_frame=SPF + 1) # scope changed samples_per_frame=SPF + 1) # scope changed
engine2, _, _ = build(tmp_path, resume=resume) engine2, _, _ = build(tmp_path, resume=resume)
with pytest.raises(RuntimeError, match="record length"): with pytest.raises(RuntimeError, match="record length"):
@@ -461,7 +528,7 @@ def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
# Row 1 was written in full; row 2 aborted before writing anything, so # Row 1 was written in full; row 2 aborted before writing anything, so
# the file ends exactly on a row boundary. # the file ends exactly on a row boundary.
sras = SrasFile(tmp_path / "out.sras") sras = SrasFile(tmp_path / "out.sras")
written = (tmp_path / "out.sras").stat().st_size - sras.data_start_offset written = (tmp_path / "out.sras").stat().st_size - sras.angle_data_offset(0)
assert written == sras.row_bytes(0) assert written == sras.row_bytes(0)
+20 -3
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from core.scan_resume import is_compatible, plan_resume from core.scan_resume import is_compatible, plan_resume
from core.sras_format import SrasFile from core.sras_format import SrasFile
from golden_util import write_v7
GOLDEN = Path(__file__).parent / "golden" GOLDEN = Path(__file__).parent / "golden"
@@ -47,11 +48,17 @@ def test_selecting_only_a_later_angle_pulls_in_the_frontier():
assert plan.auto_added == [0] assert plan.auto_added == [0]
def test_targets_carry_offsets_and_rows(): def test_targets_carry_offsets_and_rows(tmp_path):
st = _statuses("complete.sras") out = tmp_path / "v7.sras"
write_v7(out)
st = SrasFile(out).angle_status()
plan = plan_resume(st, selected={0, 1}) plan = plan_resume(st, selected={0, 1})
for target, status in zip(plan.targets, st, strict=True): for target, status in zip(plan.targets, st, strict=True):
# A re-acquired angle rewrites its background too, so a target has to
# know where the block starts as well as where the rows do.
assert target.bg_offset == status.bg_offset
assert target.data_offset == status.data_offset assert target.data_offset == status.data_offset
assert target.bg_offset < target.data_offset
assert target.n_rows == status.n_rows assert target.n_rows == status.n_rows
assert target.angle_deg == status.angle_deg assert target.angle_deg == status.angle_deg
assert plan.total_rows == sum(s.n_rows for s in st) assert plan.total_rows == sum(s.n_rows for s in st)
@@ -65,9 +72,19 @@ def test_to_state_carries_samples_per_frame():
assert state.target_indices == {0} assert state.target_indices == {0}
def test_is_compatible_checks_acquisition_settings(): def test_is_compatible_rejects_a_legacy_file():
"""A v6 file has no room for the background block each angle now writes."""
sras = SrasFile(GOLDEN / "complete.sras") sras = SrasFile(GOLDEN / "complete.sras")
h = sras.header h = sras.header
assert not is_compatible(sras, velocity=h.velocity, laser_freq=h.laser_freq,
sample_rate=h.sample_rate, n_channels=h.n_channels)
def test_is_compatible_checks_acquisition_settings(tmp_path):
out = tmp_path / "v7.sras"
write_v7(out)
sras = SrasFile(out)
h = sras.header
ok = dict(velocity=h.velocity, laser_freq=h.laser_freq, ok = dict(velocity=h.velocity, laser_freq=h.laser_freq,
sample_rate=h.sample_rate, n_channels=h.n_channels) sample_rate=h.sample_rate, n_channels=h.n_channels)
assert is_compatible(sras, **ok) assert is_compatible(sras, **ok)
+3 -1
View File
@@ -22,7 +22,9 @@ def _loaded_scan(name="complete.sras"):
def test_loaded_scan_basics(): def test_loaded_scan_basics():
scan = _loaded_scan() scan = _loaded_scan()
assert scan.rows_available == [3, 3] assert scan.rows_available == [3, 3]
assert scan.background is not None and len(scan.background) == 8 # A legacy v6 fixture: its one background stands in for every angle.
assert all(scan.background(ai) is not None and len(scan.background(ai)) == 8
for ai in (0, 1))
assert len(scan.calib.ymult_mv) == 3 assert len(scan.calib.ymult_mv) == 3
view = scan.angle_view(0) view = scan.angle_view(0)
assert view.shape == (3, 3, 4, 8) assert view.shape == (3, 3, 4, 8)
+140 -27
View File
@@ -1,21 +1,27 @@
"""core.sras_format vs the pre-refactor golden fixtures. """core.sras_format: the current writer against the spec, and the parser
against the pre-refactor golden fixtures.
The goldens were produced by the original sc3_aui_app implementation; the The goldens are legacy v6 files produced by the original sc3_aui_app
extracted module must reproduce them byte-for-byte (writer) and implementation — one background for the whole file. Nothing writes that
field-for-field (parser + frontier walk). layout any more, so they lock the parser (field-for-field, including the
frontier walk) rather than the writer. The writer is locked instead against
bytes this test lays out from scan_format.md itself.
""" """
import json import json
import struct
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
import pytest import pytest
from core.scan_geometry import build_plan from core.sras_format import (
from core.sras_format import SrasFile, create_scan_file BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, VERSION, VERSION_SAW_CHECK,
SrasFile, create_scan_file,
)
from golden_util import ( from golden_util import (
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF, BACKGROUND, CHANNELS, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame, synthetic_frame, tiny_plan, write_v7,
) )
GOLDEN = Path(__file__).parent / "golden" GOLDEN = Path(__file__).parent / "golden"
@@ -27,28 +33,43 @@ def expected():
return json.load(f) return json.load(f)
def _tiny_plan(): def spec_bytes(plan):
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ, """The v7 layout spelled out from scan_format.md, writer not involved."""
velocity_mm_s=VELOCITY_MM_S) buf = bytearray()
buf += struct.pack(HDR_FMT, MAGIC, VERSION, plan.n_angles,
plan.x_start_nominal, plan.y_start_nominal,
def _write_complete(path): plan.x_delta_nominal, plan.y_delta_nominal,
plan = _tiny_plan() plan.row_spacing, plan.velocity_mm_s, plan.laser_freq_hz,
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND) SPF, SAMPLE_RATE, 1, len(CHANNELS))
try: buf += struct.pack(f">{plan.n_angles}f", *plan.angles)
for pa in plan.per_angle:
buf += struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows)
for pa in plan.per_angle:
buf += struct.pack(f">{pa.n_rows}f", *pa.y_positions)
for pre in PREAMBLES:
buf += struct.pack(">H", len(pre)) + pre.encode("utf-8")
for ai, pa in enumerate(plan.per_angle): for ai, pa in enumerate(plan.per_angle):
bg = angle_background(ai)
buf += struct.pack(BG_LEN_FMT, len(bg)) + bg
for ri in range(pa.n_rows): for ri in range(pa.n_rows):
for ci in range(len(CHANNELS)): for ci in range(len(CHANNELS)):
for fi in range(pa.n_frames): for fi in range(pa.n_frames):
f.write(synthetic_frame(ai, ri, ci, fi)) buf += synthetic_frame(ai, ri, ci, fi)
finally: return bytes(buf)
f.close()
def test_writer_byte_identical_to_golden(tmp_path): def test_writer_matches_the_spec_byte_for_byte(tmp_path):
out = tmp_path / "rewrite.sras" out = tmp_path / "v7.sras"
_write_complete(out) plan = write_v7(out)
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes() assert out.read_bytes() == spec_bytes(plan)
def test_writer_refuses_the_legacy_versions(tmp_path):
for version in (6, 10):
with pytest.raises(ValueError, match=f"version {version}"):
create_scan_file(tmp_path / "bad.sras", tiny_plan(), SPF,
SAMPLE_RATE, PREAMBLES, version=version)
assert not (tmp_path / "bad.sras").exists()
def test_header_matches_golden(expected): def test_header_matches_golden(expected):
@@ -75,15 +96,107 @@ def test_header_matches_golden(expected):
def test_frontier_all_truncation_variants(expected): def test_frontier_all_truncation_variants(expected):
"""Every field the goldens recorded, plus the one added since.
The expectations predate AngleStatus.bg_offset, so they are compared key
by key; a v6 angle has no background block of its own, which is exactly
what bg_offset == data_offset says.
Two of the recorded data_offsets were corrected when the walk moved into
the parser: an angle past the frontier used to report the frontier's own
offset, because the old walk stopped advancing its cursor there, which
handed a resumed scan the same write position for every missing angle.
They are now the declared position each angle will be written at.
"""
for name, exp_statuses in expected["statuses"].items(): for name, exp_statuses in expected["statuses"].items():
statuses = SrasFile(GOLDEN / name).angle_status() statuses = SrasFile(GOLDEN / name).angle_status()
assert [asdict(s) for s in statuses] == exp_statuses, f"mismatch for {name}" assert len(statuses) == len(exp_statuses), f"mismatch for {name}"
for status, exp in zip(statuses, exp_statuses, strict=True):
got = asdict(status)
assert {k: got[k] for k in exp} == exp, f"mismatch for {name}"
assert status.bg_offset == status.data_offset
assert status.bg_bytes == 0
def test_preambles_and_background_roundtrip(): def test_legacy_preambles_and_shared_background():
"""A v6 file's one background stands in for every angle's."""
sras = SrasFile(GOLDEN / "complete.sras") sras = SrasFile(GOLDEN / "complete.sras")
assert sras.preambles == PREAMBLES assert sras.preambles == PREAMBLES
assert sras.background == BACKGROUND assert sras.is_legacy_layout
assert sras.backgrounds == [BACKGROUND] * sras.header.n_angles
assert np.array_equal(sras.background_array(1),
np.frombuffer(BACKGROUND, dtype=np.int8))
# ── Per-angle backgrounds (v7/v11) ───────────────────────────────────────────
def test_each_angle_keeps_its_own_background(tmp_path):
out = tmp_path / "v7.sras"
plan = write_v7(out)
sras = SrasFile(out)
assert not sras.is_legacy_layout
assert sras.backgrounds == [angle_background(ai)
for ai in range(plan.n_angles)]
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
# Every angle's rows start just past its own background block …
for st in sras.angle_status():
assert st.bg_bytes == 4 + SPF
assert st.data_offset == st.bg_offset + st.bg_bytes
# … and the data itself still reads back frame for frame.
assert sras.load_row(1, 2, 0).tobytes() == b"".join(
synthetic_frame(1, 2, 0, fi) for fi in range(plan.per_angle[1].n_frames))
sras.close()
def test_saw_check_version_also_carries_per_angle_backgrounds(tmp_path):
from core.saw_check import middle_row_plan
out = tmp_path / "check.sras"
plan = write_v7(out, plan=middle_row_plan(tiny_plan()),
version=VERSION_SAW_CHECK)
sras = SrasFile(out)
assert sras.is_saw_check and not sras.is_legacy_layout
assert sras.backgrounds == [angle_background(ai)
for ai in range(plan.n_angles)]
sras.close()
def test_angle_missing_its_background_is_the_frontier(tmp_path):
"""A file cut inside a background block stops at that angle.
Nothing of that angle is on disk yet — not even the reference its rows
would be read against — so it is MISSING rather than TRUNCATED, and its
predicted offsets are where a resumed scan would write.
"""
out = tmp_path / "v7.sras"
write_v7(out)
whole = out.read_bytes()
bg1 = SrasFile(out).angle_status()[1].bg_offset
for cut, expected_status in ((bg1, "MISSING"), (bg1 + 4 + SPF // 2, "MISSING")):
out.write_bytes(whole[:cut])
statuses = SrasFile(out).angle_status()
assert [s.status for s in statuses] == ["OK", expected_status]
assert statuses[1].n_rows_available == 0
assert statuses[1].bg_offset == bg1
# The absent block is predicted at a full record's worth of bytes,
# which is what the writer will produce when the scan resumes.
assert statuses[1].data_offset == bg1 + 4 + SPF
def test_rows_after_a_background_still_truncate_by_row(tmp_path):
out = tmp_path / "v7.sras"
plan = write_v7(out)
whole = out.read_bytes()
st1 = SrasFile(out).angle_status()[1]
out.write_bytes(whole[:st1.data_offset + 2 * st1.row_bytes])
statuses = SrasFile(out).angle_status()
assert [s.status for s in statuses] == ["OK", "TRUNCATED"]
assert statuses[1].n_rows_available == 2
assert SrasFile(out).load_angle(1, n_rows=2).shape[0] == 2
assert plan.per_angle[1].n_rows == 3
def test_load_angle_memmap_equals_eager(): def test_load_angle_memmap_equals_eager():