Files
scanengine-3/hardware/tektronix_base.py
T
Thomas Ales d6a56266b7 Driver support for multi-row FastFrame bursts; fix read_raw block parsing
Groundwork for burst acquisition: the scope needs to report and transfer a
whole multi-row FastFrame acquisition, and the BBD needs to gate its trigger
output per row rather than staying armed for the scan.

tektronix_base
- get_fastframe_max_frames() exposes HORizontal:FASTframe:MAXFRames?, which
  is what sizes a burst once the horizontal settings are fixed.
- transfer_fastframe_bulk() pulls a burst as one contiguous buffer. Unlike
  transfer_fastframe it does not assume how the scope frames the response:
  it accumulates until the expected byte count is reached, so one large IEEE
  block and one block per frame both work.
- set_data_encoding() / set_data_width() make the transfer format settable
  instead of inherited from whatever the front panel was left on.
- read_raw() had two real defects. The length-digit read used a bare recv()
  and only checked the length afterwards, so a short read raised "Failed to
  read data length" on a perfectly good transfer; it now goes through a
  _recv_exact() helper, as does the trailing separator. And a #0
  indeterminate-length block was parsed as int("") -> ValueError. #0 is
  normally delimited by EOI, which a raw socket never sees, so read_raw now
  takes expected_bytes to size it. The bulk transfer relies on this.

pybbd202
- arm_scan_gate(axis, armed) raises and drops the max-velocity trigger
  output the scope's AND-gate uses. A burst spans several rows with the
  scope running throughout, so the gate must be low for the flyback or the
  return move reaches max velocity and injects frames between rows.
- set_trigger_verified() reads the mode back after setting it. set_trigger
  is fire-and-forget over the shared TX queue; burst mode toggles the gate
  between every row, where a dropped change silently corrupts the file
  rather than failing loudly.
- set_trigger_gate_off() so the scan can leave the output idle on exit.
- TRIGOUT_GATE_OFF is deliberately marked unverified. §7.6 of the BBD203
  protocol doc describes `mode` as an enumeration capping at 0x11, which
  contradicts the bitmask this driver actually sends (TRIGOUT_MAXV = 0x90,
  known working), so the doc cannot settle which value idles the pin low.
  The engine's preflight check resolves it on the rig instead.

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

