Phase 4: extract headless ScanEngine; de-Qt the T3R driver

The headline of the refactor. Scan orchestration no longer lives inside a
QObject that reaches through Qt workers for its hardware handles.

core/scan_engine.py — ScanEngine(stage, scope, rotator, plan, out_path,
resume, callbacks). Takes the concrete drivers, blocks in run(), reports
via plain callables, and prompts through an injected blocking callable.
No Qt import anywhere in the path (test_engine_imports_without_qt proves
it), so a simpler GUI or a CLI can drive the identical acquisition.

Supporting extractions, all Qt-free:
- core/scope_sras.py  — SCPI policy: channel profiles, trigger programming,
  background average, per-row FastFrame transfer
- core/rotation.py    — RotationAxis + RotationSettings (the GR_* constants)
- core/scan_resume.py — frontier contiguity rule + settings compatibility
- gui/scan_bridge.py  — QtScanController, exposing exactly the signal
  surface the old ScanWorker had, so MainWindow's connections are unchanged

hardware/t3r_driver.py is now Qt-free: a plain Signal class, a threading
reader, and a polling thread instead of QObject/QThread/QTimer.
gui/qt_t3r.py re-emits its callbacks as queued Qt signals for the panels.

Fixes carried by the extraction:
- rotation waits on the driver's MOTION_DONE event instead of
  time.sleep(estimate + 0.5)
- abort during an operator prompt now takes effect; the old
  _prompt_event.wait() had no timeout and could not be interrupted
- the poll timer is a thread, so an I/O error tearing down the driver no
  longer calls QTimer.stop() from the wrong thread
- T3RDriver.disconnect() renamed close(); it shadowed QObject.disconnect()
- per-frame DC means use np.frombuffer over the joined block instead of
  struct.unpack per frame (~16k tuple allocations per row)

