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
+95 -438
View File
@@ -5,8 +5,6 @@ Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic
and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
"""
import json
import math
import queue
import struct
import sys
@@ -30,6 +28,11 @@ from matplotlib.figure import Figure
ROOT = Path(__file__).parent
sys.path.insert(0, str(ROOT))
from core.config import ScanDefaults
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta, validate_plan
from core.sras_format import (
SCAN_CHANNELS, SrasFile, create_scan_file, plan_from_header,
)
from hardware.helios_laser import HeliosLaser
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
from hardware.t3r_driver import T3RDriver
@@ -39,44 +42,7 @@ from hardware.uc480_camera import (
)
from t3r_control_panel import T3RControlPanel
# ── Config defaults ──────────────────────────────────────────────────────────
_AUI_DEFAULTS_PATH = ROOT / "aui_defaults.json"
_AUI_DEFAULTS_FALLBACK = {
"t3r_port": "/dev/ttyUSB0",
"bbd_port": "/dev/ttyUSB1",
"oscope_ip": "192.168.0.1",
"laser_freq_hz": 2000,
"save_dir": str(ROOT / "scans"),
"helios_port": "/dev/ttyUSB2",
}
def _load_aui_defaults() -> dict:
if _AUI_DEFAULTS_PATH.exists():
try:
with open(_AUI_DEFAULTS_PATH) as f:
return {**_AUI_DEFAULTS_FALLBACK, **json.load(f)}
except Exception:
pass
# File absent or unreadable — write fresh copy and return fallback
_save_aui_defaults(_AUI_DEFAULTS_FALLBACK)
return dict(_AUI_DEFAULTS_FALLBACK)
def _save_aui_defaults(d: dict) -> None:
try:
with open(_AUI_DEFAULTS_PATH, "w") as f:
json.dump(d, f, indent=2)
except Exception as e:
print(f"[AUI] Could not save defaults: {e}")
_aui = _load_aui_defaults()
DEFAULT_T3R_PORT = _aui["t3r_port"]
DEFAULT_BBD_PORT = _aui["bbd_port"]
DEFAULT_OSCOPE_IP = _aui["oscope_ip"]
DEFAULT_LASER_FREQ_HZ = float(_aui["laser_freq_hz"])
DEFAULT_SAVE_DIR = _aui["save_dir"]
DEFAULT_HELIOS_PORT = _aui.get("helios_port", "/dev/ttyUSB2")
DEFAULTS = ScanDefaults.load()
# ── Scan constants ───────────────────────────────────────────────────────────
@@ -155,202 +121,6 @@ class DCBiasImageWidget(FigureCanvas):
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# ── Binary blob format ───────────────────────────────────────────────────────
# Full spec: scan_format.md
BLOB_MAGIC = b"SRAS"
BLOB_VERSION = 6
SCAN_CHANNELS = [1, 3, 4] # oscilloscope channels recorded, in order
# v6: each angle scans only the bounding box of the nominal ROI rotated by
# that angle, so x_start/x_delta/n_frames/n_rows all vary per angle and are
# no longer in the fixed header — see the per-angle geometry table.
# ">4s B H f f f f f f f I d B B"
# magic ver n_angles xs_nom ys_nom xd_nom yd_nom row_spacing vel freq spf sr bps n_channels
BLOB_HDR_FMT = ">4sBHfffffffIdBB"
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"
def _open_scan_file(path: Path, angles: list[float], per_angle: list[dict],
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,
preambles: list[str],
background_waveform: bytes):
"""Create a new SRAS file and write the v6 global header + per-angle tables.
Returns an open binary file object positioned at the start of the data
block. The caller must close it (use in a try/finally block).
Each angle only scans the bounding box of the nominal
(x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal) ROI
rotated by that angle, so x_start, x_delta, n_frames (points/row) and
n_rows all vary per angle. `per_angle` holds one dict per angle (same
order as `angles`) with keys "x_start", "x_delta", "n_frames", "n_rows",
"y_positions".
Data is written by appending waveforms in angle-major, row-minor,
channel-inner order: for each angle, for each of its rows, for each
channel in SCAN_CHANNELS order, that angle's n_frames waveforms are
written sequentially.
preambles: one WFMOutpre string per channel (same order as SCAN_CHANNELS),
written as length-prefixed UTF-8 blocks after the row table.
background_waveform: raw int8 bytes of a 64-sample averaged CH1 waveform
captured with the Helios laser enabled and Genesis laser
disabled, written as uint32 length prefix followed by
the data.
"""
path.parent.mkdir(parents=True, exist_ok=True)
n_angles = len(angles)
f = open(path, "wb")
header = struct.pack(
BLOB_HDR_FMT,
BLOB_MAGIC, BLOB_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,
1, # bytes_per_sample: int8 from scope default
len(SCAN_CHANNELS), # n_channels
)
f.write(header)
f.write(struct.pack(f">{n_angles}f", *angles))
# Per-angle geometry table: x_start, x_delta, n_frames, n_rows.
for pa in per_angle:
f.write(struct.pack(">ffIH", pa["x_start"], pa["x_delta"], pa["n_frames"], pa["n_rows"]))
# Ragged row table: each angle's y_positions, concatenated in order.
for pa in 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)
# v4+: background waveform block — CH1 64-sample average (Helios ON, Genesis OFF)
f.write(struct.pack(">I", len(background_waveform)))
f.write(background_waveform)
return f
def _read_sras_header(path: Path) -> dict:
"""Parse a v6 .sras file's header, per-angle geometry, and row tables.
Does not read the (potentially huge) waveform data block itself — only
enough to know each angle's geometry and the byte offset at which the
waveform data begins.
"""
with open(path, "rb") as f:
hdr_size = struct.calcsize(BLOB_HDR_FMT)
raw = f.read(hdr_size)
if len(raw) < hdr_size:
raise ValueError(f"{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(BLOB_HDR_FMT, raw)
if magic != BLOB_MAGIC:
raise ValueError(f"{path.name}: not a valid SRAS file (bad magic)")
if version != BLOB_VERSION:
raise ValueError(
f"{path.name}: unsupported SRAS format version {version} "
f"(this app can only resume version {BLOB_VERSION} files)"
)
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
per_angle = []
for a in angles:
x_start, x_delta, n_frames, n_rows = struct.unpack(">ffIH", f.read(14))
per_angle.append({
"angle": a, "x_start": x_start, "x_delta": x_delta,
"n_frames": n_frames, "n_rows": n_rows,
})
for pa in per_angle:
n = pa["n_rows"]
pa["y_positions"] = list(struct.unpack(f">{n}f", f.read(4 * n)))
for _ in range(n_channels):
(plen,) = struct.unpack(">H", f.read(2))
f.read(plen)
(n_bg,) = struct.unpack(">I", f.read(4))
f.read(n_bg)
data_start_offset = f.tell()
return {
"version": version, "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": angles, "per_angle": per_angle,
"data_start_offset": data_start_offset,
}
def _compute_angle_status(path: Path, info: dict) -> list[dict]:
"""Figure out, per angle, how much waveform data is actually present on
disk versus declared in the per-angle geometry table.
Waveform data is written angle-major, row-minor with a fixed number of
bytes per row (derivable from the per-angle geometry table), so this
walks the expected per-row byte counts against the actual file size.
Because the data is one contiguous stream, once an angle is found to be
short every angle after it is necessarily entirely absent too — there is
always a single "frontier" past which nothing has been written yet.
Returns a list of dicts, one per angle, each with: index, angle_deg,
n_rows (declared), row_bytes, data_offset (byte offset where this
angle's data starts), n_rows_available, and status
("OK" / "TRUNCATED" / "MISSING").
"""
actual_size = path.stat().st_size
cursor = info["data_start_offset"]
statuses = []
frontier_seen = False
for ai, pa in enumerate(info["per_angle"]):
row_bytes = info["n_channels"] * pa["n_frames"] * info["samples_per_frame"] * info["bytes_per_sample"]
n_rows = pa["n_rows"]
data_offset = cursor
if frontier_seen:
n_rows_available = 0
status = "MISSING"
else:
declared_bytes = row_bytes * n_rows
if row_bytes > 0 and cursor + declared_bytes <= actual_size:
n_rows_available = n_rows
status = "OK"
cursor += declared_bytes
else:
remaining = max(0, actual_size - cursor)
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
status = "MISSING" if n_rows_available == 0 else "TRUNCATED"
frontier_seen = True
statuses.append({
"index": ai, "angle_deg": pa["angle"], "n_rows": n_rows,
"row_bytes": row_bytes, "data_offset": data_offset,
"n_rows_available": n_rows_available, "status": status,
})
return statuses
# ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(QObject):
@@ -969,9 +739,8 @@ class HeliosWindow(QWidget):
self.helios_connect_toggle.setText("Connecting…")
self.helios_connect_toggle.setEnabled(False)
port = self.helios_port_edit.text().strip()
d = _load_aui_defaults()
d["helios_port"] = port
_save_aui_defaults(d)
DEFAULTS.helios_port = port
DEFAULTS.save()
self._worker.queue_connect(port)
else:
self._poll_timer.stop()
@@ -1068,7 +837,7 @@ class ResumeAngleDialog(QDialog):
completed angle that's known to be bad).
"""
def __init__(self, statuses: list[dict], parent: QWidget | None = None):
def __init__(self, statuses, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Select Angles to Rescan")
self.resize(480, 420)
@@ -1082,13 +851,13 @@ class ResumeAngleDialog(QDialog):
self.list_widget = QListWidget(self)
for s in statuses:
label = (
f"Angle {s['index'] + 1}/{len(statuses)} — {s['angle_deg']:.2f}° — "
f"{s['n_rows_available']}/{s['n_rows']} rows — {s['status']}"
f"Angle {s.index + 1}/{len(statuses)} — {s.angle_deg:.2f}° — "
f"{s.n_rows_available}/{s.n_rows} rows — {s.status}"
)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s["index"])
item.setData(Qt.ItemDataRole.UserRole, s.index)
item.setCheckState(
Qt.CheckState.Checked if s["status"] != "OK" else Qt.CheckState.Unchecked
Qt.CheckState.Unchecked if s.complete else Qt.CheckState.Checked
)
self.list_widget.addItem(item)
layout.addWidget(self.list_widget)
@@ -1142,9 +911,7 @@ class ScanProgressWindow(QWidget):
self.verticalLayout.insertWidget(insert_pos + 1, self.bias_image_widget)
self.verticalLayout.setStretch(insert_pos + 1, 1)
self._row_start_time: float | None = None
self._row_durations: list[float] = []
self._last_angle_idx: int = -1
self._eta = EtaEstimator()
def _on_pause_btn(self, checked: bool):
# Pause takes effect at the next row boundary; show the intermediate
@@ -1163,7 +930,7 @@ class ScanProgressWindow(QWidget):
self.pause_btn.blockSignals(False)
def on_row_started(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
self._row_start_time = time.monotonic()
self._eta.row_started()
def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
n_rows = max(1, n_rows)
@@ -1173,31 +940,20 @@ class ScanProgressWindow(QWidget):
self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}")
if row == 0:
self._row_durations = []
self._row_start_time = None
self._last_angle_idx = -1
self._eta.reset()
self.current_scan_progbar.setMaximum(n_rows)
self.current_scan_progbar.setValue(0)
self.current_scan_progbar.setFormat("Row %v/%m")
else:
# Reset duration history when the angle changes
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
self._row_durations = []
self._last_angle_idx = angle_idx
if self._row_start_time is not None:
self._row_durations.append(time.monotonic() - self._row_start_time)
self._row_start_time = None
self._eta.row_finished(angle_idx)
self.current_scan_progbar.setMaximum(n_rows)
self.current_scan_progbar.setValue(row)
rows_left = n_rows - row
if self._row_durations and rows_left > 0:
recent = self._row_durations[-5:]
avg = sum(recent) / len(recent)
eta_str = _format_eta(avg * rows_left)
self.current_scan_progbar.setFormat(f"Row %v/%m — ETA: {eta_str}")
eta_secs = self._eta.eta_secs(rows_left)
if eta_secs is not None:
self.current_scan_progbar.setFormat(
f"Row %v/%m — ETA: {format_eta(eta_secs)}")
elif rows_left == 0:
self.current_scan_progbar.setFormat("Row %v/%m — Done")
else:
@@ -1230,13 +986,15 @@ class ScanWorker(QObject):
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | None,
oscope: OscopeWorker, params: dict,
resume_info: dict | None = None):
oscope: OscopeWorker, plan: ScanPlan, prefix: str,
save_dir: str, resume_info: dict | None = None):
super().__init__()
self._bbd = bbd
self._t3r = t3r
self._oscope = oscope
self._params = params
self._plan = plan
self._prefix = prefix
self._save_dir = save_dir
self._resume_info = resume_info
self._abort = False
self._prompt_event = threading.Event()
@@ -1290,66 +1048,23 @@ class ScanWorker(QObject):
self.failed.emit(str(e))
def _run_scan(self):
p = self._params
x_start_nominal = p["x_start_nominal"]
y_start_nominal = p["y_start_nominal"]
x_delta_nominal = p["x_delta_nominal"]
y_delta_nominal = p["y_delta_nominal"]
n_angles = max(1, p["num_angles"])
row_spacing = p["row_spacing"]
prefix = p["prefix"]
save_dir = Path(p["save_dir"])
plan = self._plan
per_angle = plan.per_angle
n_angles = plan.n_angles
save_dir = Path(self._save_dir)
# Each angle's bounding box (x_start, x_delta, n_frames, n_rows,
# y_positions) was pre-computed in _build_scan_params() from the
# nominal ROI rotated by *that* angle only, so every angle scans the
# minimum area needed to cover the ROI at its own rotation instead of
# the worst case across all angles.
per_angle = p["per_angle"]
angles = [pa["angle"] for pa in per_angle]
# ── Validate scan geometry against stage travel limits ─────────────────
# X axis: 0–110 mm (bbd20x.py). The actual move starts one ramp-length
# + buffer before x_start and ends one ramp-length + buffer after
# x_start + x_delta.
# 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 = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
for pa in per_angle:
a_x_start, a_x_delta = pa["x_start"], pa["x_delta"]
x_move_start = a_x_start - _x_ramp_total
x_move_end = a_x_start + a_x_delta + _x_ramp_total
if x_move_start < 0.0:
raise ValueError(
f"Angle {pa['angle']:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
f"is below the X axis minimum (0 mm). Reduce XD/YD or move XS/YS "
f"so every rotation angle's bounding box stays on-stage "
f"(SCAN_RAMP_MM={SCAN_RAMP_MM:.3f} + SCAN_RAMP_BUFFER_MM={SCAN_RAMP_BUFFER_MM:.3f})."
)
if x_move_end > 110.0:
raise ValueError(
f"Angle {pa['angle']:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
f"exceeds the X axis maximum (110 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 < 0.0:
raise ValueError(
f"Angle {pa['angle']:.1f}°: scan Y range starts at {y_min:.3f} mm, "
f"below the Y axis minimum (0 mm)."
)
if y_max > 75.0:
raise ValueError(
f"Angle {pa['angle']:.1f}°: scan Y range ends at {y_max:.3f} mm, "
f"exceeds the Y axis maximum (75 mm)."
)
validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM)
total_rows = sum(pa["n_rows"] for pa in per_angle)
geometry_summary = ", ".join(
f"{pa['angle']:.1f}°: {pa['n_rows']} row(s) × {pa['n_frames']} pts/row"
f"{pa.angle_deg:.1f}°: {pa.n_rows} row(s) × {pa.n_frames} pts/row"
for pa in per_angle
)
self.status_msg.emit(
f"Scan geometry: {n_angles} angle(s), {total_rows} row(s) total "
f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total "
f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}"
)
self.started.emit()
@@ -1530,12 +1245,9 @@ class ScanWorker(QObject):
)
else:
targets_by_ai = None
fname = save_dir / f"{prefix}.sras"
scan_file = _open_scan_file(
fname, angles, per_angle,
x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal,
row_spacing, SCAN_VELOCITY_MM_S, LASER_FREQ_HZ,
samples_per_frame, SCOPE_SAMPLE_RATE,
fname = save_dir / f"{self._prefix}.sras"
scan_file = create_scan_file(
fname, plan, samples_per_frame, SCOPE_SAMPLE_RATE,
preambles, background_waveform,
)
@@ -1554,12 +1266,12 @@ class ScanWorker(QObject):
if self._abort:
break
angle = pa["angle"]
a_x_start = pa["x_start"]
a_x_delta = pa["x_delta"]
a_n_rows = pa["n_rows"]
a_n_frames = pa["n_frames"]
y_positions = pa["y_positions"]
angle = pa.angle_deg
a_x_start = pa.x_start
a_x_delta = pa.x_delta
a_n_rows = pa.n_rows
a_n_frames = pa.n_frames
y_positions = pa.y_positions
if targets_by_ai is not None:
# Interior angles may already have valid data on either
@@ -1733,10 +1445,10 @@ class MainWindow(QMainWindow):
# ── UI initialisation ─────────────────────────────────────────────────────
def _init_ui_fields(self):
self.t3r_comport_edit.setText(DEFAULT_T3R_PORT)
self.bbd202_comport_edit.setText(DEFAULT_BBD_PORT)
self.oscope_ip_edit.setText(DEFAULT_OSCOPE_IP)
self._helios_win.helios_port_edit.setText(DEFAULT_HELIOS_PORT)
self.t3r_comport_edit.setText(DEFAULTS.t3r_port)
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
self.x_start_edit.setText("10.000")
@@ -1746,7 +1458,7 @@ class MainWindow(QMainWindow):
self.num_angles_edit.setText("1")
self.row_spacing_edit.setText("0.250")
self.scan_prefix_edit.setText("scan")
self.scan_save_dir_edit.setText(DEFAULT_SAVE_DIR)
self.scan_save_dir_edit.setText(DEFAULTS.save_dir)
# Hide the old T3R manual controls; the connect toggle becomes the panel button.
for w in (
@@ -1960,13 +1672,11 @@ class MainWindow(QMainWindow):
# ── Persist defaults ──────────────────────────────────────────────────────
def _persist_defaults(self):
_save_aui_defaults({
"t3r_port": self.t3r_comport_edit.text().strip(),
"bbd_port": self.bbd202_comport_edit.text().strip(),
"oscope_ip": self.oscope_ip_edit.text().strip(),
"laser_freq_hz": DEFAULT_LASER_FREQ_HZ,
"save_dir": self.scan_save_dir_edit.text().strip(),
})
DEFAULTS.t3r_port = self.t3r_comport_edit.text().strip()
DEFAULTS.bbd_port = self.bbd202_comport_edit.text().strip()
DEFAULTS.oscope_ip = self.oscope_ip_edit.text().strip()
DEFAULTS.save_dir = self.scan_save_dir_edit.text().strip()
DEFAULTS.save()
# ── Camera toggle ─────────────────────────────────────────────────────────
@@ -2000,11 +1710,11 @@ class MainWindow(QMainWindow):
def _on_start_scan(self):
try:
params = self._build_scan_params()
plan, prefix, save_dir = self._build_scan_plan()
except ValueError as e:
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
return
self._launch_scan_worker(params)
self._launch_scan_worker(plan, prefix, save_dir)
def _on_resume_scan_action(self):
if self._scan_thread is not None and self._scan_thread.isRunning():
@@ -2014,7 +1724,7 @@ class MainWindow(QMainWindow):
)
return
start_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
start_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
path_str, _ = QFileDialog.getOpenFileName(
self, "Select Scan File to Resume", start_dir, "SRAS Scan Files (*.sras)"
)
@@ -2023,16 +1733,17 @@ class MainWindow(QMainWindow):
path = Path(path_str)
try:
info = _read_sras_header(path)
statuses = _compute_angle_status(path, info)
sras = SrasFile(path)
statuses = sras.angle_status()
except (ValueError, struct.error, OSError) as e:
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
return
if (info["bytes_per_sample"] != 1 or info["n_channels"] != len(SCAN_CHANNELS)
or abs(info["velocity"] - SCAN_VELOCITY_MM_S) > 1e-3
or abs(info["laser_freq"] - LASER_FREQ_HZ) > 1e-3
or abs(info["sample_rate"] - SCOPE_SAMPLE_RATE) > 1.0):
hdr = sras.header
if (hdr.bytes_per_sample != 1 or hdr.n_channels != len(SCAN_CHANNELS)
or abs(hdr.velocity - SCAN_VELOCITY_MM_S) > 1e-3
or abs(hdr.laser_freq - LASER_FREQ_HZ) > 1e-3
or abs(hdr.sample_rate - SCOPE_SAMPLE_RATE) > 1.0):
QMessageBox.warning(
self, "Cannot Resume Scan",
f"{path.name} was recorded with acquisition settings that don't "
@@ -2049,11 +1760,12 @@ class MainWindow(QMainWindow):
if not selected:
return
frontier_idx = next((s["index"] for s in statuses if s["status"] != "OK"), len(statuses))
# Data is one contiguous stream, so angles past the frontier (the
# first incomplete one) can't be skipped over — back-fill the range.
frontier_idx = next((s.index for s in statuses if not s.complete), len(statuses))
at_or_past_frontier = {i for i in selected if i >= frontier_idx}
if at_or_past_frontier:
required = set(range(frontier_idx, max(at_or_past_frontier) + 1))
final = selected | required
final = selected | set(range(frontier_idx, max(at_or_past_frontier) + 1))
else:
final = selected
@@ -2069,10 +1781,10 @@ class MainWindow(QMainWindow):
targets = [
{
"ai": s["index"], "data_offset": s["data_offset"],
"n_rows": s["n_rows"], "angle_deg": s["angle_deg"],
"ai": s.index, "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
]
total_rows = sum(t["n_rows"] for t in targets)
angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets)
@@ -2087,28 +1799,20 @@ class MainWindow(QMainWindow):
if reply != QMessageBox.StandardButton.Yes:
return
params = {
"x_start_nominal": info["x_start_nominal"], "y_start_nominal": info["y_start_nominal"],
"x_delta_nominal": info["x_delta_nominal"], "y_delta_nominal": info["y_delta_nominal"],
"num_angles": info["n_angles"],
"row_spacing": info["row_spacing"],
"laser_freq": info["laser_freq"],
"prefix": path.stem,
"save_dir": str(path.parent),
"per_angle": info["per_angle"],
}
plan = plan_from_header(sras)
resume_info = {
"path": path,
"targets": targets,
"samples_per_frame": info["samples_per_frame"],
"samples_per_frame": hdr.samples_per_frame,
}
self._launch_scan_worker(params, resume_info)
self._launch_scan_worker(plan, path.stem, str(path.parent), resume_info)
def _launch_scan_worker(self, params: dict, resume_info: dict | None = None):
# ── Launch scan worker ────────────────────────────────────────────────
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
resume_info: dict | None = None):
self._scan_thread = QThread(self)
self._scan_worker = ScanWorker(
self._bbd_worker, self._t3r_driver, self._oscope_worker, params, resume_info
self._bbd_worker, self._t3r_driver, self._oscope_worker,
plan, prefix, save_dir, resume_info
)
self._scan_worker.moveToThread(self._scan_thread)
self._scan_thread.started.connect(self._scan_worker.run)
@@ -2124,85 +1828,38 @@ class MainWindow(QMainWindow):
self.start_scan_btn.setEnabled(False)
self._scan_progress.reset_pause_btn()
if resume_info is None:
self._scan_progress.update_progress(0, params["per_angle"][0]["n_rows"], 0, params["num_angles"])
self._scan_progress.update_progress(0, plan.per_angle[0].n_rows, 0, plan.n_angles)
else:
ai0 = resume_info["targets"][0]["ai"]
self._scan_progress.update_progress(
0, params["per_angle"][ai0]["n_rows"], ai0 + 1, params["num_angles"]
0, plan.per_angle[ai0].n_rows, ai0 + 1, plan.n_angles
)
self._scan_progress.show()
self._scan_thread.start()
def _build_scan_params(self) -> dict:
def _build_scan_plan(self) -> tuple[ScanPlan, str, str]:
def _f(w, label):
try:
return float(w.text())
except ValueError:
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}")
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}") from None
def _i(w, label):
try:
return int(w.text())
except ValueError:
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}")
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}") from None
x_start = _f(self.x_start_edit, "XS")
y_start = _f(self.y_start_edit, "YS")
x_delta = _f(self.x_delta_edit, "XD")
y_delta = _f(self.y_delta_edit, "YD")
num_angles = _i(self.num_angles_edit, "NumAngles")
row_spacing = _f(self.row_spacing_edit, "RowSpacing")
prefix = self.scan_prefix_edit.text().strip() or "scan"
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
if x_delta <= 0:
raise ValueError("XD must be > 0")
if row_spacing <= 0:
raise ValueError("RowSpacing must be > 0")
if num_angles < 1:
raise ValueError("NumAngles must be ≥ 1")
# Signed so the recorded/commanded angle sequence reflects the GR
# stage's actual physical rotation direction (see GR_ROTATION_SIGN).
if num_angles > 1:
angles = [GR_ROTATION_SIGN * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
else:
angles = [0.0]
# 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 instead of once globally.
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 / SCAN_VELOCITY_MM_S))
per_angle.append({
"angle": 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 {
"x_start_nominal": x_start, "y_start_nominal": y_start,
"x_delta_nominal": x_delta, "y_delta_nominal": y_delta,
"num_angles": num_angles,
"row_spacing": row_spacing,
"laser_freq": DEFAULT_LASER_FREQ_HZ,
"prefix": prefix,
"save_dir": save_dir,
"per_angle": per_angle,
}
plan = build_plan(
_f(self.x_start_edit, "XS"), _f(self.y_start_edit, "YS"),
_f(self.x_delta_edit, "XD"), _f(self.y_delta_edit, "YD"),
_i(self.num_angles_edit, "NumAngles"),
_f(self.row_spacing_edit, "RowSpacing"),
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=SCAN_VELOCITY_MM_S,
rotation_sign=GR_ROTATION_SIGN,
)
prefix = self.scan_prefix_edit.text().strip() or "scan"
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
return plan, prefix, save_dir
def _on_row_done(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
self._scan_progress.update_progress(row, n_rows, angle_idx, n_angles)