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
+64
View File
@@ -0,0 +1,64 @@
"""Resume planning: turn a file's frontier into a set of angles to re-acquire.
Pure logic, no Qt and no file I/O beyond what SrasFile already parsed, so
the non-obvious contiguity rule is testable on its own.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from core.scan_engine import ResumeState, ResumeTarget
from core.sras_format import SrasFile
@dataclass
class ResumePlan:
targets: list[ResumeTarget]
auto_added: list[int] = field(default_factory=list) # indices forced in
frontier_idx: int = 0
@property
def total_rows(self) -> int:
return sum(t.n_rows for t in self.targets)
def to_state(self, sras: SrasFile) -> ResumeState:
return ResumeState(path=sras.path, targets=self.targets,
samples_per_frame=sras.header.samples_per_frame)
def plan_resume(statuses, selected: set[int]) -> ResumePlan:
"""Expand an operator's angle selection into a runnable resume plan.
Waveform data is one contiguous append-only stream, so nothing can be
written past a gap: if the operator picks an angle at or beyond the
frontier (the first incomplete angle), every angle from the frontier up
to it must be re-acquired too. Those extras are reported in
``auto_added`` so the UI can say so.
"""
frontier_idx = next((s.index for s in statuses if not s.complete), len(statuses))
at_or_past = {i for i in selected if i >= frontier_idx}
if at_or_past:
final = selected | set(range(frontier_idx, max(at_or_past) + 1))
else:
final = set(selected)
targets = [
ResumeTarget(angle_idx=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
]
return ResumePlan(targets=targets,
auto_added=sorted(final - set(selected)),
frontier_idx=frontier_idx)
def is_compatible(sras: SrasFile, *, velocity: float, laser_freq: float,
sample_rate: float, n_channels: int) -> bool:
"""Whether appending to this file with the current settings is safe."""
h = sras.header
return (h.bytes_per_sample == 1
and h.n_channels == n_channels
and abs(h.velocity - velocity) <= 1e-3
and abs(h.laser_freq - laser_freq) <= 1e-3
and abs(h.sample_rate - sample_rate) <= 1.0)