tests/fakes.py + test_scan_engine.py (14 tests) assert the exact command
sequence, file layout, resume seeking, abort/pause, and geometry
rejection before any hardware call; test_scan_resume.py covers the
frontier rule. 58 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales
2026-07-28 11:11:09 -05:00
parent d2734c45d6
commit afe33249d1
13 changed files with 1585 additions and 613 deletions
+50 -542
View File
@@ -10,7 +10,6 @@ import struct
import sys
import time
from pathlib import Path
import threading
from threading import Thread
import numpy as np
@@ -29,13 +28,19 @@ 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 core.rotation import DEFAULT_ROTATION, RotationAxis
from core.scan_engine import (
ResumeState,
LASER_FREQ_HZ, SCAN_VELOCITY_MM_S,
)
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
from core.scan_resume import is_compatible, plan_resume
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
from gui.qt_t3r import QtT3RAdapter
from gui.scan_bridge import QtScanController
from hardware.helios_laser import HeliosLaser
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
from hardware.t3r_driver import T3RDriver
from hardware.tektronix_base import TektronixOscilloscopeBase
from hardware.uc480_camera import (
CameraStreamThread, UC480Camera, find_camera_bus_conflicts,
@@ -44,31 +49,6 @@ from t3r_control_panel import T3RControlPanel
DEFAULTS = ScanDefaults.load()
# ── Scan constants ───────────────────────────────────────────────────────────
SCAN_VELOCITY_MM_S = 100.0
SCAN_ACCEL_MM_S2 = 1500.0
LASER_FREQ_HZ = 20000.0 # laser pulse frequency during data acquisition
# Theoretical ramp distance: d = v² / (2a) = 100² / (2×1500) ≈ 3.33 mm
SCAN_RAMP_MM = SCAN_VELOCITY_MM_S**2 / (2.0 * SCAN_ACCEL_MM_S2)
# Extra buffer added to both ends of the ramp. The BBD202 controller begins
# decelerating slightly before the theoretical point to avoid overshoot, which
# causes TRIGOUT_MAXV to drop early and clip the last few data points.
# 1 mm at 100 mm/s and 2000 Hz corresponds to 20 extra trigger windows.
SCAN_RAMP_BUFFER_MM = 1.0
SCOPE_SAMPLE_RATE = 6.25e9 # 6.25 GS/s → 160 ps/sample
SCOPE_TRIG_LEVEL_V = 0.500
# GR-axis rotation defaults (used by ScanWorker between angles)
GR_MICROSTEPS = 8 # microsteps/full-step on GR axis (ch3)
GR_MOVE_VELOCITY = 4000 # steps/s for inter-angle moves
GR_MOVE_ACCEL = 2000 # steps/s² for inter-angle moves
GR_RUN_CURRENT_MA = 1200 # drive current while moving
GR_HOLD_CURRENT_MA = 400 # standstill current
GR_IHOLD_DELAY = 6 # run→hold current ramp delay (TMC IHOLDDELAY units)
# Sample rotates CW instead of CCW to clear wiring and avoid a stall condition.
GR_ROTATION_SIGN = -1
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
@@ -317,52 +297,12 @@ class OscopeWorker(QObject):
try:
self.scope = TektronixOscilloscopeBase(resource_name=ip, port=4000, timeout=10.0)
self.scope.connect()
self._configure_channels()
configure_channels(self.scope)
self.is_connected = True
self.connected.emit()
except Exception as e:
self.connection_failed.emit(str(e))
def _configure_channels(self):
"""Apply standard SRAS channel configuration after connecting."""
s = self.scope
# Turn on all four channels
for ch in (1, 2, 3, 4):
s.write(f"SELect:CH{ch} ON")
# ── CH1 — RF Acoustic Packet ──────────────────────────────────────────
s.set_channel_label_name(1, "RF Acoustic Packet")
s.set_channel_scale(1, 0.07) # 70 mV/div
s.set_channel_position(1, 0.0) # 0 divs
s.set_channel_termination(1, 50) # 50 Ohm
s.set_channel_coupling(1, "DC")
s.set_channel_bandwidth(1, 250E6) # 250Mhz Low-Pass
# ── CH2 — Trigger Signal ──────────────────────────────────────────────
s.set_channel_label_name(2, "Trigger Signal")
s.set_channel_scale(2, 0.5) # 500 mV/div
s.set_channel_position(2, -2.72) # -2.72 divs
s.set_channel_termination(2, 1000000) # 50 Ohm
s.set_channel_coupling(2, "DC")
s.set_channel_bandwidth(2, 20E6) # 20 MHz
# ── CH3 — Max Velocity Gate ───────────────────────────────────────────
s.set_channel_label_name(3, "Max Vel Gate")
s.set_channel_scale(3, 1.0) # 1 V/div
s.set_channel_position(3, -2.72) # -2.72 divs
s.set_channel_termination(3, 1000000) # 1 MOhm
s.set_channel_coupling(3, "DC")
s.set_channel_bandwidth(3, 20E6) # 20 MHz
# ── CH4 — Bias B ──────────────────────────────────────────────────────
s.set_channel_label_name(4, "Bias - B")
s.set_channel_scale(4, 0.1) # 100 mV/div
s.set_channel_position(4, -2.72) # -2.72 divs
s.set_channel_termination(4, 1000000) # 1 Mohm
s.set_channel_coupling(4, "DC")
s.set_channel_bandwidth(4, 20E6) # 20 MHz
def _do_disconnect(self):
if self.scope:
try:
@@ -968,424 +908,6 @@ class ScanProgressWindow(QWidget):
# ── Scan worker ───────────────────────────────────────────────────────────────
class ScanWorker(QObject):
"""Runs the full SRAS scan sequence in a background thread.
Accesses hardware drivers directly (not through worker queues) so it can
make blocking calls. BBD202Worker.scanning_active is set to True for the
duration to suppress position polling in the BBD worker thread.
"""
started = pyqtSignal()
completed = pyqtSignal()
dc_bias_updated = pyqtSignal(int, object) # row index, list[float] per-frame DC means
failed = pyqtSignal(str)
row_started = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles
row_done = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles
status_msg = pyqtSignal(str)
user_prompt = pyqtSignal(str, str) # title, message
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | 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._plan = plan
self._prefix = prefix
self._save_dir = save_dir
self._resume_info = resume_info
self._abort = False
self._prompt_event = threading.Event()
self._resume_event = threading.Event()
self._resume_event.set() # set = running, cleared = pause requested
def abort(self):
self._abort = True
self._resume_event.set() # unblock a paused scan so it can exit
def pause(self):
"""Request a pause; takes effect at the next row boundary."""
self._resume_event.clear()
def resume(self):
self._resume_event.set()
def _pause_point(self):
"""Block here (between rows, hardware idle) while a pause is requested."""
if self._resume_event.is_set() or self._abort:
return
self.status_msg.emit(
"Scan paused — lasers may be switched off. "
"Turn lasers back on before resuming."
)
self.paused_changed.emit(True)
while not self._resume_event.wait(0.2):
if self._abort:
break
self.paused_changed.emit(False)
if not self._abort:
self.status_msg.emit("Scan resumed.")
def acknowledge_prompt(self):
"""Called from UI thread when user clicks OK on a prompt dialog."""
self._prompt_event.set()
def _request_user_prompt(self, title: str, message: str):
"""Emit a prompt signal and block until the UI thread acknowledges it."""
self._prompt_event.clear()
self.user_prompt.emit(title, message)
self._prompt_event.wait()
@pyqtSlot()
def run(self):
try:
self._run_scan()
except Exception as e:
import traceback
traceback.print_exc()
self.failed.emit(str(e))
def _run_scan(self):
plan = self._plan
per_angle = plan.per_angle
n_angles = plan.n_angles
save_dir = Path(self._save_dir)
# 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
validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM)
geometry_summary = ", ".join(
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), {plan.total_rows} row(s) total "
f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}"
)
self.started.emit()
# ── Validate hardware ─────────────────────────────────────────────────
ctrl = self._bbd.controller
scope = self._oscope.scope
if ctrl is None:
raise RuntimeError("BBD202 not connected")
if scope is None:
raise RuntimeError("Oscilloscope not connected")
if n_angles > 1 and (self._t3r is None or not self._t3r.is_open):
raise RuntimeError(
f"NumAngles={n_angles} requires the T3R rotation stage (GR-axis), "
"but it is not connected. Connect T3R from the T3R panel before "
"starting a multi-angle scan, or set NumAngles to 1."
)
# ── Configure GR rotation axis ────────────────────────────────────────
# steps_for_angle() assumes GR_MICROSTEPS, so the device must be set to
# match — never rely on whatever the T3R panel (or firmware default)
# last left it at. Also set drive current and energise the channel.
if self._t3r is not None and self._t3r.is_open:
self.status_msg.emit(
f"Configuring GR axis: {GR_MICROSTEPS} µsteps, "
f"{GR_RUN_CURRENT_MA}/{GR_HOLD_CURRENT_MA} mA run/hold …"
)
gr_ch = self._t3r.GR_AXIS_CH
self._t3r.set_microstep(gr_ch, GR_MICROSTEPS)
self._t3r.set_current(gr_ch, GR_RUN_CURRENT_MA,
GR_HOLD_CURRENT_MA, GR_IHOLD_DELAY)
self._t3r.enable(gr_ch)
time.sleep(0.2)
# ── Prepare stage ─────────────────────────────────────────────────────
self.status_msg.emit("Enabling stage axes …")
if not ctrl.am_enabled[0]:
ctrl.enable_axis(AXIS_X)
if not ctrl.am_enabled[1]:
ctrl.enable_axis(AXIS_Y)
time.sleep(0.2)
if not ctrl.am_homed[0] or not ctrl.am_homed[1]:
self.status_msg.emit("Homing stage (may take up to 2 min) …")
if not ctrl.am_homed[0]:
ctrl.home_axis(AXIS_X, timeout=120.0)
if not ctrl.am_homed[1]:
ctrl.home_axis(AXIS_Y, timeout=120.0)
self.status_msg.emit("Setting scan velocity …")
ctrl.set_velocity_params(AXIS_X, max_velocity=SCAN_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
# X trigger: logic-high output when stage is at maximum velocity
ctrl.set_trigger_trigout_maxv(AXIS_X)
# ── Configure oscilloscope ────────────────────────────────────────────
self.status_msg.emit("Configuring oscilloscope …")
# Edge trigger: rising edge of CH2 (laser pulse) at 1.0 V.
scope.write("TRIGger:A:TYPe EDGE")
scope.set_trigger_source(2)
scope.set_trigger_slope("RISE")
scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V)
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(False) # start non-FF for background capture
scope.set_sample_rate(SCOPE_SAMPLE_RATE)
scope.write("HORizontal:POSition 30") # 10 % trigger offset
time.sleep(0.3)
samples_per_frame = scope.get_record_length()
resume_info = self._resume_info
if resume_info is None:
# ── Snapshot WFMOutpre for each channel (captures YMULT/YOFF/YZERO) ──
preambles = []
for ch in SCAN_CHANNELS:
scope.set_data_source(ch)
preambles.append(scope.query_wfmoutpre())
# ── Background subtraction capture ──────────────────────────────────
# Prompt user to ensure Helios is ON and Genesis is OFF.
self._request_user_prompt(
"Background Capture",
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
"then click OK to capture the background waveform."
)
if self._abort:
self.failed.emit("Scan aborted by user.")
return
# Capture a single averaged CH1 frame (1024 waveforms averaged).
self.status_msg.emit("Capturing background waveform (1024-average) …")
scope.set_acquire_mode("AVERAGE")
scope.write("ACQuire:NUMAVg 1024")
scope.write("ACQuire:STOPAfter SEQuence") # auto-stop after all 1024 averages
scope.set_data_source(1)
scope.write("ACQuire:STATE RUN")
# Poll until the scope finishes all 1024 averages and auto-stops.
# Timeout: 1024 averages at 2 kHz (worst case) = ~0.5 s; allow 60 s.
_bg_deadline = time.time() + 60.0
while time.time() < _bg_deadline:
if self._abort:
break
if scope.query("ACQuire:STATE?").strip() == "0":
break
time.sleep(0.25)
else:
scope.write("ACQuire:STATE STOP")
self.status_msg.emit("Warning: background average timed out; stopping early.")
time.sleep(0.1)
background_waveform = scope.transfer_curve()
# Prompt user to turn Genesis back on before the actual scan.
self._request_user_prompt(
"Begin Scanning",
"Background captured successfully.\n\n"
"Please ensure the Genesis laser is back ON,\n"
"then click OK to begin scanning."
)
if self._abort:
self.failed.emit("Scan aborted by user.")
return
else:
# Resuming an existing file: the background waveform and channel
# preambles it already contains are reused as-is (the format has
# no way to replace them without rewriting the whole file), so
# background capture is skipped entirely. Sanity-check that this
# scope is still producing the same record length the file was
# started with — a mismatch would silently corrupt the ragged
# per-row byte layout on append.
if samples_per_frame != resume_info["samples_per_frame"]:
raise RuntimeError(
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
f"does not match the {resume_info['samples_per_frame']} samples/frame "
f"this scan file was started with — cannot safely resume."
)
target_angles = ", ".join(str(t["ai"] + 1) for t in resume_info["targets"])
self._request_user_prompt(
"Resume Scan",
f"Resuming {resume_info['path'].name} — will (re)acquire "
f"angle(s) {target_angles} of {n_angles}.\n\n"
"Please re-home the GR axis to 0° before continuing — the scan "
"will rotate it directly from angle to angle before scanning resumes.\n\n"
"Please ensure the Genesis laser is ON,\n"
"then click OK to continue scanning."
)
if self._abort:
self.failed.emit("Scan aborted by user.")
return
# Restore SAMPLE mode and FastFrame for the actual scan. FastFrame
# count (points/row) is set per-angle in the scan loop below, since
# it depends on that angle's bounding-box X extent.
scope.write("ACQuire:STOPAfter RUNSTop")
scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(True)
# Restore logic-AND trigger (CH2 HIGH AND CH3 HIGH) for the scan loop:
# CH3 is the BBD202 TRIGOUT_MAXV gate, so frames only accumulate while
# the stage is at full scan velocity.
scope.write("TRIGger:A:TYPe LOGIc")
scope.write("TRIGger:A:LOGIc:FUNCtion AND")
scope.set_trigger_level(2, SCOPE_TRIG_LEVEL_V)
scope.set_trigger_level(3, SCOPE_TRIG_LEVEL_V)
scope.write("TRIGger:A:LOGICPattern:CH2 HIGH")
scope.write("TRIGger:A:LOGICPattern:CH3 HIGH")
time.sleep(0.2)
# ── Open output file (entire scan in one .sras) ─────────────────────────
if resume_info is not None:
targets_by_ai = {t["ai"]: t for t in resume_info["targets"]}
scan_file = open(resume_info["path"], "r+b")
self.status_msg.emit(
f"Resuming {resume_info['path'].name} — "
f"{len(targets_by_ai)} angle(s) to (re)acquire …"
)
else:
targets_by_ai = None
fname = save_dir / f"{self._prefix}.sras"
scan_file = create_scan_file(
fname, plan, samples_per_frame, SCOPE_SAMPLE_RATE,
preambles, background_waveform,
)
# ── Scan loop ─────────────────────────────────────────────────────────
self._bbd.scanning_active = True
# Both fresh and resumed scans assume the GR axis starts at home (0°)
# — the resume prompt instructs the operator to re-home it before
# continuing — so the first move always rotates directly from 0°
# to the starting angle.
prev_gr_deg = 0.0
try:
for ai, pa in enumerate(per_angle):
if targets_by_ai is not None and ai not in targets_by_ai:
continue # not selected for (re)acquisition
self._pause_point()
if self._abort:
break
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
# side of them, so seek to this angle's fixed offset
# rather than relying on the file's current position.
scan_file.seek(targets_by_ai[ai]["data_offset"])
# ── Rotate GR ─────────────────────────────────────────────────
if self._t3r is not None and self._t3r.is_open:
delta_deg = angle - prev_gr_deg
if abs(delta_deg) > 0.001:
steps = self._t3r.steps_for_angle(delta_deg, GR_MICROSTEPS)
# Trapezoidal move time: cruise + accel/decel ramps
est_secs = (abs(steps) / GR_MOVE_VELOCITY
+ GR_MOVE_VELOCITY / GR_MOVE_ACCEL)
self.status_msg.emit(
f"Rotating GR to {angle:.1f}° (Δ{delta_deg:+.1f}°, "
f"≈{est_secs:.1f} s) …"
)
self._t3r.rotate_stage(delta_deg, GR_MICROSTEPS,
GR_MOVE_VELOCITY, GR_MOVE_ACCEL)
time.sleep(est_secs + 0.5)
prev_gr_deg = angle
# This angle's bounding box gives it its own points/row count,
# so the scope's FastFrame count must be re-armed per angle.
scope.set_fastframe_count(a_n_frames)
# ── Row loop ──────────────────────────────────────────────────
for ri, y_pos in enumerate(y_positions):
self._pause_point()
if self._abort:
break
self.row_started.emit(ri + 1, a_n_rows, ai + 1, n_angles)
self.status_msg.emit(
f"Angle {ai+1}/{n_angles} Row {ri+1}/{a_n_rows} "
f"(Y={y_pos:.3f} mm)"
)
# Position stage one ramp-length + buffer before the data
# window so the stage is at full velocity before x_start.
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, a_x_start - _x_ramp_total, timeout=30.0)
# Arm oscilloscope — trigger is gated with TRIGOUT_MAXV so
# frames only accumulate once the stage reaches full velocity
scope.write("ACQuire:STATE RUN")
time.sleep(0.05)
# Execute scan move: data window + ramp + buffer run-off so
# the stage does not begin decelerating before the last point.
x_end = a_x_start + a_x_delta + _x_ramp_total
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
# Brief settle: wait for trailing frames then stop acquisition
time.sleep(0.2)
scope.write("ACQuire:STATE STOP")
# Stream all channels from oscilloscope and append to file.
# CH3 is the max-vel gate signal — no useful waveform data,
# so write zeroed frames to keep the file format intact.
for ch in SCAN_CHANNELS:
if ch == 3:
self.status_msg.emit("Writing zeroed CH3 frames …")
zero_frame = bytes(samples_per_frame)
n_frames_acquired = int(scope.query("ACQuire:NUMFRAMESACQuired?"))
for _ in range(n_frames_acquired):
scan_file.write(zero_frame)
elif ch == 4:
self.status_msg.emit(f"Fetching CH{ch} data …")
scope.set_data_source(ch)
waveforms: list[bytes] = scope.transfer_fastframe(parse=False)
if waveforms:
frame_avgs = []
for w in waveforms:
n = len(w)
frame_avgs.append(
sum(struct.unpack(f"{n}b", w)) / n if n > 0 else 0.0
)
self.dc_bias_updated.emit(ri + 1, frame_avgs)
for w in waveforms:
scan_file.write(w)
else:
self.status_msg.emit(f"Fetching CH{ch} data …")
scope.set_data_source(ch)
waveforms: list[bytes] = scope.transfer_fastframe(parse=False)
for w in waveforms:
scan_file.write(w)
self.row_done.emit(ri + 1, a_n_rows, ai + 1, n_angles)
finally:
scan_file.close()
self._bbd.scanning_active = False
# Return GR axis to home (0°) regardless of abort or error
if self._t3r is not None and self._t3r.is_open and abs(prev_gr_deg) > 0.001:
return_deg = -prev_gr_deg
est_secs = (abs(self._t3r.steps_for_angle(return_deg, GR_MICROSTEPS))
/ GR_MOVE_VELOCITY + GR_MOVE_VELOCITY / GR_MOVE_ACCEL)
self.status_msg.emit(
f"Returning GR to home ({return_deg:+.1f}°, ≈{est_secs:.1f} s) …"
)
self._t3r.rotate_stage(return_deg, GR_MICROSTEPS,
GR_MOVE_VELOCITY, GR_MOVE_ACCEL)
time.sleep(est_secs + 0.5)
if self._abort:
self.failed.emit("Scan aborted by user.")
else:
self.status_msg.emit("Scan complete.")
self.completed.emit()
# ── Main window ───────────────────────────────────────────────────────────────
class MainWindow(QMainWindow):
@@ -1394,8 +916,10 @@ class MainWindow(QMainWindow):
uic.loadUi(ROOT / "sc3-aui-main.ui", self)
self.setWindowTitle("Scanengine-3 AUI")
# ── T3R driver (owns serial + reader QThread internally) ─────────────
self._t3r_driver = T3RDriver(self)
# ── T3R driver (owns serial + reader thread internally) ──────────────
# QtT3RAdapter re-emits the driver's thread-side callbacks as queued
# Qt signals so widget slots run on the GUI thread.
self._t3r_driver = QtT3RAdapter(parent=self)
self._t3r_panel: T3RControlPanel | None = None
# ── Worker threads ────────────────────────────────────────────────────
@@ -1419,8 +943,8 @@ class MainWindow(QMainWindow):
self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow()
self._scan_worker: ScanWorker | None = None
self._scan_thread: QThread | None = None
self._scan_worker: QtScanController | None = None
self._scan_thread: QThread | None = None
self._bbd_active_jog: tuple[str, int] | None = None # (axis, direction)
self._bbd_jog_timer = QTimer(self)
@@ -1739,11 +1263,9 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
return
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):
if not is_compatible(sras, velocity=SCAN_VELOCITY_MM_S,
laser_freq=LASER_FREQ_HZ, sample_rate=SAMPLE_RATE_HZ,
n_channels=len(SCAN_CHANNELS)):
QMessageBox.warning(
self, "Cannot Resume Scan",
f"{path.name} was recorded with acquisition settings that don't "
@@ -1760,59 +1282,47 @@ class MainWindow(QMainWindow):
if not selected:
return
# 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:
final = selected | set(range(frontier_idx, max(at_or_past_frontier) + 1))
else:
final = selected
auto_added = sorted(final - selected)
if auto_added:
resume_plan = plan_resume(statuses, selected)
if resume_plan.auto_added:
QMessageBox.information(
self, "Additional Angles Required",
"The scan file has no data yet past angle "
f"{frontier_idx + 1}, so angle(s) can't be skipped over. "
f"{resume_plan.frontier_idx + 1}, so angle(s) can't be skipped over. "
"The following angle(s) will also be (re)acquired so there's "
f"no gap: {[i + 1 for i in auto_added]}"
f"no gap: {[i + 1 for i in resume_plan.auto_added]}"
)
targets = [
{
"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
]
total_rows = sum(t["n_rows"] for t in targets)
angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets)
angle_list = ", ".join(str(t.angle_idx + 1) for t in resume_plan.targets)
reply = QMessageBox.question(
self, "Resume Scan",
f"Resume {path.name}?\n\n"
f"Angle(s) to (re)acquire: {angle_list}\n"
f"Total rows to acquire: {total_rows}\n\n"
f"Total rows to acquire: {resume_plan.total_rows}\n\n"
"Existing data for these angle(s) (if any) will be overwritten.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
plan = plan_from_header(sras)
resume_info = {
"path": path,
"targets": targets,
"samples_per_frame": hdr.samples_per_frame,
}
self._launch_scan_worker(plan, path.stem, str(path.parent), resume_info)
self._launch_scan_worker(plan_from_header(sras), path.stem,
str(path.parent), resume_plan.to_state(sras))
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
resume_info: dict | None = None):
resume: ResumeState | None = None):
rotator = RotationAxis(self._t3r_driver.driver, DEFAULT_ROTATION)
self._scan_thread = QThread(self)
self._scan_worker = ScanWorker(
self._bbd_worker, self._t3r_driver, self._oscope_worker,
plan, prefix, save_dir, resume_info
self._scan_worker = QtScanController(
stage=self._bbd_worker.controller,
scope=self._oscope_worker.scope,
rotator=rotator,
plan=plan,
out_path=Path(save_dir) / f"{prefix}.sras",
resume=resume,
# Suppress BBD position polling for the duration of the scan —
# a worker concern, not the engine's.
on_scan_active=lambda active: setattr(
self._bbd_worker, "scanning_active", active),
)
self._scan_worker.moveToThread(self._scan_thread)
self._scan_thread.started.connect(self._scan_worker.run)
@@ -1827,13 +1337,11 @@ 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, plan.per_angle[0].n_rows, 0, plan.n_angles)
else:
ai0 = resume_info["targets"][0]["ai"]
self._scan_progress.update_progress(
0, plan.per_angle[ai0].n_rows, ai0 + 1, plan.n_angles
)
ai0 = 0 if resume is None else resume.targets[0].angle_idx
self._scan_progress.update_progress(
0, plan.per_angle[ai0].n_rows,
0 if resume is None else ai0 + 1, plan.n_angles
)
self._scan_progress.show()
self._scan_thread.start()
@@ -1855,7 +1363,7 @@ class MainWindow(QMainWindow):
_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,
rotation_sign=DEFAULT_ROTATION.rotation_sign,
)
prefix = self.scan_prefix_edit.text().strip() or "scan"
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
@@ -1901,7 +1409,7 @@ class MainWindow(QMainWindow):
def _disconnect_all_and_close(self):
"""Cleanly disconnect all hardware then quit."""
if self._t3r_driver.is_open:
self._t3r_driver.disconnect()
self._t3r_driver.close()
if self._bbd_worker.is_connected:
self._bbd_worker.queue_disconnect()
if self._oscope_worker.is_connected:
@@ -1930,7 +1438,7 @@ class MainWindow(QMainWindow):
self._scan_progress.close()
self._helios_win.close()
if self._t3r_driver.is_open:
self._t3r_driver.disconnect()
self._t3r_driver.close()
for w in (self._bbd_worker, self._oscope_worker, self._helios_worker):
w.stop_worker()
for t in (self._bbd_thread, self._oscope_thread, self._helios_thread):