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
+87
View File
@@ -0,0 +1,87 @@
"""GR rotation axis: the T3R configuration and move policy for scanning.
Qt-free façade over hardware.t3r_driver.T3RDriver that owns the drive
settings the scan depends on, so the engine never re-derives them.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RotationSettings:
"""Drive settings for the GR axis during a scan."""
microsteps: int = 8 # microsteps/full-step on the GR axis (ch3)
velocity: int = 4000 # steps/s for inter-angle moves
accel: int = 2000 # steps/s² for inter-angle moves
run_current_ma: int = 1200 # drive current while moving
hold_current_ma: int = 400 # standstill current
ihold_delay: int = 6 # run→hold current ramp delay (TMC IHOLDDELAY units)
# The sample rotates CW instead of CCW to clear wiring and avoid a stall.
rotation_sign: int = -1
DEFAULT_ROTATION = RotationSettings()
class RotationAxis:
"""Blocking rotation control for the GR axis.
``configure()`` must run before any move: ``steps_for_angle()`` assumes
the configured microstep setting, so the device has to be told to match
rather than trusting whatever the T3R panel or firmware default left it
at.
"""
def __init__(self, driver, settings: RotationSettings = DEFAULT_ROTATION):
self.driver = driver
self.settings = settings
self._current_deg = 0.0
@property
def is_available(self) -> bool:
return self.driver is not None and self.driver.is_open
@property
def current_deg(self) -> float:
return self._current_deg
def configure(self) -> None:
s = self.settings
ch = self.driver.GR_AXIS_CH
self.driver.set_microstep(ch, s.microsteps)
self.driver.set_current(ch, s.run_current_ma, s.hold_current_ma, s.ihold_delay)
self.driver.enable(ch)
def estimate_move_secs(self, delta_deg: float) -> float:
"""Trapezoidal move time: cruise + accel/decel ramps."""
s = self.settings
steps = abs(self.driver.steps_for_angle(delta_deg, s.microsteps))
return steps / s.velocity + s.velocity / s.accel
def rotate_to(self, angle_deg: float, timeout_margin_s: float = 5.0) -> float:
"""Rotate to an absolute angle and block until the move completes.
Returns the estimated move time (for status reporting). Waits on the
driver's MOTION_DONE event rather than sleeping for a guessed
duration; falls back to the estimate only if the event never arrives.
"""
delta_deg = angle_deg - self._current_deg
if abs(delta_deg) <= 0.001:
return 0.0
s = self.settings
est_secs = self.estimate_move_secs(delta_deg)
self.driver.rotate_stage(delta_deg, s.microsteps, s.velocity, s.accel)
if not self.driver.wait_motion_done(self.driver.GR_AXIS_CH,
est_secs + timeout_margin_s):
logger.warning(
"GR axis did not report MOTION_DONE within %.1f s for a "
"%.1f° move; continuing", est_secs + timeout_margin_s, delta_deg)
self._current_deg = angle_deg
return est_secs
def return_to_zero(self) -> float:
return self.rotate_to(0.0)
+393
View File
@@ -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)
+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)
+167
View File
@@ -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()