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:
+33
-93
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user