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