Phase 2: extract headless core modules (sras_format, scan_geometry, config)

- core/sras_format.py: THE v6 implementation — create_scan_file (writer,
  byte-identical to the old one, enforced against the Phase-0 goldens),
  SrasFile parser with frontier/truncation walk, and zero-copy mmap
  load_angle/load_row views for multi-GB files
- core/scan_geometry.py: ScanPlan/AngleGeometry dataclasses, build_plan
  (rotated-bbox trig from MainWindow._build_scan_params), travel-limit
  validate_plan (limits now a StageLimits dataclass, not literals buried
  in the worker), format_eta + EtaEstimator (bounded deque)
- core/config.py: ScanDefaults dataclass replaces the module-import-time
  dict globals. FIXES: editing any main-window port used to rewrite
  aui_defaults.json without helios_port, silently reverting the Helios
  port every time (test_helios_port_survives_partial_update covers it).
  Also drops the inert laser_freq_hz plumbing — scans always used the
  LASER_FREQ_HZ constant.
- hardware/serial_util.py: shared 8N1 open + scored port enumeration
  (promoted from t3r_control_panel); helios_laser and the panel use it
- sc3_aui_app.py and sras_scan_manager.py migrated onto core (three
  format implementations down to one); ScanWorker now takes a ScanPlan
- tests: byte-identical writer vs golden, frontier over every truncation
  variant, mmap==eager, geometry vs golden fixtures + invariants, config
  round-trip. 28 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-28 10:20:44 -05:00
