From d6a56266b7f4f527c8fb2a29308d51917dbf9608 Mon Sep 17 00:00:00 2001 From: Thomas Ales Date: Wed, 2 Sep 2026 12:16:20 -0500 Subject: [PATCH] Driver support for multi-row FastFrame bursts; fix read_raw block parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hardware/pybbd202/apt_constants.py | 15 ++++ hardware/pybbd202/bbd20x.py | 41 ++++++++++- hardware/tektronix_base.py | 107 ++++++++++++++++++++++++----- 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/hardware/pybbd202/apt_constants.py b/hardware/pybbd202/apt_constants.py index c0a1959..315ae1c 100755 --- a/hardware/pybbd202/apt_constants.py +++ b/hardware/pybbd202/apt_constants.py @@ -45,5 +45,20 @@ class TriggerBitsServo(IntFlag): 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) + + diff --git a/hardware/pybbd202/bbd20x.py b/hardware/pybbd202/bbd20x.py index 0e7b6a6..c40e73d 100755 --- a/hardware/pybbd202/bbd20x.py +++ b/hardware/pybbd202/bbd20x.py @@ -7,7 +7,7 @@ import time from threading import Thread, Event 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 .serial_comms import SerialSnooper @@ -465,3 +465,42 @@ class ThorlabsServoDriver(): def set_trigger_trigout_maxv(self, axis): '''Set trigger output high + pulse at max velocity (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" + ) diff --git a/hardware/tektronix_base.py b/hardware/tektronix_base.py index dd3153b..b1184c8 100755 --- a/hardware/tektronix_base.py +++ b/hardware/tektronix_base.py @@ -183,6 +183,10 @@ class TektronixOscilloscopeBase: 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?") @@ -361,6 +365,21 @@ class TektronixOscilloscopeBase: 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?") @@ -455,6 +474,43 @@ class TektronixOscilloscopeBase: 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): @@ -526,7 +582,19 @@ class TektronixOscilloscopeBase: 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. @@ -535,6 +603,10 @@ class TektronixOscilloscopeBase: 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) @@ -564,27 +636,24 @@ class TektronixOscilloscopeBase: num_digits = int(length_of_length) - # Read the data length - length_bytes = self.socket.recv(num_digits) - if len(length_bytes) != num_digits: - raise RuntimeError("Failed to read data 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_length = int(length_bytes) + data = self._recv_exact(data_length) - # Read the actual binary data - chunks = [] - 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 / block separator + self._recv_exact(1) - # Read the trailing newline - self.socket.recv(1) - - return b''.join(chunks) + return data @property def is_connected(self):