Files
scanengine-3/core/scope_sras.py
T
Thomas Ales aa06fa1460 Per-angle background capture: v7/v11 .sras layout
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.

Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.

Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.

v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.

Two fixes made along the way:

  * QtScanController never accepted file_version, so every scan launched
    from the app raised TypeError at construction.
  * angle_status() left its cursor parked at the frontier, so every angle
    past it reported the frontier's own data_offset — which handed a resumed
    scan the same write position for several angles. Two recorded offsets in
    tests/golden/sras_expected.json are corrected accordingly.

The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:44:25 -05:00

157 lines
5.4 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_background_trigger(scope) -> None:
"""Program the edge trigger used for a background capture.
Edge trigger on the rising edge of CH2 (laser pulse), FastFrame off so
the capture runs as a single record. Every angle starts with a fresh
background, so the scan comes back here between angles from the
logic-AND trigger configure_scan_trigger leaves behind.
"""
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)
def configure_acquisition(scope) -> int:
"""Program the edge trigger and timebase; returns samples per frame."""
configure_background_trigger(scope)
# 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)