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,167 @@
|
||||
"""Oscilloscope SCPI policy for SRAS acquisition.
|
||||
|
||||
All the Tektronix-specific instrument setup the scan depends on, in one
|
||||
Qt-free place: per-channel display/coupling config, trigger programming,
|
||||
the background average, and per-row FastFrame transfer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_RATE_HZ = 6.25e9 # 6.25 GS/s → 160 ps/sample
|
||||
TRIG_LEVEL_V = 0.500
|
||||
BACKGROUND_AVERAGES = 1024
|
||||
BACKGROUND_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelProfile:
|
||||
"""Display/input configuration for one scope channel."""
|
||||
label: str
|
||||
scale_v_div: float
|
||||
position_div: float
|
||||
termination_ohm: int
|
||||
coupling: str
|
||||
bandwidth_hz: float
|
||||
|
||||
|
||||
# Standard SRAS front-end configuration.
|
||||
SRAS_CHANNELS = {
|
||||
1: ChannelProfile("RF Acoustic Packet", 0.07, 0.0, 50, "DC", 250e6),
|
||||
2: ChannelProfile("Trigger Signal", 0.5, -2.72, 1_000_000, "DC", 20e6),
|
||||
3: ChannelProfile("Max Vel Gate", 1.0, -2.72, 1_000_000, "DC", 20e6),
|
||||
4: ChannelProfile("Bias - B", 0.1, -2.72, 1_000_000, "DC", 20e6),
|
||||
}
|
||||
|
||||
|
||||
def configure_channels(scope, profiles=None) -> None:
|
||||
"""Apply the standard SRAS channel configuration."""
|
||||
profiles = profiles if profiles is not None else SRAS_CHANNELS
|
||||
for ch, p in profiles.items():
|
||||
scope.write(f"SELect:CH{ch} ON")
|
||||
scope.set_channel_label_name(ch, p.label)
|
||||
scope.set_channel_scale(ch, p.scale_v_div)
|
||||
scope.set_channel_position(ch, p.position_div)
|
||||
scope.set_channel_termination(ch, p.termination_ohm)
|
||||
scope.set_channel_coupling(ch, p.coupling)
|
||||
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
|
||||
|
||||
|
||||
def configure_acquisition(scope) -> int:
|
||||
"""Program the edge trigger and timebase; returns samples per frame.
|
||||
|
||||
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays
|
||||
off here so the background capture runs as a single record.
|
||||
"""
|
||||
scope.write("TRIGger:A:TYPe EDGE")
|
||||
scope.set_trigger_source(2)
|
||||
scope.set_trigger_slope("RISE")
|
||||
scope.set_trigger_level(2, 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)
|
||||
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
||||
scope.write("HORizontal:POSition 30") # 10 % trigger offset
|
||||
time.sleep(0.3) # let the timebase settle before reading back
|
||||
return scope.get_record_length()
|
||||
|
||||
|
||||
def read_preambles(scope, channels) -> list[str]:
|
||||
"""Snapshot WFMOutpre per channel (captures YMULT/YOFF/YZERO)."""
|
||||
preambles = []
|
||||
for ch in channels:
|
||||
scope.set_data_source(ch)
|
||||
preambles.append(scope.query_wfmoutpre())
|
||||
return preambles
|
||||
|
||||
|
||||
def capture_background(scope, should_abort=lambda: False,
|
||||
on_status=lambda msg: None) -> bytes:
|
||||
"""Capture one CH1 waveform averaged over BACKGROUND_AVERAGES shots.
|
||||
|
||||
The scope auto-stops after the sequence; poll ACQuire:STATE until it
|
||||
does rather than assuming a duration.
|
||||
"""
|
||||
on_status(f"Capturing background waveform ({BACKGROUND_AVERAGES}-average) …")
|
||||
scope.set_acquire_mode("AVERAGE")
|
||||
scope.write(f"ACQuire:NUMAVg {BACKGROUND_AVERAGES}")
|
||||
scope.write("ACQuire:STOPAfter SEQuence")
|
||||
scope.set_data_source(1)
|
||||
scope.write("ACQuire:STATE RUN")
|
||||
|
||||
deadline = time.time() + BACKGROUND_TIMEOUT_S
|
||||
while time.time() < deadline:
|
||||
if should_abort():
|
||||
break
|
||||
if scope.query("ACQuire:STATE?").strip() == "0":
|
||||
break
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
scope.write("ACQuire:STATE STOP")
|
||||
on_status("Warning: background average timed out; stopping early.")
|
||||
time.sleep(0.1)
|
||||
return scope.transfer_curve()
|
||||
|
||||
|
||||
def configure_scan_trigger(scope) -> None:
|
||||
"""Switch to the scan-time logic-AND trigger (CH2 HIGH AND CH3 HIGH).
|
||||
|
||||
CH3 is the BBD202 TRIGOUT_MAXV gate, so frames only accumulate while the
|
||||
stage is at full scan velocity.
|
||||
"""
|
||||
scope.write("ACQuire:STOPAfter RUNSTop")
|
||||
scope.set_acquire_mode("SAMPLE")
|
||||
scope.set_fastframe_state(True)
|
||||
|
||||
scope.write("TRIGger:A:TYPe LOGIc")
|
||||
scope.write("TRIGger:A:LOGIc:FUNCtion AND")
|
||||
scope.set_trigger_level(2, TRIG_LEVEL_V)
|
||||
scope.set_trigger_level(3, TRIG_LEVEL_V)
|
||||
scope.write("TRIGger:A:LOGICPattern:CH2 HIGH")
|
||||
scope.write("TRIGger:A:LOGICPattern:CH3 HIGH")
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def arm_row(scope) -> None:
|
||||
"""Start acquisition for one scan row."""
|
||||
scope.write("ACQuire:STATE RUN")
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def finish_row(scope) -> None:
|
||||
"""Wait for trailing frames, then stop acquisition."""
|
||||
time.sleep(0.2)
|
||||
scope.write("ACQuire:STATE STOP")
|
||||
|
||||
|
||||
def frames_acquired(scope) -> int:
|
||||
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
|
||||
|
||||
def transfer_channel(scope, ch: int) -> list[bytes]:
|
||||
"""Fetch one channel's FastFrame block as raw int8 frames."""
|
||||
scope.set_data_source(ch)
|
||||
return scope.transfer_fastframe(parse=False)
|
||||
|
||||
|
||||
def frame_means(waveforms: list[bytes]) -> list[float]:
|
||||
"""Per-frame DC mean of a raw int8 FastFrame block.
|
||||
|
||||
numpy over the joined buffer: the per-frame struct.unpack this replaces
|
||||
allocated a tuple of Python ints per frame (~16k frames per row).
|
||||
"""
|
||||
if not waveforms:
|
||||
return []
|
||||
n = len(waveforms[0])
|
||||
if n == 0 or any(len(w) != n for w in waveforms):
|
||||
# Ragged block (shouldn't happen) — fall back to per-frame means.
|
||||
return [float(np.frombuffer(w, dtype=np.int8).mean()) if len(w) else 0.0
|
||||
for w in waveforms]
|
||||
block = np.frombuffer(b"".join(waveforms), dtype=np.int8).reshape(len(waveforms), n)
|
||||
return block.mean(axis=1, dtype=np.float32).tolist()
|
||||
Reference in New Issue
Block a user