116c9c07c7
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>
150 lines
5.1 KiB
Python
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)
|