parent 67aabde4b6
commit dff9f69d78
15 changed files with 1117 additions and 809 deletions
+1
View File
@@ -0,0 +1 @@
"""Headless scan-engine core: importable without PyQt6 or any vendor SDK."""
+47
View File
@@ -0,0 +1,47 @@
"""Persisted user defaults (ports, scope IP, save directory).
One flat JSON file, one dataclass. ``save()`` always writes every field, so
a partial UI update can never silently drop another field's saved value
(which is exactly what the old dict-based writer did to helios_port).
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass, fields
from pathlib import Path
logger = logging.getLogger(__name__)
DEFAULTS_PATH = Path(__file__).resolve().parent.parent / "aui_defaults.json"
@dataclass
class ScanDefaults:
t3r_port: str = "/dev/ttyUSB0"
bbd_port: str = "/dev/ttyUSB1"
oscope_ip: str = "192.168.0.1"
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
helios_port: str = "/dev/ttyUSB2"
@classmethod
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
"""Load defaults, tolerating a missing/corrupt file and unknown keys."""
if path.exists():
try:
with open(path) as f:
data = json.load(f)
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known})
except (OSError, ValueError, TypeError) as e:
logger.warning("Could not load %s (%s); using fallback defaults", path, e)
inst = cls()
inst.save(path)
return inst
def save(self, path: Path = DEFAULTS_PATH) -> None:
try:
with open(path, "w") as f:
json.dump(asdict(self), f, indent=2)
except OSError as e:
logger.warning("Could not save defaults to %s: %s", path, e)
+199
View File
@@ -0,0 +1,199 @@
"""Scan geometry planning: angle sequences, rotated bounding boxes, travel
limits, and ETA math. Pure Python — no Qt, no hardware.
"""
from __future__ import annotations
import math
import time
from collections import deque
from dataclasses import dataclass, field
class ScanGeometryError(ValueError):
"""Scan geometry that cannot be executed (bad inputs or off-stage)."""
@dataclass(frozen=True)
class StageLimits:
"""Usable travel of the scanning stage in mm (MLS203-1)."""
x_min: float = 0.0
x_max: float = 110.0
y_min: float = 0.0
y_max: float = 75.0
DEFAULT_STAGE_LIMITS = StageLimits()
@dataclass
class AngleGeometry:
"""One rotation angle's scan extent — also the on-disk v6 geometry row."""
angle_deg: float
x_start: float
x_delta: float
n_frames: int
n_rows: int
y_positions: list[float] = field(default_factory=list)
@dataclass
class ScanPlan:
x_start_nominal: float
y_start_nominal: float
x_delta_nominal: float
y_delta_nominal: float
row_spacing: float
velocity_mm_s: float
laser_freq_hz: float
per_angle: list[AngleGeometry] = field(default_factory=list)
@property
def n_angles(self) -> int:
return len(self.per_angle)
@property
def angles(self) -> list[float]:
return [pa.angle_deg for pa in self.per_angle]
@property
def total_rows(self) -> int:
return sum(pa.n_rows for pa in self.per_angle)
def build_plan(x_start: float, y_start: float, x_delta: float, y_delta: float,
num_angles: int, row_spacing: float, *,
laser_freq_hz: float, velocity_mm_s: float,
rotation_sign: int = -1) -> ScanPlan:
"""Compute the per-angle scan geometry for a nominal ROI.
Each angle only needs to physically scan the bounding box of the nominal
(x_start, y_start, x_delta, y_delta) rectangle rotated by THAT angle --
not the worst case across all angles -- so the X extent (and therefore
points/row) and the row count are computed per angle.
"""
if x_delta <= 0:
raise ScanGeometryError("XD must be > 0")
if row_spacing <= 0:
raise ScanGeometryError("RowSpacing must be > 0")
if num_angles < 1:
raise ScanGeometryError("NumAngles must be ≥ 1")
# Signed so the recorded/commanded angle sequence reflects the GR
# stage's actual physical rotation direction.
if num_angles > 1:
angles = [rotation_sign * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
else:
angles = [0.0]
cx = x_start + x_delta / 2.0
cy = y_start + y_delta / 2.0
per_angle = []
for a in angles:
r = math.radians(a)
bb_w = abs(x_delta * math.cos(r)) + abs(y_delta * math.sin(r))
bb_h = abs(x_delta * math.sin(r)) + abs(y_delta * math.cos(r))
a_x_start = cx - bb_w / 2.0
a_y_start = cy - bb_h / 2.0
a_n_rows = max(1, round(bb_h / row_spacing) + 1) if bb_h > 0 else 1
a_n_frames = max(1, round(bb_w * laser_freq_hz / velocity_mm_s))
per_angle.append(AngleGeometry(
angle_deg=a,
x_start=a_x_start,
x_delta=bb_w,
n_frames=a_n_frames,
n_rows=a_n_rows,
y_positions=[a_y_start + i * row_spacing for i in range(a_n_rows)],
))
return ScanPlan(
x_start_nominal=x_start, y_start_nominal=y_start,
x_delta_nominal=x_delta, y_delta_nominal=y_delta,
row_spacing=row_spacing,
velocity_mm_s=velocity_mm_s, laser_freq_hz=laser_freq_hz,
per_angle=per_angle,
)
def validate_plan(plan: ScanPlan, ramp_mm: float, ramp_buffer_mm: float,
limits: StageLimits = DEFAULT_STAGE_LIMITS) -> None:
"""Raise ScanGeometryError if any angle's physical move leaves the stage.
The actual X move starts one ramp-length + buffer before x_start and ends
one ramp-length + buffer after x_start + x_delta, so the stage is at full
velocity across the whole data window.
"""
x_ramp_total = ramp_mm + ramp_buffer_mm
for pa in plan.per_angle:
x_move_start = pa.x_start - x_ramp_total
x_move_end = pa.x_start + pa.x_delta + x_ramp_total
if x_move_start < limits.x_min:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
f"is below the X axis minimum ({limits.x_min:g} mm). Reduce XD/YD or move XS/YS "
f"so every rotation angle's bounding box stays on-stage "
f"(SCAN_RAMP_MM={ramp_mm:.3f} + SCAN_RAMP_BUFFER_MM={ramp_buffer_mm:.3f})."
)
if x_move_end > limits.x_max:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
f"exceeds the X axis maximum ({limits.x_max:g} mm). Reduce XD/YD or move XS/YS "
f"so every rotation angle's bounding box stays on-stage."
)
y_min = min(pa.y_positions)
y_max = max(pa.y_positions)
if y_min < limits.y_min:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan Y range starts at {y_min:.3f} mm, "
f"below the Y axis minimum ({limits.y_min:g} mm)."
)
if y_max > limits.y_max:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan Y range ends at {y_max:.3f} mm, "
f"exceeds the Y axis maximum ({limits.y_max:g} mm)."
)
def format_eta(secs: float) -> str:
secs = max(0.0, secs)
m, s = divmod(int(secs), 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h}h {m:02d}m"
if m > 0:
return f"{m}m {s:02d}s"
return f"{s}s"
class EtaEstimator:
"""Rolling average of recent row durations → remaining-time estimate.
Duration history resets when the angle index changes, since different
angles have different row lengths.
"""
def __init__(self, window: int = 5):
self._durations: deque[float] = deque(maxlen=window)
self._row_start: float | None = None
self._last_angle_idx: int = -1
def reset(self) -> None:
self._durations.clear()
self._row_start = None
self._last_angle_idx = -1
def row_started(self, now: float | None = None) -> None:
self._row_start = time.monotonic() if now is None else now
def row_finished(self, angle_idx: int, now: float | None = None) -> None:
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
self._durations.clear()
self._last_angle_idx = angle_idx
if self._row_start is not None:
end = time.monotonic() if now is None else now
self._durations.append(end - self._row_start)
self._row_start = None
def eta_secs(self, rows_left: int) -> float | None:
if not self._durations or rows_left <= 0:
return None
return sum(self._durations) / len(self._durations) * rows_left
+330
View File
@@ -0,0 +1,330 @@
"""SRAS v6 binary scan-file format — the single implementation.
Full byte-level spec: scan_format.md. Summary:
header >4sBHfffffffIdBB magic ver n_angles xs_nom ys_nom xd_nom yd_nom
row_spacing velocity laser_freq spf sample_rate
bytes_per_sample n_channels
angle table n_angles × >f
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
row tables (ragged) per angle: n_rows × >f (y positions)
preambles n_channels × (>H length + utf-8 WFMOutpre string)
background block >I length + raw int8 CH1 average
waveform data angle-major, row-minor, channel-inner:
for each angle, for each row, for each channel,
n_frames × samples_per_frame × bytes_per_sample
Incomplete files are valid: the data block is one contiguous append-only
stream, so the readable prefix defines a single frontier past which nothing
has been written yet (see ``SrasFile.angle_status``).
"""
from __future__ import annotations
import mmap
import struct
from dataclasses import dataclass, field
from pathlib import Path
from typing import BinaryIO
import numpy as np
from core.scan_geometry import AngleGeometry, ScanPlan
MAGIC = b"SRAS"
VERSION = 6
HDR_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
# Oscilloscope channels recorded, in on-disk order.
SCAN_CHANNELS = [1, 3, 4]
STATUS_OK = "OK"
STATUS_TRUNCATED = "TRUNCATED"
STATUS_MISSING = "MISSING"
@dataclass
class ScanHeader:
"""The fixed v6 global header (everything but magic/version)."""
n_angles: int
x_start_nominal: float
y_start_nominal: float
x_delta_nominal: float
y_delta_nominal: float
row_spacing: float
velocity: float
laser_freq: float
samples_per_frame: int
sample_rate: float
bytes_per_sample: int
n_channels: int
@dataclass
class AngleStatus:
"""How much of one angle's declared data is actually on disk."""
index: int
angle_deg: float
n_rows: int # declared
row_bytes: int
data_offset: int
n_rows_available: int
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
@property
def complete(self) -> bool:
return self.status == STATUS_OK
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
sample_rate: float, preambles: list[str],
background_waveform: bytes) -> BinaryIO:
"""Create a new .sras file and write the v6 header + tables.
Returns an open binary file positioned at the start of the data block;
the caller appends waveform rows and must close it (try/finally).
"""
path.parent.mkdir(parents=True, exist_ok=True)
f = open(path, "wb")
f.write(struct.pack(
HDR_FMT, MAGIC, VERSION,
plan.n_angles,
plan.x_start_nominal, plan.y_start_nominal,
plan.x_delta_nominal, plan.y_delta_nominal,
plan.row_spacing,
plan.velocity_mm_s, plan.laser_freq_hz,
samples_per_frame,
sample_rate,
1, # bytes_per_sample: int8 from scope default
len(SCAN_CHANNELS),
))
f.write(struct.pack(f">{plan.n_angles}f", *plan.angles))
for pa in plan.per_angle:
f.write(struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows))
for pa in plan.per_angle:
f.write(struct.pack(f">{pa.n_rows}f", *pa.y_positions))
for p in preambles:
enc = p.encode("utf-8")
f.write(struct.pack(">H", len(enc)))
f.write(enc)
f.write(struct.pack(">I", len(background_waveform)))
f.write(background_waveform)
return f
@dataclass
class SrasFile:
"""Parsed v6 .sras file: header, tables, and lazy (memmap) data access.
Parsing reads only the header/tables — never the waveform block — so
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
read-only numpy views backed by a shared mmap; no data is copied until
the caller computes on it.
"""
path: Path
header: ScanHeader = field(init=False)
per_angle: list[AngleGeometry] = field(init=False)
preambles: list[str] = field(init=False)
preambles_raw: list[bytes] = field(init=False)
background: bytes = field(init=False)
data_start_offset: int = field(init=False)
file_size: int = field(init=False)
def __post_init__(self):
self.path = Path(self.path)
self._mmap: mmap.mmap | None = None
self._parse()
def _parse(self):
self.file_size = self.path.stat().st_size
with open(self.path, "rb") as f:
raw = f.read(HDR_SIZE)
if len(raw) < HDR_SIZE:
raise ValueError(f"{self.path.name}: file too short to contain a valid header")
(magic, version, n_angles, x_start_nominal, y_start_nominal,
x_delta_nominal, y_delta_nominal, row_spacing, velocity, laser_freq,
samples_per_frame, sample_rate, bytes_per_sample,
n_channels) = struct.unpack(HDR_FMT, raw)
if magic != MAGIC:
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
if version != VERSION:
raise ValueError(
f"{self.path.name}: unsupported SRAS format version {version} "
f"(only version {VERSION} is supported)"
)
self.header = ScanHeader(
n_angles=n_angles,
x_start_nominal=x_start_nominal, y_start_nominal=y_start_nominal,
x_delta_nominal=x_delta_nominal, y_delta_nominal=y_delta_nominal,
row_spacing=row_spacing, velocity=velocity, laser_freq=laser_freq,
samples_per_frame=samples_per_frame, sample_rate=sample_rate,
bytes_per_sample=bytes_per_sample, n_channels=n_channels,
)
angles = struct.unpack(f">{n_angles}f", f.read(4 * n_angles))
self.per_angle = []
for a in angles:
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
self.per_angle.append(AngleGeometry(
angle_deg=a, x_start=x_start, x_delta=x_delta,
n_frames=n_frames, n_rows=n_rows,
))
for pa in self.per_angle:
pa.y_positions = list(struct.unpack(f">{pa.n_rows}f", f.read(4 * pa.n_rows)))
self.preambles_raw = []
for _ in range(n_channels):
(plen,) = struct.unpack(">H", f.read(2))
self.preambles_raw.append(f.read(plen))
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
(n_bg,) = struct.unpack(">I", f.read(4))
self.background = f.read(n_bg)
self.data_start_offset = f.tell()
# ── Frontier / truncation analysis ───────────────────────────────────────
def row_bytes(self, angle_idx: int) -> int:
pa = self.per_angle[angle_idx]
return (self.header.n_channels * pa.n_frames
* self.header.samples_per_frame * self.header.bytes_per_sample)
def angle_status(self) -> list[AngleStatus]:
"""Walk declared per-row byte counts against the actual file size.
Because the data is one contiguous append-only stream, once an angle
is found short every later angle is necessarily absent too — there is
a single frontier past which nothing has been written yet.
"""
statuses = []
cursor = self.data_start_offset
frontier_seen = False
for ai, pa in enumerate(self.per_angle):
row_bytes = self.row_bytes(ai)
data_offset = cursor
if frontier_seen:
n_rows_available = 0
status = STATUS_MISSING
else:
declared_bytes = row_bytes * pa.n_rows
if row_bytes > 0 and cursor + declared_bytes <= self.file_size:
n_rows_available = pa.n_rows
status = STATUS_OK
cursor += declared_bytes
else:
remaining = max(0, self.file_size - cursor)
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
frontier_seen = True
statuses.append(AngleStatus(
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
row_bytes=row_bytes, data_offset=data_offset,
n_rows_available=n_rows_available, status=status,
))
return statuses
def angle_data_offset(self, angle_idx: int) -> int:
offset = self.data_start_offset
for ai in range(angle_idx):
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
return offset
# ── Lazy data access ─────────────────────────────────────────────────────
def _ensure_mmap(self) -> mmap.mmap:
if self._mmap is None:
# The mapping stays valid after the file object is closed, so
# don't hold the descriptor open for the (long) life of a viewer
# session.
with open(self.path, "rb") as f:
self._mmap = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
return self._mmap
def _dtype(self) -> np.dtype:
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
"""Read-only view of one angle's data block, shape
(n_rows, n_channels, n_frames, samples_per_frame).
``n_rows`` limits the view to the rows actually on disk (pass
``AngleStatus.n_rows_available`` for truncated files); default is the
declared row count.
"""
pa = self.per_angle[angle_idx]
h = self.header
if n_rows is None:
n_rows = pa.n_rows
start = self.angle_data_offset(angle_idx)
count = n_rows * h.n_channels * pa.n_frames * h.samples_per_frame
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
count=count, offset=start)
arr = arr.reshape(n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
arr.flags.writeable = False
return arr
def load_row(self, angle_idx: int, row: int, channel_idx: int) -> np.ndarray:
"""Read-only view of one row/channel, shape (n_frames, samples_per_frame)."""
pa = self.per_angle[angle_idx]
h = self.header
ch_bytes = pa.n_frames * h.samples_per_frame * h.bytes_per_sample
start = (self.angle_data_offset(angle_idx) + row * self.row_bytes(angle_idx)
+ channel_idx * ch_bytes)
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
count=pa.n_frames * h.samples_per_frame, offset=start)
arr = arr.reshape(pa.n_frames, h.samples_per_frame)
arr.flags.writeable = False
return arr
def close(self):
"""Release this file's hold on the mapping.
Views handed out earlier stay valid — they keep the mapping alive
until they are garbage-collected, at which point the OS frees it.
"""
if self._mmap is not None:
try:
self._mmap.close()
except BufferError:
pass # live numpy views still reference the buffer
self._mmap = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# ── Axes helpers (viewer conveniences, derived from header fields) ───────
def pixel_pitch_x_mm(self) -> float:
"""Distance between adjacent frames along X."""
return self.header.velocity / self.header.laser_freq
def x_axis_mm(self, angle_idx: int) -> np.ndarray:
pa = self.per_angle[angle_idx]
return pa.x_start + np.arange(pa.n_frames) * self.pixel_pitch_x_mm()
def time_axis_ns(self) -> np.ndarray:
h = self.header
return np.arange(h.samples_per_frame) / h.sample_rate * 1e9
def freq_axis_mhz(self, nfft: int) -> np.ndarray:
return np.fft.rfftfreq(nfft, d=1.0 / self.header.sample_rate) / 1e6
def plan_from_header(sras: SrasFile) -> ScanPlan:
"""Reconstruct the ScanPlan a file was written with (for resume)."""
h = sras.header
return ScanPlan(
x_start_nominal=h.x_start_nominal, y_start_nominal=h.y_start_nominal,
x_delta_nominal=h.x_delta_nominal, y_delta_nominal=h.y_delta_nominal,
row_spacing=h.row_spacing,
velocity_mm_s=h.velocity, laser_freq_hz=h.laser_freq,
per_angle=list(sras.per_angle),
)