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:
+95
-438
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user