Files
scanengine-3/core/scope_sras.py
Thomas Ales 116c9c07c7 Burst acquisition: many whole rows per FastFrame acquisition
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
and the transfer is one IEEE-488.2 block read per frame (~16k frames a row).
Burst mode runs one FastFrame acquisition across as many complete rows as the
scope's frame memory holds and pulls each burst in a single CURVe?
transaction, amortising the round trip over the whole burst.

It is opt-in (ScanEngine(burst_mode=...), default False) and writes
byte-identical files to the per-row path — test_burst_and_serial_produce_
identical_files runs the same plan both ways and compares the bytes, which is
the property the whole feature rests on.

core/scope_burst.py — the new policy module. Everything that computes rather
than talks to hardware is a free function, so sizing and row-splitting are
testable without a rig: rows_per_burst() (rounds down, since a partial row
can't be written, and clamps to a transfer-buffer budget), split_row_counts(),
normalize_row(), frame_means_block().

The hard part is that a burst carries no row markers — the scope returns one
flat run of frames. Boundaries come from ACQuire:NUMFRAMESACQuired? sampled
after each acquiring pass while the stage gate is already low, rebased on a
baseline read back at RUN rather than assuming the counter resets. A counter
that goes backwards means the acquisition restarted mid-burst and is now a
hard error instead of silently misattributing every later row.

core/scan_engine.py — the row loop splits into _scan_rows_serial and
_scan_rows_burst. The wire is channel-major and the file is row-major with
channels inner, so _write_burst deinterleaves by writing one channel at a
time to strided offsets; peak memory stays at a single channel's burst
instead of the whole thing.

_gate_off_preflight is what makes this trustworthy on real hardware. The BBD
value that idles the trigger output low is not settled by the protocol docs
(see TRIGOUT_GATE_OFF), and getting it wrong fills every burst with flyback
frames that silently shift the file. The scope already measures the gate on
CH3, so the check needs no bench probe: one gated-off flyback must acquire
nothing, and one gated pass must acquire something — the second half is what
stops a dark laser from making the first half pass vacuously. It runs once
per scan and costs two row-times.

Two fixes fall out of this work and apply to both paths:
- Rows are now squared up to the declared n_frames (short rows zero-padded,
  long rows truncated, both warned). v6 commits to n_frames per row in the
  header and has no per-row length field, so an over- or under-triggered row
  used to shift every later row in the file.
- The X trigger output is returned to idle in the run() finally block. The
  per-row path left TRIGOUT_MAXV armed for the rest of the session, so the
  gate line kept being driven on every later jog.

core/scope_sras.py — pins DATa:ENCdg RIBinary and DATa:WIDth 1 during setup
instead of inheriting front-panel state. The file header hardcodes
bytes_per_sample=1; a scope left on 2 bytes would have corrupted every frame
written. frames_acquired/frame_means move to scope_burst, where the offset-
based variants serve both paths.

tests/fakes.py — FakeStage and FakeScope are now wired together the way the
rig is: a gated X move at scan velocity feeds frames into a running
acquisition at the real 20 kHz / 100 mm/s rate, direction-agnostic. Both
paths therefore derive frame counts from one model, which is what makes the
byte-identity comparison meaningful, and a gate the engine forgets to drop
shows up as extra frames instead of passing silently. Frame content is a
function of (channel, index) alone, so the same frame sequence yields the
same bytes however it is chopped into transfers.

87 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:17:24 -05:00

150 lines
5.1 KiB
Python

"""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
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)
# Pin the transfer format instead of inheriting front-panel state — the
# file header hardcodes bytes_per_sample=1, and a scope left on 2 bytes
# would corrupt every frame written.
scope.set_data_encoding("RIBinary")
scope.set_data_width(1)
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 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)