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,96 @@
|
||||
"""Qt bridge over the headless ScanEngine.
|
||||
|
||||
Exposes exactly the signal surface the old in-GUI ScanWorker had, so window
|
||||
code connects to it unchanged, and adapts prompts/progress to Qt. The
|
||||
engine itself stays Qt-free and reusable by any other front end.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
|
||||
from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine
|
||||
|
||||
|
||||
class QtScanController(QObject):
|
||||
"""Runs a ScanEngine on the caller's QThread and republishes its events.
|
||||
|
||||
Move this to a QThread and connect `started` → `run`, exactly like the
|
||||
previous ScanWorker.
|
||||
"""
|
||||
|
||||
started = pyqtSignal()
|
||||
completed = pyqtSignal()
|
||||
dc_bias_updated = pyqtSignal(int, object) # row index, 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)
|
||||
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, stage, scope, rotator, plan, out_path,
|
||||
resume=None, on_scan_active=None):
|
||||
super().__init__()
|
||||
self._prompt_event = threading.Event()
|
||||
self._on_scan_active = on_scan_active
|
||||
|
||||
callbacks = ScanCallbacks(
|
||||
on_status=self.status_msg.emit,
|
||||
on_started=self.started.emit,
|
||||
on_row_started=self.row_started.emit,
|
||||
on_row_done=self.row_done.emit,
|
||||
on_dc_bias=self.dc_bias_updated.emit,
|
||||
on_paused_changed=self.paused_changed.emit,
|
||||
prompt=self._blocking_prompt,
|
||||
)
|
||||
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||||
resume=resume, callbacks=callbacks)
|
||||
|
||||
# ── Engine control (called from the GUI thread) ───────────────────────────
|
||||
|
||||
def abort(self):
|
||||
self._engine.abort()
|
||||
self._prompt_event.set() # release a scan parked on a prompt
|
||||
|
||||
def pause(self):
|
||||
self._engine.pause()
|
||||
|
||||
def resume(self):
|
||||
self._engine.resume()
|
||||
|
||||
def acknowledge_prompt(self):
|
||||
"""Called from the GUI thread when the operator dismisses a prompt."""
|
||||
self._prompt_event.set()
|
||||
|
||||
# ── Callback plumbing ─────────────────────────────────────────────────────
|
||||
|
||||
def _blocking_prompt(self, title: str, message: str):
|
||||
"""Ask the GUI thread, then block the scan thread until answered.
|
||||
|
||||
Polls rather than waiting forever so an abort during a prompt takes
|
||||
effect immediately instead of deadlocking the scan thread.
|
||||
"""
|
||||
self._prompt_event.clear()
|
||||
self.user_prompt.emit(title, message)
|
||||
while not self._prompt_event.wait(0.2):
|
||||
if self._engine.aborted:
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
if self._on_scan_active is not None:
|
||||
self._on_scan_active(True)
|
||||
try:
|
||||
self._engine.run()
|
||||
self.completed.emit()
|
||||
except ScanAborted as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
traceback.print_exc()
|
||||
self.failed.emit(str(exc))
|
||||
finally:
|
||||
if self._on_scan_active is not None:
|
||||
self._on_scan_active(False)
|
||||
Reference in New Issue
Block a user