From 3f8506f22257e77e4fa41121eb029e05fff55a3c Mon Sep 17 00:00:00 2001 From: ash Date: Wed, 8 Oct 2025 00:01:19 -0400 Subject: [PATCH] thinkrf --- scripts/convert_pyrf_to_python3.sh | 45 ++++++++ src/ria_toolkit_oss/sdr/thinkrf.py | 159 ++++++++++++++++++++++++----- 2 files changed, 181 insertions(+), 23 deletions(-) create mode 100755 scripts/convert_pyrf_to_python3.sh diff --git a/scripts/convert_pyrf_to_python3.sh b/scripts/convert_pyrf_to_python3.sh new file mode 100755 index 0000000..db75300 --- /dev/null +++ b/scripts/convert_pyrf_to_python3.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Fix pyrf Python 3 compatibility +# Run this after: pip install pyrf + +set -e + +VENV_DIR="${1:-venv}" +PYRF_BASE="$VENV_DIR/lib/python3.12/site-packages/pyrf" + +if [ ! -d "$PYRF_BASE" ]; then + echo "❌ pyrf not found at $PYRF_BASE" + echo "Usage: $0 [venv_directory]" + exit 1 +fi + +echo "🔧 Fixing pyrf for Python 3..." + +# Backup originals +cp "$PYRF_BASE/devices/thinkrf.py" "$PYRF_BASE/devices/thinkrf.py.bak" 2>/dev/null || true +cp "$PYRF_BASE/devices/thinkrf_properties.py" "$PYRF_BASE/devices/thinkrf_properties.py.bak" 2>/dev/null || true +cp "$PYRF_BASE/connectors/blocking.py" "$PYRF_BASE/connectors/blocking.py.bak" 2>/dev/null || true + +# Fix thinkrf.py +echo " Fixing thinkrf.py..." +sed -i 's/\.iteritems()/.items()/g' "$PYRF_BASE/devices/thinkrf.py" +sed -i 's/raw_input/input/g' "$PYRF_BASE/devices/thinkrf.py" +# Fix print statements (carefully to handle the format string) +sed -i '884s/.*/ print(fmt % (index, wsa["HOST"], modelstring, wsa["SERIAL"]))/' "$PYRF_BASE/devices/thinkrf.py" +sed -i 's/print "r) Refresh"/print("r) Refresh")/g' "$PYRF_BASE/devices/thinkrf.py" +sed -i 's/print "q) Abort"/print("q) Abort")/g' "$PYRF_BASE/devices/thinkrf.py" +sed -i 's/print "error: invalid selection: '\''%s'\''" % choice/print("error: invalid selection: '\''%s'\''" % choice)/g' "$PYRF_BASE/devices/thinkrf.py" + +# Fix thinkrf_properties.py +echo " Fixing thinkrf_properties.py..." +sed -i 's/\.iteritems()/.items()/g' "$PYRF_BASE/devices/thinkrf_properties.py" + +# Fix blocking.py (socket bytes issue) +echo " Fixing blocking.py..." +sed -i '29s/self._sock_scpi.send(cmd)/self._sock_scpi.send(cmd.encode())/' "$PYRF_BASE/connectors/blocking.py" +sed -i '34s/self._sock_scpi.send(cmd)/self._sock_scpi.send(cmd.encode())/' "$PYRF_BASE/connectors/blocking.py" +# Fix line 37 - replace entire line to avoid double decode +sed -i '37s/.*/ return buf.decode()/' "$PYRF_BASE/connectors/blocking.py" + +echo "✅ pyrf fixed for Python 3!" +echo " Backups saved with .bak extension" diff --git a/src/ria_toolkit_oss/sdr/thinkrf.py b/src/ria_toolkit_oss/sdr/thinkrf.py index 3d50cf7..abdb5a5 100644 --- a/src/ria_toolkit_oss/sdr/thinkrf.py +++ b/src/ria_toolkit_oss/sdr/thinkrf.py @@ -44,6 +44,8 @@ class ThinkRF(SDR): BASE_SAMPLE_RATE = 125_000_000 SUPPORTED_DECIMATIONS = (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024) + MAX_ONBOARD_SAMPLES = 33_500_000 # Confirmed: 512 packets @ dec 1 = 33.5M samples (268ms) + DEFAULT_SPP = 65504 # VRT packet size (samples per packet) def __init__(self, identifier: Optional[str] = None): super().__init__() @@ -108,22 +110,27 @@ class ThinkRF(SDR): gain: int, channel: int, gain_mode: Optional[str] = "absolute", + decimation: Optional[int] = None, ): if channel not in (0, None): raise ValueError("ThinkRF devices expose a single receive channel") stream_mode = getattr(self, "_capture_mode", "block") == "stream" - decimation = self._derive_decimation(sample_rate) - if stream_mode and decimation < self._min_stream_decimation: + # Enforce sample rate / decimation + # Note: decimation parameter takes precedence if provided + actual_decimation, actual_sample_rate = self.enforce_sample_rate(sample_rate, decimation) + + if stream_mode and actual_decimation < self._min_stream_decimation: enforced = self._min_stream_decimation print( "Requested ThinkRF sample rate exceeds typical GigE throughput; " f"enforcing decimation {enforced} for streaming." ) - decimation = enforced - actual_sample_rate = self.BASE_SAMPLE_RATE / decimation - self._decimation = decimation + actual_decimation = enforced + actual_sample_rate = self.BASE_SAMPLE_RATE / actual_decimation + + self._decimation = actual_decimation self.radio.reset() self.radio.scpiset(":SYSTEM:FLUSH") @@ -140,11 +147,11 @@ class ThinkRF(SDR): gain_profile = self._gain_profile if gain_mode and isinstance(gain_mode, str) and gain_mode.upper() in {"LOW", "MEDIUM", "HIGH", "VLOW"}: gain_profile = gain_mode.upper() - self.radio.psfm_gain(gain_profile) + self.radio.gain(gain_profile.lower()) # WSA.gain() expects lowercase - self.radio.decimation(decimation) + self.radio.decimation(actual_decimation) if stream_mode: - self.radio.scpiset(f":SENSE:DECIMATION {decimation}") + self.radio.scpiset(f":SENSE:DECIMATION {actual_decimation}") trigger = self._trigger_config or self._default_trigger(center_frequency) self.radio.trigger(trigger) @@ -152,12 +159,20 @@ class ThinkRF(SDR): if stream_mode: self._streaming_active = False else: + print(f"ThinkRF: Configuring block capture - SPP={self._samples_per_packet}, PPB={self._packets_per_block}") self.radio.scpiset(f":TRACE:BLOCK:PACKETS {self._packets_per_block}") self.radio.scpiset(":TRACE:BLOCK:DATA?") self.rx_sample_rate = actual_sample_rate self.rx_center_frequency = center_frequency - self.rx_gain = {"attenuation_dB": attenuation, "profile": gain_profile} + self.rx_gain = { + "attenuation_dB": attenuation, + "profile": gain_profile, + "decimation": actual_decimation, + "rfe_mode": self._rfe_mode, + "spp": self._samples_per_packet, + "ppb": self._packets_per_block, + } self.rx_buffer_size = self._samples_per_packet self.rx_channel = 0 @@ -195,10 +210,20 @@ class ThinkRF(SDR): try: packet = self.radio.read() except Exception as exc: + # In block mode, reaching end of block can cause exceptions + # This is normal - just stop reading + if not stream_mode and packets_processed > 0: + # Got some packets in block mode, finish gracefully + print(f"ThinkRF: Block read complete ({packets_processed} packets received)") + break print(f"ThinkRF read error: {exc}") break if packet is None: + # No more packets available + if not stream_mode and packets_processed >= self._packets_per_block: + # Finished reading block + break continue if packet.is_context_packet(): @@ -206,14 +231,25 @@ class ThinkRF(SDR): continue if not packet.is_data_packet(): + # Unknown packet type - skip continue - iq_data = np.asarray(packet.data, dtype=np.float32) - if iq_data.ndim != 2 or iq_data.shape[1] != 2: - print("Unexpected ThinkRF packet format; skipping packet") - continue + # packet.data is an iterable IQData object that yields (I, Q) tuples + # Convert to numpy array: collect all [I, Q] pairs + try: + # Iterate through packet.data to get all IQ pairs + iq_pairs = list(packet.data) # List of (I, Q) tuples + if not iq_pairs: + continue - complex_buffer = (iq_data[:, 0] + 1j * iq_data[:, 1]).astype(np.complex64, copy=False) + # Convert to numpy array [N, 2] + iq_array = np.array(iq_pairs, dtype=np.float32) + + # Extract I and Q channels and create complex buffer + complex_buffer = (iq_array[:, 0] + 1j * iq_array[:, 1]).astype(np.complex64) + except Exception as e: + print(f"Error extracting IQ from packet.data: {e}") + continue metadata = None if hasattr(packet, "fields"): @@ -221,16 +257,15 @@ class ThinkRF(SDR): if metadata.get("sample_loss"): print("\033[93mWarning: ThinkRF sample overflow detected\033[0m") + # Send packet data to callback (accumulation handled by parent) callback(buffer=complex_buffer, metadata=metadata) - if stream_mode: - packets_processed += 1 - else: - packets_processed += 1 - if packets_processed >= self._packets_per_block: - packets_processed = 0 - if self._enable_rx: - self.radio.scpiset(":TRACE:BLOCK:DATA?") + packets_processed += 1 + + # In block mode, stop after receiving all packets in the block + if not stream_mode and packets_processed >= self._packets_per_block: + # Got all packets for this block + break print("ThinkRF RX Completed.") if stream_mode and self._streaming_active: @@ -271,15 +306,93 @@ class ThinkRF(SDR): raise NotImplementedError("ThinkRF radios do not expose a controllable bias-tee") def _derive_decimation(self, target_sample_rate: int | float) -> int: + """Round sample rate to nearest supported decimation.""" if not target_sample_rate: return 1 requested = float(target_sample_rate) if requested >= self.BASE_SAMPLE_RATE: return 1 desired = self.BASE_SAMPLE_RATE / requested - best = min(self.SUPPORTED_DECIMATIONS, key=lambda dec: abs(dec - desired)) + # Always round UP to next decimation (lower sample rate) to avoid exceeding user request + best = min((d for d in self.SUPPORTED_DECIMATIONS if d >= desired), default=self.SUPPORTED_DECIMATIONS[-1]) return int(best) + def enforce_sample_rate(self, requested_sample_rate: int | float, decimation: Optional[int] = None) -> tuple[int, float]: + """ + Enforce valid sample rate and decimation. + + If decimation is provided, it takes precedence. + Otherwise, derive decimation from requested sample rate. + + Returns: + (decimation, actual_sample_rate) + """ + if decimation is not None: + # Decimation provided - validate and use it + if decimation not in self.SUPPORTED_DECIMATIONS: + # Round to nearest supported + decimation = min(self.SUPPORTED_DECIMATIONS, key=lambda d: abs(d - decimation)) + print(f"ThinkRF: Requested decimation not supported. Using decimation={decimation}") + else: + # Derive from sample rate + decimation = self._derive_decimation(requested_sample_rate) + + actual_sample_rate = self.BASE_SAMPLE_RATE / decimation + + if abs(actual_sample_rate - requested_sample_rate) > 1e3: # More than 1 kHz difference + print(f"ThinkRF: Requested {requested_sample_rate/1e6:.2f} MS/s → Using decimation={decimation} ({actual_sample_rate/1e6:.2f} MS/s)") + + return decimation, actual_sample_rate + + def calculate_spp_ppb(self, num_samples: int, spp: Optional[int] = None) -> tuple[int, int]: + """ + Calculate optimal SPP (samples per packet) and PPB (packets per block). + + Strategy: + - Maximize SPP (use DEFAULT_SPP) unless num_samples < DEFAULT_SPP + - Calculate PPB to get as close as possible to num_samples + - Actual captured samples = SPP * PPB (may exceed num_samples slightly) + + Args: + num_samples: Desired number of samples + spp: Override SPP (for advanced users, not recommended) + + Returns: + (spp, ppb) + """ + if spp is not None: + # User override - use as-is + actual_spp = max(1, int(spp)) + else: + # Maximize SPP unless samples requested is smaller + if num_samples < self.DEFAULT_SPP: + actual_spp = num_samples + else: + actual_spp = self.DEFAULT_SPP + + # Calculate PPB to get close to num_samples + ppb = max(1, int(np.ceil(num_samples / actual_spp))) + + actual_samples = actual_spp * ppb + if actual_samples != num_samples: + print(f"ThinkRF: Requested {num_samples} samples → Capturing {actual_samples} (SPP={actual_spp}, PPB={ppb})") + + return actual_spp, ppb + + def check_ram_limit(self, num_samples: int, decimation: int) -> None: + """ + Check if requested capture exceeds onboard RAM limits. + + Raises warning if exceeds MAX_ONBOARD_SAMPLES at low decimations. + For decimation 1 or 2, block captures are limited by onboard RAM. + """ + if decimation <= 2 and num_samples > self.MAX_ONBOARD_SAMPLES: + raise ValueError( + f"ThinkRF: Cannot capture {num_samples} samples at decimation {decimation}. " + f"Onboard RAM limit is ~{self.MAX_ONBOARD_SAMPLES} samples for dec 1/2. " + f"Either reduce num_samples or use stream mode (increase decimation to >=4)." + ) + def _default_trigger(self, center_frequency: int | float) -> Dict[str, Any]: span = 40_000_000 half = span // 2