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>
This commit is contained in:
Thomas Ales
2026-09-02 12:16:20 -05:00
parent 278df9411e
commit d6a56266b7
3 changed files with 143 additions and 20 deletions
+15
View File
@@ -45,5 +45,20 @@ class TriggerBitsServo(IntFlag):
TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY
# Gate off: no trigger-out function selected, so the pin idles inactive.
#
# Treat this as unverified until it has been checked on the rig. §7.6 of
# docs/hardware/BBD203_Communications_Protocol.md documents `mode` as an
# enumeration capping at 0x11, which flatly contradicts the bitmask this
# driver actually sends (TRIGOUT_MAXV = 0x90, known working), so the doc
# cannot settle what makes the pin idle low. Under the bitmask reading 0x00
# clears everything and the pin sits low. If instead TRIGOUT_HIGH is an
# active-high *polarity* bit, clearing it means active-low and the pin idles
# HIGH — which in burst mode floods the acquisition with flyback frames.
# ScanEngine's gate-off preflight catches that; if it trips, change this to
# TriggerBitsServo.TRIGOUT_HIGH.
TRIGOUT_GATE_OFF = TriggerBitsServo(0)
+40 -1
View File
@@ -7,7 +7,7 @@
import time import time
from threading import Thread, Event from threading import Thread, Event
from queue import Queue, Empty from queue import Queue, Empty
from .apt_constants import StatusBits, TriggerBitsServo from .apt_constants import StatusBits, TriggerBitsServo, TRIGOUT_GATE_OFF
from .apt_messages import APTProtocol from .apt_messages import APTProtocol
from .serial_comms import SerialSnooper from .serial_comms import SerialSnooper
@@ -465,3 +465,42 @@ class ThorlabsServoDriver():
def set_trigger_trigout_maxv(self, axis): def set_trigger_trigout_maxv(self, axis):
'''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).''' '''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV) self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV)
def set_trigger_gate_off(self, axis):
'''Drive the trigger output inactive, so no pulses reach the gate.'''
self.set_trigger(axis, TRIGOUT_GATE_OFF)
def arm_scan_gate(self, axis, armed, verify=True):
'''
arm_scan_gate(axis, armed): Arms or drops the max-velocity trigger
output the oscilloscope AND-gate uses.
Burst acquisition runs one scope acquisition across many rows, so
the gate must be armed only for the acquiring pass and dropped for
the flyback — otherwise the return move hits max velocity and
injects frames between rows.
'''
mode = TriggerBitsServo.TRIGOUT_MAXV if armed else TRIGOUT_GATE_OFF
if verify:
self.set_trigger_verified(axis, mode)
else:
self.set_trigger(axis, mode)
def set_trigger_verified(self, axis, mode, timeout=5.0, retries=2):
'''
set_trigger_verified(axis, mode): Sets the trigger mode and reads
it back to confirm it landed.
set_trigger is fire-and-forget over the shared TX queue. Burst
acquisition toggles the gate between every row, and a dropped
change there silently fills the acquisition with flyback frames —
so confirm rather than assume.
'''
for _ in range(retries + 1):
self.set_trigger(axis, mode)
if int(self.get_trigger(axis, timeout=timeout)) == int(mode):
return
raise RuntimeError(
f"Axis 0x{axis:02X} did not accept trigger mode 0x{int(mode):02X} "
f"after {retries + 1} attempts"
)
+88 -19
View File
@@ -183,6 +183,10 @@ class TektronixOscilloscopeBase:
self.write(f"HORizontal:FASTframe:COUNt {count}") 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): def get_record_length(self):
"""Query the current horizontal record length.""" """Query the current horizontal record length."""
response = self.query("HORizontal:MODe:RECOrdlength?") response = self.query("HORizontal:MODe:RECOrdlength?")
@@ -361,6 +365,21 @@ class TektronixOscilloscopeBase:
self.write(f"DATa:SOUrce {source}") 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): def query_wfmoutpre(self):
"""Query all waveform output preamble parameters.""" """Query all waveform output preamble parameters."""
return self.query("WFMOutpre?") return self.query("WFMOutpre?")
@@ -455,6 +474,43 @@ class TektronixOscilloscopeBase:
return waveforms 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'): def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'):
"""Parse raw curve data into integer array.""" """Parse raw curve data into integer array."""
if byte_count not in (1, 2): if byte_count not in (1, 2):
@@ -526,7 +582,19 @@ class TektronixOscilloscopeBase:
return response.decode('ascii').strip() return response.decode('ascii').strip()
def read_raw(self): 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. Read raw binary data from the instrument.
@@ -535,6 +603,10 @@ class TektronixOscilloscopeBase:
where N is a digit indicating how many digits follow, where N is a digit indicating how many digits follow,
and those digits specify the length of the data block. 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: Returns:
bytes: Raw binary data (without IEEE 488.2 header) bytes: Raw binary data (without IEEE 488.2 header)
@@ -564,27 +636,24 @@ class TektronixOscilloscopeBase:
num_digits = int(length_of_length) num_digits = int(length_of_length)
# Read the data length if num_digits == 0:
length_bytes = self.socket.recv(num_digits) # Indeterminate length: no byte count follows, and the EOI that
if len(length_bytes) != num_digits: # would delimit it does not exist on a raw socket.
raise RuntimeError("Failed to read data length") 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_length = int(length_bytes) data = self._recv_exact(data_length)
# Read the actual binary data # Read the trailing newline / block separator
chunks = [] self._recv_exact(1)
remaining = data_length
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)
# Read the trailing newline return data
self.socket.recv(1)
return b''.join(chunks)
@property @property
def is_connected(self): def is_connected(self):