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:
@@ -0,0 +1,393 @@
|
||||
"""Headless SRAS scan engine.
|
||||
|
||||
Takes plain hardware drivers, a ScanPlan, and callbacks — no Qt, no
|
||||
widgets. ``run()`` blocks, so the caller owns the thread; GUIs wrap this
|
||||
with gui.scan_bridge.QtScanController, which adapts the callbacks to Qt
|
||||
signals. A CLI or a simpler GUI can drive the same engine with nothing but
|
||||
functions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from core import scope_sras
|
||||
from core.rotation import RotationAxis
|
||||
from core.scan_geometry import ScanPlan, validate_plan
|
||||
from core.sras_format import SCAN_CHANNELS, create_scan_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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 drops TRIGOUT_MAXV early and clips the last few data points.
|
||||
SCAN_RAMP_BUFFER_MM = 1.0
|
||||
|
||||
AXIS_X = 0x21
|
||||
AXIS_Y = 0x22
|
||||
|
||||
|
||||
class ScanAborted(Exception):
|
||||
"""Raised inside the engine thread to unwind a scan cleanly."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResumeTarget:
|
||||
"""One angle selected for (re)acquisition in an existing file."""
|
||||
angle_idx: int
|
||||
data_offset: int
|
||||
n_rows: int
|
||||
angle_deg: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResumeState:
|
||||
path: Path
|
||||
targets: list[ResumeTarget]
|
||||
samples_per_frame: int
|
||||
|
||||
@property
|
||||
def target_indices(self) -> set[int]:
|
||||
return {t.angle_idx for t in self.targets}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanCallbacks:
|
||||
"""Progress reporting hooks. Every one is optional."""
|
||||
on_status: Callable[[str], None] = lambda msg: None
|
||||
on_started: Callable[[], None] = lambda: None
|
||||
on_row_started: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
|
||||
on_row_done: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
|
||||
on_dc_bias: Callable[[int, list], None] = lambda row, means: None
|
||||
on_paused_changed: Callable[[bool], None] = lambda paused: None
|
||||
# Blocking operator prompt: must not return until acknowledged.
|
||||
prompt: Callable[[str, str], None] = lambda title, msg: None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
path: Path
|
||||
rows_written: int = 0
|
||||
aborted: bool = False
|
||||
angles_acquired: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
class ScanEngine:
|
||||
"""Runs a full SRAS acquisition: stage, rotation, scope, and file output.
|
||||
|
||||
Constructed with the concrete drivers (not worker/queue wrappers), so
|
||||
any front end can reuse it::
|
||||
|
||||
engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||||
callbacks=ScanCallbacks(on_status=print))
|
||||
result = engine.run() # blocking
|
||||
"""
|
||||
|
||||
def __init__(self, stage, scope, rotator: RotationAxis | None,
|
||||
plan: ScanPlan, out_path: Path,
|
||||
resume: ResumeState | None = None,
|
||||
callbacks: ScanCallbacks | None = None):
|
||||
self._stage = stage
|
||||
self._scope = scope
|
||||
self._rotator = rotator
|
||||
self._plan = plan
|
||||
self._out_path = Path(out_path)
|
||||
self._resume = resume
|
||||
self._cb = callbacks if callbacks is not None else ScanCallbacks()
|
||||
|
||||
self._abort = threading.Event()
|
||||
self._resume_event = threading.Event()
|
||||
self._resume_event.set() # set = running, cleared = pause requested
|
||||
|
||||
# ── External control (thread-safe) ────────────────────────────────────────
|
||||
|
||||
def abort(self):
|
||||
self._abort.set()
|
||||
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()
|
||||
|
||||
@property
|
||||
def aborted(self) -> bool:
|
||||
return self._abort.is_set()
|
||||
|
||||
# ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_abort(self):
|
||||
if self._abort.is_set():
|
||||
raise ScanAborted("Scan aborted by user.")
|
||||
|
||||
def _pause_point(self):
|
||||
"""Block here (between rows, hardware idle) while a pause is requested."""
|
||||
if self._resume_event.is_set():
|
||||
self._check_abort()
|
||||
return
|
||||
self._cb.on_status(
|
||||
"Scan paused — lasers may be switched off. "
|
||||
"Turn lasers back on before resuming."
|
||||
)
|
||||
self._cb.on_paused_changed(True)
|
||||
while not self._resume_event.wait(0.2):
|
||||
if self._abort.is_set():
|
||||
break
|
||||
self._cb.on_paused_changed(False)
|
||||
self._check_abort()
|
||||
self._cb.on_status("Scan resumed.")
|
||||
|
||||
def _prompt(self, title: str, message: str):
|
||||
self._cb.prompt(title, message)
|
||||
self._check_abort()
|
||||
|
||||
# ── Main sequence ─────────────────────────────────────────────────────────
|
||||
|
||||
def run(self) -> ScanResult:
|
||||
"""Execute the scan. Blocking; returns a ScanResult.
|
||||
|
||||
Raises ScanGeometryError for an unrunnable plan, RuntimeError for
|
||||
missing/mismatched hardware, or ScanAborted if the operator aborts.
|
||||
"""
|
||||
plan = self._plan
|
||||
per_angle = plan.per_angle
|
||||
n_angles = plan.n_angles
|
||||
result = ScanResult(path=self._out_path)
|
||||
|
||||
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._cb.on_status(
|
||||
f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total "
|
||||
f"(per-angle bounding box) | save → {self._out_path.parent}\n"
|
||||
f"{geometry_summary}"
|
||||
)
|
||||
self._cb.on_started()
|
||||
|
||||
if self._stage is None:
|
||||
raise RuntimeError("BBD202 not connected")
|
||||
if self._scope is None:
|
||||
raise RuntimeError("Oscilloscope not connected")
|
||||
rotator_ready = self._rotator is not None and self._rotator.is_available
|
||||
if n_angles > 1 and not rotator_ready:
|
||||
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."
|
||||
)
|
||||
|
||||
if rotator_ready:
|
||||
s = self._rotator.settings
|
||||
self._cb.on_status(
|
||||
f"Configuring GR axis: {s.microsteps} µsteps, "
|
||||
f"{s.run_current_ma}/{s.hold_current_ma} mA run/hold …"
|
||||
)
|
||||
self._rotator.configure()
|
||||
time.sleep(0.2)
|
||||
|
||||
self._prepare_stage()
|
||||
samples_per_frame = self._prepare_scope()
|
||||
scan_file = self._open_output(samples_per_frame, result)
|
||||
|
||||
try:
|
||||
self._scan_loop(scan_file, samples_per_frame, result)
|
||||
finally:
|
||||
scan_file.close()
|
||||
# Return the GR axis home regardless of abort or error
|
||||
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
|
||||
self._cb.on_status("Returning GR to home …")
|
||||
try:
|
||||
self._rotator.return_to_zero()
|
||||
except Exception:
|
||||
logger.exception("GR return-to-home failed")
|
||||
|
||||
if self._abort.is_set():
|
||||
result.aborted = True
|
||||
raise ScanAborted("Scan aborted by user.")
|
||||
self._cb.on_status("Scan complete.")
|
||||
return result
|
||||
|
||||
def _prepare_stage(self):
|
||||
ctrl = self._stage
|
||||
self._cb.on_status("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._cb.on_status("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._cb.on_status("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 while the stage is at maximum velocity
|
||||
ctrl.set_trigger_trigout_maxv(AXIS_X)
|
||||
|
||||
def _prepare_scope(self) -> int:
|
||||
self._cb.on_status("Configuring oscilloscope …")
|
||||
samples_per_frame = scope_sras.configure_acquisition(self._scope)
|
||||
|
||||
if self._resume is None:
|
||||
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
|
||||
self._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."
|
||||
)
|
||||
self._background = scope_sras.capture_background(
|
||||
self._scope, should_abort=self._abort.is_set,
|
||||
on_status=self._cb.on_status)
|
||||
self._prompt(
|
||||
"Begin Scanning",
|
||||
"Background captured successfully.\n\n"
|
||||
"Please ensure the Genesis laser is back ON,\n"
|
||||
"then click OK to begin scanning."
|
||||
)
|
||||
else:
|
||||
# Resuming: the file's existing background waveform and channel
|
||||
# preambles are reused as-is (the format has no way to replace
|
||||
# them without rewriting the whole file), so background capture is
|
||||
# skipped. Sanity-check that this scope still produces 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 != self._resume.samples_per_frame:
|
||||
raise RuntimeError(
|
||||
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
||||
f"does not match the {self._resume.samples_per_frame} samples/frame "
|
||||
f"this scan file was started with — cannot safely resume."
|
||||
)
|
||||
targets = ", ".join(str(t.angle_idx + 1) for t in self._resume.targets)
|
||||
self._prompt(
|
||||
"Resume Scan",
|
||||
f"Resuming {self._resume.path.name} — will (re)acquire "
|
||||
f"angle(s) {targets} of {self._plan.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."
|
||||
)
|
||||
|
||||
scope_sras.configure_scan_trigger(self._scope)
|
||||
return samples_per_frame
|
||||
|
||||
def _open_output(self, samples_per_frame: int, result: ScanResult):
|
||||
if self._resume is not None:
|
||||
result.path = self._resume.path
|
||||
self._cb.on_status(
|
||||
f"Resuming {self._resume.path.name} — "
|
||||
f"{len(self._resume.targets)} angle(s) to (re)acquire …"
|
||||
)
|
||||
return open(self._resume.path, "r+b")
|
||||
return create_scan_file(
|
||||
self._out_path, self._plan, samples_per_frame,
|
||||
scope_sras.SAMPLE_RATE_HZ, self._preambles, self._background,
|
||||
)
|
||||
|
||||
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
|
||||
plan = self._plan
|
||||
n_angles = plan.n_angles
|
||||
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||||
ctrl = self._stage
|
||||
scope = self._scope
|
||||
|
||||
targets_by_ai = None
|
||||
if self._resume is not None:
|
||||
targets_by_ai = {t.angle_idx: t for t in self._resume.targets}
|
||||
|
||||
# Both fresh and resumed scans assume the GR axis starts at home (0°)
|
||||
# — the resume prompt instructs the operator to re-home it — so the
|
||||
# first move always rotates directly from 0° to the starting angle.
|
||||
for ai, pa in enumerate(plan.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 targets_by_ai is not None:
|
||||
# Interior angles may already have valid data on either side,
|
||||
# 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)
|
||||
|
||||
if self._rotator is not None and self._rotator.is_available:
|
||||
delta = pa.angle_deg - self._rotator.current_deg
|
||||
if abs(delta) > 0.001:
|
||||
self._cb.on_status(
|
||||
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
||||
self._rotator.rotate_to(pa.angle_deg)
|
||||
|
||||
# Each 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(pa.n_frames)
|
||||
|
||||
for ri, y_pos in enumerate(pa.y_positions):
|
||||
self._pause_point()
|
||||
|
||||
self._cb.on_row_started(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||||
self._cb.on_status(
|
||||
f"Angle {ai+1}/{n_angles} Row {ri+1}/{pa.n_rows} "
|
||||
f"(Y={y_pos:.3f} mm)"
|
||||
)
|
||||
|
||||
# Position the stage one ramp-length + buffer before the data
|
||||
# window so it is at full velocity before x_start.
|
||||
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
|
||||
ctrl.move_axis_absolute(AXIS_X, pa.x_start - x_ramp_total, timeout=30.0)
|
||||
|
||||
scope_sras.arm_row(scope)
|
||||
|
||||
# Data window + ramp + buffer run-off, so the stage does not
|
||||
# begin decelerating before the last point.
|
||||
x_end = pa.x_start + pa.x_delta + x_ramp_total
|
||||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||||
|
||||
scope_sras.finish_row(scope)
|
||||
self._write_row(scan_file, samples_per_frame, ri)
|
||||
|
||||
result.rows_written += 1
|
||||
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||||
|
||||
result.angles_acquired.append(ai)
|
||||
|
||||
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int):
|
||||
"""Stream every channel from the scope into the file.
|
||||
|
||||
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
|
||||
frames are written to keep the file layout intact.
|
||||
"""
|
||||
scope = self._scope
|
||||
for ch in SCAN_CHANNELS:
|
||||
if ch == 3:
|
||||
self._cb.on_status("Writing zeroed CH3 frames …")
|
||||
zero_frame = bytes(samples_per_frame)
|
||||
for _ in range(scope_sras.frames_acquired(scope)):
|
||||
scan_file.write(zero_frame)
|
||||
continue
|
||||
|
||||
self._cb.on_status(f"Fetching CH{ch} data …")
|
||||
waveforms = scope_sras.transfer_channel(scope, ch)
|
||||
if ch == 4 and waveforms:
|
||||
self._cb.on_dc_bias(row_idx + 1, scope_sras.frame_means(waveforms))
|
||||
for w in waveforms:
|
||||
scan_file.write(w)
|
||||
Reference in New Issue
Block a user