671 lines
24 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Base class for Tektronix oscilloscope control with raw SCPI command support.
"""
import socket
class TektronixOscilloscopeBase:
"""
Base class for Tektronix oscilloscope instruments.
Provides low-level SCPI command interface for fast data transfer.
"""
# Valid acquisition modes (short and long forms)
# SCPI uses capital letters for short form, scope responds with full uppercase
ACQUIRE_MODES = {
'SAMPLE': ['SAM', 'SAMPLE', 'SAMple'],
'PEAKDETECT': ['PEAK', 'PEAKDETECT', 'PEAKdetect'],
'HIRES': ['HIR', 'HIRES', 'HIRes'],
'AVERAGE': ['AVE', 'AVERAGE', 'AVErage'],
'ENVELOPE': ['ENV', 'ENVELOPE', 'ENVelope']
}
# Valid trigger coupling modes
TRIGGER_COUPLING = {
'DC': ['DC'],
'HFREJ': ['HFRej', 'HFREJ'],
'LFREJ': ['LFRej', 'LFREJ'],
'NOISEREJ': ['NOISErej', 'NOISEREJ']
}
# Valid trigger slope options
TRIGGER_SLOPE = {
'RISE': ['RISe', 'RISE'],
'FALL': ['FALL'],
'EITHER': ['EITher', 'EITHER']
}
# Valid trigger modes
TRIGGER_MODE = {
'AUTO': ['AUTO'],
'NORMAL': ['NORMal', 'NORMAL']
}
# Valid channel coupling modes
CHANNEL_COUPLING = {
'AC': ['AC'],
'DC': ['DC']
}
# Valid channel termination values (ohms)
CHANNEL_TERMINATION = [50, 1000000]
# Valid data encoding formats
DATA_ENCODING = {
'ASCII': ['ASCIi', 'ASCII'],
'RIBINARY': ['RIBinary', 'RIBINARY'], # Signed integer, MSB first (recommended)
'RPBINARY': ['RPBinary', 'RPBINARY'], # Positive integer, MSB first
'FPBINARY': ['FPBinary', 'FPBINARY'], # Floating point binary
'SRIBINARY': ['SRIbinary', 'SRIBINARY'], # Signed integer, byte-swapped
'SRPBINARY': ['SRPbinary', 'SRPBINARY'], # Positive integer, byte-swapped
'SFPBINARY': ['SFPbinary', 'SFPBINARY'] # Floating point binary, byte-swapped
}
# Valid waveform output preamble encoding
WFMOUTPRE_ENCODING = {
'BINARY': ['BINary', 'BINARY'],
'ASCII': ['ASCii', 'ASCII']
}
# Valid byte order options
BYTE_ORDER = {
'LSB': ['LSB'], # Least significant byte first
'MSB': ['MSB'] # Most significant byte first
}
def __init__(self, resource_name=None, port=4000, timeout=5.0, terminator='\n'):
"""
Initialize the oscilloscope base class.
Args:
resource_name: IP address or hostname of the instrument
port: TCP port for SCPI communication (default: 4000)
timeout: Socket timeout in seconds (default: 5.0)
terminator: Line terminator for SCPI commands (default: '\n')
"""
self.resource_name = resource_name
self.port = port
self.timeout = timeout
self.terminator = terminator
self.socket = None
self._connected = False
def connect(self):
"""Establish TCP socket connection to the oscilloscope."""
if not self.resource_name:
raise ValueError("resource_name (IP address/hostname) must be provided")
if self._connected:
return
try:
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.settimeout(self.timeout)
self.socket.connect((self.resource_name, self.port))
self._connected = True
except socket.error as e:
self.socket = None
self._connected = False
raise ConnectionError(
f"Failed to connect to {self.resource_name}:{self.port} - {e}") from e
def disconnect(self):
"""
Close TCP socket connection to the oscilloscope.
"""
if self.socket:
try:
self.socket.close()
except Exception:
pass
finally:
self.socket = None
self._connected = False
def _normalize_channel(self, channel):
"""Normalize channel input to integer (1-4)."""
if isinstance(channel, str):
channel_upper = channel.upper()
if not (channel_upper.startswith('CH') and len(channel_upper) == 3 and channel_upper[2].isdigit()):
raise ValueError(f"Invalid channel: {channel}. Must be CH1, CH2, CH3, or CH4")
channel = int(channel_upper[2])
if not isinstance(channel, int) or channel < 1 or channel > 4:
raise ValueError(f"Invalid channel: {channel}. Must be 1-4")
return channel
def set_acquire_mode(self, mode):
"""Set the acquisition mode."""
# Normalize mode to uppercase for comparison
mode_upper = mode.upper()
# Check if mode is valid (any short or long form)
valid = False
for variants in self.ACQUIRE_MODES.values():
if mode_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.ACQUIRE_MODES.values():
valid_options.extend(variants)
raise ValueError(f"Invalid acquisition mode: {mode}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"ACQuire:MODe {mode}")
def get_fastframe_state(self):
"""Query the current FastFrame state."""
response = self.query("HORizontal:FASTframe:STATE?")
return int(response)
def set_fastframe_state(self, state):
"""Enable or disable FastFrame mode."""
# Convert boolean to int if needed
if isinstance(state, bool):
state = 1 if state else 0
# Validate state
if state not in (0, 1):
raise ValueError(f"Invalid FastFrame state: {state}. Must be 0 (off) or 1 (active)")
self.write(f"HORizontal:FASTframe:STATE {state}")
def set_fastframe_count(self, count):
"""Set the number of FastFrame frames."""
# Validate count
if not isinstance(count, int) or count <= 0:
raise ValueError(f"Invalid frame count: {count}. Must be a positive integer")
self.write(f"HORizontal:FASTframe:COUNt {count}")
def get_fastframe_max_frames(self):
"""Query how many FastFrame frames the current horizontal settings allow."""
return int(self.query("HORizontal:FASTframe:MAXFRames?"))
def get_record_length(self):
"""Query the current horizontal record length."""
response = self.query("HORizontal:MODe:RECOrdlength?")
return int(response)
def set_sample_rate(self, rate):
"""Set the horizontal sample rate."""
# Validate rate
if not isinstance(rate, (int, float)) or rate <= 0:
raise ValueError(f"Invalid sample rate: {rate}. Must be a positive number")
self.write(f"HORizontal:MODe:SAMPLERate {rate}")
def set_trigger_slope(self, slope):
"""Set the trigger slope."""
slope_upper = slope.upper()
# Check if slope is valid
valid = False
for variants in self.TRIGGER_SLOPE.values():
if slope_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.TRIGGER_SLOPE.values():
valid_options.extend(variants)
raise ValueError(f"Invalid trigger slope: {slope}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"TRIGger:A:EDGE:SLOpe {slope}")
def set_trigger_source(self, source):
"""Set the trigger source."""
# Convert integer to channel string if needed
if isinstance(source, int):
if source < 1 or source > 4:
raise ValueError(f"Invalid trigger source: {source}. Must be 1-4")
source = f"CH{source}"
# Validate channel format
source_upper = source.upper()
if not (source_upper.startswith('CH') and len(source_upper) == 3 and source_upper[2].isdigit()):
raise ValueError(f"Invalid trigger source: {source}. Must be CH1, CH2, CH3, or CH4")
channel_num = int(source_upper[2])
if channel_num < 1 or channel_num > 4:
raise ValueError(f"Invalid trigger source: {source}. Channel must be 1-4")
self.write(f"TRIGger:A:EDGE:SOUrce {source}")
def set_trigger_level(self, channel, level):
"""Set the trigger level for a specific channel."""
# Convert to channel number if string
if isinstance(channel, str):
channel_upper = channel.upper()
if not (channel_upper.startswith('CH') and len(channel_upper) == 3 and channel_upper[2].isdigit()):
raise ValueError(f"Invalid channel: {channel}. Must be CH1, CH2, CH3, or CH4")
channel = int(channel_upper[2])
# Validate channel number
if not isinstance(channel, int) or channel < 1 or channel > 4:
raise ValueError(f"Invalid channel: {channel}. Must be 1-4")
# Validate level
if not isinstance(level, (int, float)):
raise ValueError(f"Invalid trigger level: {level}. Must be a number")
self.write(f"TRIGger:A:LEVel:CH{channel} {level}")
def set_trigger_mode(self, mode):
"""Set the trigger mode."""
mode_upper = mode.upper()
# Check if mode is valid
valid = False
for variants in self.TRIGGER_MODE.values():
if mode_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.TRIGGER_MODE.values():
valid_options.extend(variants)
raise ValueError(f"Invalid trigger mode: {mode}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"TRIGger:A:MODe {mode}")
# ========== Channel Control Methods ==========
def set_channel_bandwidth(self, channel, bandwidth):
"""Set the bandwidth for a specific channel."""
channel = self._normalize_channel(channel)
self.write(f"CH{channel}:BANdwidth {bandwidth}")
def set_channel_coupling(self, channel, coupling):
"""Set the coupling mode for a specific channel."""
channel = self._normalize_channel(channel)
coupling_upper = coupling.upper()
# Validate coupling
valid = False
for variants in self.CHANNEL_COUPLING.items():
if coupling_upper in [v.upper() for v in variants]:
valid = True
break
if not valid:
valid_options = []
for variants in self.CHANNEL_COUPLING.values():
valid_options.extend(variants)
raise ValueError(f"Invalid coupling mode: {coupling}. "
f"Valid options: {', '.join(valid_options)}")
self.write(f"CH{channel}:COUPling {coupling}")
def set_channel_label_name(self, channel, name):
"""Set the label name for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(name, str):
raise ValueError(f"Invalid label name: {name}. Must be a string")
# Need to quote the string for SCPI
self.write(f'CH{channel}:LABel:NAMe "{name}"')
def set_channel_position(self, channel, position):
"""Set the vertical position for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(position, (int, float)):
raise ValueError(f"Invalid position: {position}. Must be a number")
self.write(f"CH{channel}:POSition {position}")
def set_channel_scale(self, channel, scale):
"""Set the vertical scale for a specific channel."""
channel = self._normalize_channel(channel)
if not isinstance(scale, (int, float)) or scale <= 0:
raise ValueError(f"Invalid scale: {scale}. Must be a positive number")
self.write(f"CH{channel}:SCAle {scale}")
def set_channel_termination(self, channel, termination):
"""Set the termination for a specific channel."""
channel = self._normalize_channel(channel)
if termination not in self.CHANNEL_TERMINATION:
raise ValueError(f"Invalid termination: {termination}. "
f"Valid options: {', '.join(map(str, self.CHANNEL_TERMINATION))}")
self.write(f"CH{channel}:TERmination {termination}")
# ========== Waveform Transfer Methods ==========
def set_data_source(self, source):
"""Set the data source for waveform transfer."""
# Convert integer to channel string if needed
if isinstance(source, int):
if source < 1 or source > 4:
raise ValueError(f"Invalid data source: {source}. Must be 1-4")
source = f"CH{source}"
# Validate channel format
source_upper = source.upper()
if not (source_upper.startswith('CH') and len(source_upper) == 3 and source_upper[2].isdigit()):
raise ValueError(f"Invalid data source: {source}. Must be CH1, CH2, CH3, or CH4")
channel_num = int(source_upper[2])
if channel_num < 1 or channel_num > 4:
raise ValueError(f"Invalid data source: {source}. Channel must be 1-4")
self.write(f"DATa:SOUrce {source}")
def set_data_encoding(self, encoding):
"""Set the curve transfer encoding (e.g. RIBinary = signed int, MSB first)."""
valid = ('ASCII', 'RIBinary', 'RPBinary', 'FPBinary',
'SRIbinary', 'SRPbinary', 'SFPbinary')
if encoding.upper() not in [v.upper() for v in valid]:
raise ValueError(f"Invalid data encoding: {encoding}. "
f"Valid options: {', '.join(valid)}")
self.write(f"DATa:ENCdg {encoding}")
def set_data_width(self, width):
"""Set bytes per sample for curve transfers."""
if width not in (1, 2):
raise ValueError(f"Invalid data width: {width}. Must be 1 or 2")
self.write(f"DATa:WIDth {width}")
def query_wfmoutpre(self):
"""Query all waveform output preamble parameters."""
return self.query("WFMOutpre?")
def transfer_curve(self):
"""
Transfer waveform curve data from the oscilloscope.
This method reads binary curve data according to the current DATa:SOUrce,
DATa:ENCdg, and WFMOutpre settings.
Returns:
bytes: Raw binary curve data
Raises:
RuntimeError: If not connected or transfer fails
"""
# Send the CURVe? query
self.write("CURVe?")
# Read the binary data using IEEE 488.2 format
return self.read_raw()
def transfer_fastframe(self, parse=True, byte_count=1, signed=True, byte_order='MSB'):
"""
Transfer all FastFrame waveform data from the oscilloscope.
When FastFrame is enabled, a single CURVe? query returns all frames
as sequential IEEE 488.2 binary blocks. This method reads all frames
efficiently in a single operation.
Args:
parse: If True, parse the raw bytes into integer arrays (default: True)
byte_count: Number of bytes per sample for parsing (1 or 2)
signed: True for signed integer parsing, False for unsigned
byte_order: 'MSB' or 'LSB' for byte order when parsing
Returns:
list: List of waveforms. If parse=True, each waveform is a list of
integer ADC values. If parse=False, each waveform is raw bytes.
Raises:
RuntimeError: If not connected, FastFrame not enabled, or transfer fails
ValueError: If parsing parameters are invalid
Example:
# Enable FastFrame with 1000 frames
scope.set_fastframe_state(True)
scope.set_fastframe_count(1000)
# Acquire data
scope.write("ACQuire:STATE RUN")
time.sleep(2)
scope.write("ACQuire:STATE STOP")
# Transfer all frames at once
waveforms = scope.transfer_fastframe()
print(f"Got {len(waveforms)} frames, {len(waveforms[0])} points each")
"""
# Verify FastFrame is enabled
if not self.get_fastframe_state():
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
# Use the number of frames actually acquired, not the configured maximum.
# If the stage stops early, fewer triggers arrive and the scope captures
# fewer frames than configured — reading the configured count would block.
acquired = int(self.query("ACQuire:NUMFRAMESACQuired?"))
if acquired <= 0:
raise RuntimeError("Scope acquired 0 FastFrame frames — no data to read")
frame_count = acquired
# Send a single CURVe? query - scope will return all frames
self.write("CURVe?")
# Read all frames as sequential IEEE 488.2 binary blocks
waveforms = []
for _ in range(frame_count):
curve_bytes = self.read_raw()
if parse:
waveform = self.parse_curve_data(
curve_bytes,
byte_count=byte_count,
signed=signed,
byte_order=byte_order
)
else:
waveform = curve_bytes
waveforms.append(waveform)
return waveforms
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
bytes_per_sample=1):
"""Transfer a whole FastFrame burst as one contiguous buffer.
Unlike transfer_fastframe this does not care how the scope frames the
response — it accumulates blocks until it has the expected byte count,
so one large IEEE block and one block per frame both work. Returns a
bytearray of frame_count * samples_per_frame * bytes_per_sample bytes.
"""
if not self.get_fastframe_state():
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
expected = frame_count * samples_per_frame * bytes_per_sample
if expected <= 0:
raise RuntimeError(
f"Nothing to transfer: {frame_count} frames × "
f"{samples_per_frame} samples × {bytes_per_sample} bytes"
)
self.write("CURVe?")
buf = bytearray()
while len(buf) < expected:
block = self.read_raw(expected_bytes=expected - len(buf))
if not block:
raise RuntimeError(
f"Scope returned an empty block {len(buf)}/{expected} bytes "
"into the burst transfer"
)
buf += block
if len(buf) != expected:
raise RuntimeError(
f"Burst transfer overran: got {len(buf)} bytes, expected {expected}"
)
return buf
def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'):
"""Parse raw curve data into integer array."""
if byte_count not in (1, 2):
raise ValueError(f"Invalid byte count: {byte_count}. Must be 1 or 2")
if byte_order not in ('MSB', 'LSB'):
raise ValueError(f"Invalid byte order: {byte_order}. Must be 'MSB' or 'LSB'")
values = []
if byte_count == 1:
# 8-bit data
for byte in curve_bytes:
if signed:
# Convert unsigned byte to signed
value = byte if byte < 128 else byte - 256
else:
value = byte
values.append(value)
else:
# 16-bit data
num_samples = len(curve_bytes) // 2
for i in range(num_samples):
byte1 = curve_bytes[i * 2]
byte2 = curve_bytes[i * 2 + 1]
if byte_order == 'MSB':
# Most significant byte first
value = (byte1 << 8) | byte2
else:
# Least significant byte first
value = (byte2 << 8) | byte1
if signed:
# Convert unsigned to signed
if value >= 32768:
value = value - 65536
values.append(value)
return values
def write(self, command):
"""Send a raw SCPI command to the instrument."""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
if not command.endswith(self.terminator):
command += self.terminator
self.socket.sendall(command.encode('ascii'))
def query(self, command):
"""Send a SCPI query and return the response."""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
self.write(command)
response = b''
terminator_bytes = self.terminator.encode('ascii')
while True:
chunk = self.socket.recv(4096)
if not chunk:
break
response += chunk
if terminator_bytes in chunk:
break
return response.decode('ascii').strip()
def _recv_exact(self, count):
"""Read exactly `count` bytes; recv() is free to return fewer."""
chunks = []
remaining = count
while remaining > 0:
chunk = self.socket.recv(min(remaining, 65536))
if not chunk:
raise RuntimeError("Connection closed while reading data")
chunks.append(chunk)
remaining -= len(chunk)
return b''.join(chunks)
def read_raw(self, expected_bytes=None):
"""
Read raw binary data from the instrument.
Useful for fast waveform data transfer. Handles IEEE 488.2 binary block format.
Format: #<N><digits><data><newline>
where N is a digit indicating how many digits follow,
and those digits specify the length of the data block.
`#0` announces an indeterminate-length block, normally delimited by EOI
— which a raw socket never sees. Pass expected_bytes to say how many
bytes to take in that case.
Returns:
bytes: Raw binary data (without IEEE 488.2 header)
Raises:
RuntimeError: If not connected or invalid format
socket.error: If communication fails
"""
if not self._connected or not self.socket:
raise RuntimeError("Not connected to instrument")
# Read the '#' character
header = self.socket.recv(1)
if header != b'#':
# Read whatever else is buffered so the error shows the actual response
self.socket.setblocking(False)
try:
more = self.socket.recv(100)
except OSError:
more = b''
self.socket.setblocking(True)
raise RuntimeError(f"Invalid IEEE 488.2 header: expected '#', got {header}, followed by: {more[:50]}")
# Read the digit indicating length of length field
length_of_length = self.socket.recv(1)
if not length_of_length.isdigit():
raise RuntimeError(f"Invalid length specifier: {length_of_length}")
num_digits = int(length_of_length)
if num_digits == 0:
# Indeterminate length: no byte count follows, and the EOI that
# would delimit it does not exist on a raw socket.
if expected_bytes is None:
raise RuntimeError(
"Scope returned an indeterminate-length block (#0); "
"read_raw needs expected_bytes to size it over a socket"
)
data_length = expected_bytes
else:
data_length = int(self._recv_exact(num_digits))
data = self._recv_exact(data_length)
# Read the trailing newline / block separator
self._recv_exact(1)
return data
@property
def is_connected(self):
"""Check if instrument is connected."""
return self._connected
def __enter__(self):
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.disconnect()