SAW quality check: one middle row per angle, and a viewer that overlays them

A full multi-angle scan takes hours, and a rig whose angles disagree produces
all of them before anyone finds out. This adds a test mode that acquires one
row per angle — the row-wise middle of the ROI — and a viewer that puts every
angle's SAW frequency on one graph. The default 80×50 mm ROI at 5 angles goes
from 1461 rows to 5.

Why the middle row answers an alignment question at all: build_plan centres
every angle's rotated bounding box on the same nominal ROI centre, so each
angle's middle row crosses that one point on the sample. All the angles
measure the same material, so a spread in their frequencies belongs to the rig
rather than to where each row happened to land. test_every_angles_middle_row_
crosses_the_roi_centre pins that premise, since the whole comparison rests on
it and nothing else in the geometry code would notice it breaking.

core/saw_check.py — both halves of the mode, kept together because neither is
much use alone. middle_row_plan() reduces a ScanPlan to one row per angle
(n_rows // 2, the upper of two centre rows when even); frequency_traces() and
alignment_summary() turn the resulting file back into per-angle frequency
traces and the scalars an operator is actually asking about — the spread of
the per-angle medians, the worst drift along a row, the sparsest row. The
verdict thresholds are labelled as rules of thumb, not physics: an anisotropic
sample genuinely varies with angle, so a wide spread is a prompt to look at
the curves rather than a verdict.

Format v10: byte-identical to v6, one row per angle. The version byte earns
its keep because the two are otherwise indistinguishable — a v6 scan aborted
after its first row is not a check, and a reader guessing from the row count
would read a failed scan as a deliberate measurement. create_scan_file()
enforces the one-row rule at write time, since nothing downstream can recover
from a v10 file that breaks it. ScanEngine gains file_version and is otherwise
untouched: the acquisition, the abort/pause path and the background capture
are the scan's, unchanged.

sras_scan_manager.py now carries the source file's version through an export
instead of stamping v6 on everything, which the wider reader would otherwise
have made a lie.

saw_check_viewer.py — frequency along the row, one curve per angle, over a
common offset axis so the curves lie on the same piece of sample; a summary of
each angle's median ±1σ against angle; and the per-angle numbers in a table.
Analysis parameters (DC threshold, background, time gate) recompute on a
worker thread; display ones (smoothing, axis, MHz↔m/s) only redraw. A full v6
scan opens too — the same middle row is pulled out of it — so a finished scan
can be re-examined with the check's own read-out.

In the app, a check finishes by handing the operator the file and an "Open
Viewer" button rather than shutting the rig down the way a completed scan
does. Burst mode is not offered: one row per angle means every burst would be
a single row, so it buys nothing and still pays for the gate preflight.

137 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-09-04 08:13:54 -05:00
parent dfd6c9e2b8
commit 844fcd0297
11 changed files with 1541 additions and 28 deletions
+46 -3
View File
@@ -1,8 +1,18 @@
# SRAS Scan Binary Format — Version 6
# SRAS Scan Binary Format — Versions 6 and 10
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
Y rows. Files are named `{prefix}.sras`.
Two versions share this layout byte for byte — only the version field differs,
and with it what the file means:
| Version | Meaning | Rows per angle |
|---------|---------|----------------|
| 6 | A full scan. | Whatever the ROI needs. |
| 10 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
See [SAW Quality Check (v10)](#saw-quality-check-v10) below.
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
`x_start`, `x_delta` (and therefore `n_frames`, the points/row count) and
@@ -34,7 +44,7 @@ All multi-byte integers and floats use **big-endian** byte order
| Offset | Size | Type | Field | Description |
|--------|------|-----------|--------------------|--------------------------------------------------|
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
| 4 | 1 | `uint8` | `version` | Format version — `6` |
| 4 | 1 | `uint8` | `version` | Format version — `6` (scan) or `10` (SAW check) |
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
| 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 |
@@ -242,6 +252,38 @@ the file always ends on a whole-row boundary.
---
## SAW Quality Check (v10)
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
per angle — the row-wise middle of the ROI** — and writes it as a v10 file.
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
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
the geometry table (rather than assuming a uniform shape) reads a v10 file
unchanged.
The version byte earns its keep because the two are otherwise
indistinguishable: **a v6 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
count would treat a failed scan as a deliberate measurement.
Why the middle row in particular: `core/scan_geometry.py` centres every
angle's rotated bounding box on the same nominal ROI centre, so each angle's
middle row crosses that one point on the sample. All the angles therefore
measure the same material, and a spread in their SAW frequencies is a property
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.
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
`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).
---
## Version History
| Version | Change |
@@ -252,4 +294,5 @@ the file always ends on a whole-row boundary.
| 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) |
| 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) |
| 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`. |