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
+33 -93
View File
@@ -22,12 +22,9 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
BLOB_MAGIC = b"SRAS"
BLOB_VERSION = 6
HDR_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core.sras_format import GEOM_FMT, HDR_FMT, MAGIC, VERSION as BLOB_VERSION, SrasFile
@dataclass
@@ -58,94 +55,37 @@ class SrasScanFile:
self._parse()
def _parse(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 for a valid header")
(magic, version, n_angles, x_start_nom, y_start_nom, x_delta_nom,
y_delta_nom, row_spacing, velocity, laser_freq, samples_per_frame,
sample_rate, bytes_per_sample, n_channels) = struct.unpack(HDR_FMT, raw)
sras = SrasFile(self.path)
h = sras.header
self.x_start_nominal = h.x_start_nominal
self.y_start_nominal = h.y_start_nominal
self.x_delta_nominal = h.x_delta_nominal
self.y_delta_nominal = h.y_delta_nominal
self.row_spacing_mm = h.row_spacing
self.velocity_mm_s = h.velocity
self.laser_freq_hz = h.laser_freq
self.samples_per_frame = h.samples_per_frame
self.sample_rate_hz = h.sample_rate
self.bytes_per_sample = h.bytes_per_sample
self.n_channels = h.n_channels
self.preambles_raw = sras.preambles_raw
self.background_raw = sras.background
self.data_start_offset = sras.data_start_offset
self.file_size = sras.file_size
if magic != BLOB_MAGIC:
raise ValueError(f"{self.path.name}: bad magic {magic!r}, not a .sras file")
if version != BLOB_VERSION:
raise ValueError(
f"{self.path.name}: unsupported format version {version} "
f"(this tool only supports v{BLOB_VERSION})")
self.x_start_nominal = x_start_nom
self.y_start_nominal = y_start_nom
self.x_delta_nominal = x_delta_nom
self.y_delta_nominal = y_delta_nom
self.row_spacing_mm = row_spacing
self.velocity_mm_s = velocity
self.laser_freq_hz = laser_freq
self.samples_per_frame = samples_per_frame
self.sample_rate_hz = sample_rate
self.bytes_per_sample = bytes_per_sample
self.n_channels = n_channels
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
geoms = []
for _ in range(n_angles):
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
geoms.append((x_start, x_delta, n_frames, n_rows))
row_tables = []
for (_, _, _, n_rows) in geoms:
row_tables.append(list(struct.unpack(f">{n_rows}f", f.read(4 * n_rows))))
preambles_raw = []
for _ in range(n_channels):
(plen,) = struct.unpack(">H", f.read(2))
preambles_raw.append(f.read(plen))
self.preambles_raw = preambles_raw
(n_bg,) = struct.unpack(">I", f.read(4))
self.background_raw = f.read(n_bg)
data_start_offset = f.tell()
# Build angle entries and compute what's actually present on disk,
# in case the file was closed early (aborted scan) — see scan_format.md's
# "Incomplete files" note. Waveform data is angle-major/row-minor with a
# fixed per-row byte count within an angle, so we walk cumulative offsets.
self.angles = []
cursor = data_start_offset
truncated_seen = False
for i, (angle, (x_start, x_delta, n_frames, n_rows)) in enumerate(zip(angles, geoms)):
row_bytes = n_channels * n_frames * samples_per_frame * bytes_per_sample
entry = AngleEntry(
index=i, angle_deg=angle, x_start=x_start, x_delta=x_delta,
n_frames=n_frames, n_rows_declared=n_rows,
y_positions=row_tables[i], row_bytes=row_bytes,
data_offset=cursor,
self.angles = [
AngleEntry(
index=st.index, angle_deg=st.angle_deg,
x_start=pa.x_start, x_delta=pa.x_delta,
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
y_positions=pa.y_positions, row_bytes=st.row_bytes,
data_offset=st.data_offset,
n_rows_available=st.n_rows_available,
data_size_available=st.n_rows_available * st.row_bytes,
complete=st.complete,
)
if truncated_seen:
entry.n_rows_available = 0
entry.data_size_available = 0
entry.complete = False
else:
declared_bytes = row_bytes * n_rows
if row_bytes > 0 and cursor + declared_bytes <= file_size:
entry.n_rows_available = n_rows
entry.data_size_available = declared_bytes
entry.complete = True
cursor += declared_bytes
else:
remaining = max(0, file_size - cursor)
n_complete = remaining // row_bytes if row_bytes > 0 else 0
entry.n_rows_available = n_complete
entry.data_size_available = n_complete * row_bytes
entry.complete = (n_complete == n_rows)
cursor += entry.data_size_available
truncated_seen = True
self.angles.append(entry)
self.data_start_offset = data_start_offset
self.file_size = file_size
for pa, st in zip(sras.per_angle, sras.angle_status(), strict=True)
]
def get(self, index: int) -> AngleEntry:
return self.angles[index]
@@ -165,7 +105,7 @@ def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
selected = [sf.get(i) for i in indices]
header = struct.pack(
HDR_FMT, BLOB_MAGIC, BLOB_VERSION, len(selected),
HDR_FMT, MAGIC, BLOB_VERSION, len(selected),
sf.x_start_nominal, sf.y_start_nominal,
sf.x_delta_nominal, sf.y_delta_nominal,
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,