RemoteRF supports the USRP 2901, a versatile 2 × 2 MIMO software-defined radio (built on the Ettus Research USRP B210 architecture). Powered by the Analog Devices AD9361 RF Agile Transceiver, it provides two independent transmit and two independent receive channels operating continuously from 70 MHz to 6 GHz with sampling rates up to 56 MHz (over USB 3.0). This guide covers how to initialize, configure, and stream synchronous multi-channel baseband signals with a reserved USRP 2901 through RemoteRF using Python and UHD.

The USRP 2901, a 2 × 2 MIMO software-defined radio.
The USRP 2901, a 2 × 2 MIMO software-defined radio.

Device Initialization

To interface with a USRP 2901 through RemoteRF, import the UHD client driver from the remoterf library and initialize a MultiUSRP object with the token issued upon making your reservation. This token is used by the RemoteRF platform to validate your reservation and route subsequent interfacing to the correct USRP corresponding to that reservation.

from remoterf.drivers.usrp import MultiUSRP, uhd
usrp = MultiUSRP(token='dUx6lG3I9lA') # token issued when making a reservation

Once initialized, the rest of your Python script follows standard UHD Python API usage, exactly as if connected locally via USB.

Example Script

The Python script below demonstrates how to configure a USRP 2901 and perform synchronous transmit and receive operations on both channels (2 × 2 MIMO) via the RemoteRF platform. Because the USRP 2901 features full-duplex dual-channel transceivers and onboard hardware timestamping, timed bursts can be scheduled and executed synchronously in a single thread without requiring background threads or asynchronous loops. In this example, two distinct complex tones (+100 kHz on TX0 and +200 kHz on TX1) are generated, scaled, and scheduled for transmission. Simultaneously, a timed receive command captures 50,000 samples across both receive channels (RX0 and RX1), and the FFT spectra of both channels are plotted.

Note that the device initialization lines described above have already been included in the code below. You should not need to change any of the code below except for the token you received upon making a reservation.

import numpy as np
import matplotlib.pyplot as plt

# the below code can also be used to ensure you have the most
# up-to-date drivers pulled from the RemoteRF server
# from remoterf.drivers import ensure_driver
# ensure_driver(token='dUx6lG3I9lA')

# for interfacing with USRP through RemoteRF
from remoterf.drivers.usrp import MultiUSRP, uhd

# ---------------------------------------------------------------
# Digital communication system parameters.
# ---------------------------------------------------------------
fs = 1e6     # baseband sampling rate (1 MSps)
ts = 1 / fs  # sampling period (seconds per sample)
sps = 10     # samples per data symbol
T = ts * sps # symbol period (seconds per symbol)

# ---------------------------------------------------------------
# USRP system parameters.
# ---------------------------------------------------------------
sample_rate = fs                # baseband sampling rate (Hz), up to 56 MSps
tx_carrier_freq_Hz = 915e6      # center RF frequency for TX (Hz), 70 MHz to 6 GHz
rx_carrier_freq_Hz = 915e6      # center RF frequency for RX (Hz), 70 MHz to 6 GHz
tx_gain_dB = 30                 # TX gain in dB (0 to ~89.8 dB)
rx_gain_dB = 30                 # RX gain in dB (0 to ~76 dB)
num_tx_samples = 50_000         # number of samples to transmit
num_rx_samples = 50_000         # number of samples to capture

# ---------------------------------------------------------------
# Create transmit signals (2 Channels).
# ---------------------------------------------------------------
t = np.arange(num_tx_samples) / sample_rate # time vector

# Channel 0: Complex exponential tone at +100 kHz
tone_freq_0 = 100e3                         # tone offset frequency for channel 0
tx_signal_chan0 = np.exp(2.0j * np.pi * tone_freq_0 * t).astype(np.complex64)

# Channel 1: Complex exponential tone at +200 kHz
tone_freq_1 = 200e3                         # tone offset frequency for channel 1
tx_signal_chan1 = np.exp(2.0j * np.pi * tone_freq_1 * t).astype(np.complex64)

# Scale signals to target digital amplitude (scaled by same factor)
target_amplitude = 1.0                      # target peak digital amplitude
max_peak = max(np.max(np.abs(tx_signal_chan0)), np.max(np.abs(tx_signal_chan1)))
scale_factor = target_amplitude / max_peak  # uniform scale factor preserving relative amplitudes
tx_signal_chan0_scaled = tx_signal_chan0 * scale_factor
tx_signal_chan1_scaled = tx_signal_chan1 * scale_factor

# Pack scaled signals into 2D buffer (shape: [num_channels, num_samples])
tx_buffer = np.zeros((2, num_tx_samples), dtype=np.complex64)
tx_buffer[0] = tx_signal_chan0_scaled
tx_buffer[1] = tx_signal_chan1_scaled

# ---------------------------------------------------------------
# Connect to USRP and perform synchronous transmit & receive!
# ---------------------------------------------------------------
usrp = MultiUSRP(token='dUx6lG3I9lA') # token issued when making a reservation

# Configure RF parameters across both channels (0 and 1)
for ch in [0, 1]:
    usrp.set_tx_rate(sample_rate, ch)          # set transmitter sampling rate
    usrp.set_tx_freq(tx_carrier_freq_Hz, ch)   # set transmitter carrier frequency
    usrp.set_tx_gain(tx_gain_dB, ch)           # set transmit gain

    usrp.set_rx_rate(sample_rate, ch)          # set receiver sampling rate
    usrp.set_rx_freq(rx_carrier_freq_Hz, ch)   # set receiver carrier frequency
    usrp.set_rx_gain(rx_gain_dB, ch)           # set receive gain

# Setup stream arguments: host fc32 (complex64), wire sc16 (16-bit integer)
rx_args = uhd.usrp.StreamArgs("fc32", "sc16")
rx_args.channels = [0, 1]
rx_streamer = usrp.get_rx_stream(rx_args)
rx_buffer = np.zeros((2, num_rx_samples), dtype=np.complex64)
rx_metadata = uhd.types.RXMetadata()

tx_args = uhd.usrp.StreamArgs("fc32", "sc16")
tx_args.channels = [0, 1]
tx_streamer = usrp.get_tx_stream(tx_args)

# Schedule synchronized burst transmission and reception using hardware timestamps
burst_start = usrp.get_time_now() + 1.0        # schedule 1.0s in the future

# Step 1: Issue timed RX command
rx_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.num_done)
rx_cmd.num_samps = num_rx_samples             # number of samples to receive
rx_cmd.stream_now = False                     # do not stream immediately
rx_cmd.time_spec = burst_start + 0.005        # begin capture 5 ms after TX burst start
rx_streamer.issue_stream_cmd(rx_cmd)

# Step 2: Send TX burst (queues to device FIFO and returns immediately)
tx_metadata = uhd.types.TXMetadata()
tx_metadata.start_of_burst = True              # marks start of burst
tx_metadata.end_of_burst = True                # marks end of burst
tx_metadata.has_time_spec = True               # enable hardware timestamping
tx_metadata.time_spec = burst_start            # execute burst at timestamp
tx_streamer.send(tx_buffer, tx_metadata, 5.0)  # submit transmit buffer

# Step 3: Fetch captured RX samples (blocks until hardware receives them)
num_received = int(rx_streamer.recv(rx_buffer, rx_metadata, 5.0))
if num_received == 0:
    raise RuntimeError("No RX samples received.")

# Close streamers
tx_streamer.close()                            # close transmit streamer
rx_streamer.close()                            # close receive streamer

# ---------------------------------------------------------------
# Take FFT of received signals.
# ---------------------------------------------------------------
rx_chan0 = rx_buffer[0, :num_received]
rx_chan1 = rx_buffer[1, :num_received]

rx_fft_0 = np.abs(np.fft.fftshift(np.fft.fft(rx_chan0)))
rx_fft_1 = np.abs(np.fft.fftshift(np.fft.fft(rx_chan1)))
f = np.linspace(-sample_rate / 2, sample_rate / 2, num_received)

# ---------------------------------------------------------------
# Plot spectral response.
# ---------------------------------------------------------------
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 7), constrained_layout=True)

ax1.plot(f / 1e3, rx_fft_0, color="tab:blue")
ax1.set_xlabel("Frequency (kHz)")
ax1.set_ylabel("Magnitude")
ax1.set_title("FFT of Received Signal - RX Channel 0")
ax1.grid(True)

ax2.plot(f / 1e3, rx_fft_1, color="tab:orange")
ax2.set_xlabel("Frequency (kHz)")
ax2.set_ylabel("Magnitude")
ax2.set_title("FFT of Received Signal - RX Channel 1")
ax2.grid(True)

plt.show()

Properties and Methods

When interacting with a USRP 2901 through RemoteRF, client scripts communicate with a MultiUSRP object generated dynamically from RemoteRF’s built-in usrp schema. This interface exposes the complete UHD Python API over the remote RPC connection.

Configuration Methods

Method Arguments Range / Options Description
Configuration Methods
usrp.set_tx_rate(rate, chan=0) rate (float), chan (int) Up to 56 MSPS Sets the baseband transmit sampling rate in samples per second for the specified channel.
usrp.set_rx_rate(rate, chan=0) rate (float), chan (int) Up to 56 MSPS Sets the baseband receive sampling rate in samples per second for the specified channel.
usrp.set_tx_freq(freq, chan=0) freq (float), chan (int) 70 MHz to 6 GHz Tunes the transmit local oscillator (LO) center carrier frequency in Hz.
usrp.set_rx_freq(freq, chan=0) freq (float), chan (int) 70 MHz to 6 GHz Tunes the receive local oscillator (LO) center carrier frequency in Hz.
usrp.set_tx_gain(gain, chan=0) gain (float), chan (int) 0.0 to ~89.8 dB Sets the transmit analog gain in dB on the specified channel.
usrp.set_rx_gain(gain, chan=0) gain (float), chan (int) 0.0 to ~76.0 dB Sets the receive analog gain in dB on the specified channel.
usrp.set_tx_bandwidth(bw, chan=0) bw (float), chan (int) Up to 56 MHz Sets the analog baseband transmit low-pass filter bandwidth in Hz.
usrp.set_rx_bandwidth(bw, chan=0) bw (float), chan (int) Up to 56 MHz Sets the analog baseband receive low-pass filter bandwidth in Hz.
usrp.get_mboard_name() None str Queries and returns the USRP motherboard model name (e.g., 'B210').
usrp.get_pp_string() None str Returns a formatted diagnostic string detailing device subdevices, clocks, sensors, and channels.
usrp.set_clock_source(source) source (str) 'internal', 'external', 'gpsdo' Selects the 10 MHz reference clock source for frequency synthesis and timing.
usrp.set_time_source(source) source (str) 'internal', 'external', 'gpsdo' Selects the 1 PPS time synchronization source for hardware timestamps.

Streaming & Synchronization Methods

Method Arguments Returns Description
Stream Management Methods
usrp.get_tx_stream(stream_args) stream_args (StreamArgs) TXStreamer Allocates and initializes a UHD hardware transmit streamer for the specified channels and data format.
usrp.get_rx_stream(stream_args) stream_args (StreamArgs) RXStreamer Allocates and initializes a UHD hardware receive streamer for the specified channels and data format.
tx_streamer.send(buffer, metadata, timeout) buffer (ndarray), metadata (TXMetadata), timeout (float) int Sends complex IQ samples into the USRP hardware transmit FIFO queue.
rx_streamer.recv(buffer, metadata, timeout) buffer (ndarray), metadata (RXMetadata), timeout (float) int Receives complex IQ samples from the USRP hardware receive FIFO queue into host memory.
rx_streamer.issue_stream_cmd(stream_cmd) stream_cmd (StreamCMD) None Issues a hardware streaming command (e.g., timed burst capture or continuous streaming).
tx_streamer.close() / rx_streamer.close() None None Closes the active streamer channel and frees onboard DMA FIFO resources.
usrp.get_time_now() None TimeSpec Reads and returns the current hardware timestamp from the onboard FPGA master clock.
usrp.set_time_now(time_spec) time_spec (TimeSpec) None Sets the FPGA master clock to the specified timestamp.

Tips and Tricks

Common Configuration Template

For quick reference, the following template brings together the most commonly configured RF, rate, and gain properties for both channels of a USRP 2901 session:

# Common USRP 2901 Configuration Template (2x2 MIMO)
for ch in [0, 1]:
    usrp.set_tx_rate(int(1e6), ch)          # baseband sampling rate (1 MSps), up to 56 MSps
    usrp.set_tx_freq(int(915e6), ch)        # center RF frequency for TX (Hz), 70 MHz to 6 GHz
    usrp.set_tx_gain(30, ch)                # TX gain in dB (0 to ~89.8 dB)
    usrp.set_rx_rate(int(1e6), ch)          # baseband sampling rate (1 MSps), up to 56 MSps
    usrp.set_rx_freq(int(915e6), ch)        # center RF frequency for RX (Hz), 70 MHz to 6 GHz
    usrp.set_rx_gain(30, ch)                # RX gain in dB (0 to ~76 dB)

Full-Duplex 2 × 2 MIMO Architecture

The USRP 2901 features two complete transmit paths (TX0, TX1) and two complete receive paths (RX0, RX1) driven by the Analog Devices AD9361 transceiver. Unlike half-duplex radios (such as the HackRF) or receive-only dongles (such as the RTL-SDR), the USRP 2901 can transmit and receive simultaneously on both channels, enabling full-duplex 2 × 2 MIMO experiments.

Synchronous Timed Bursts

Because UHD supports FPGA hardware timestamps, synchronous transmit and receive bursts can be executed cleanly in a single thread without background worker threads or asynchronous callbacks:

  1. Query current hardware time with burst_start = usrp.get_time_now() + 1.0 (scheduling 1 second ahead).
  2. Schedule the receive capture using uhd.types.StreamCMD with time_spec = burst_start + 0.005.
  3. Submit the transmit burst with tx_metadata.time_spec = burst_start. The call to tx_streamer.send() queues samples directly into the USRP hardware FIFO and returns immediately.
  4. Call rx_streamer.recv(), which blocks until the scheduled samples arrive from the hardware.

Stream Formats (fc32 vs. sc16)

When creating streamers with uhd.usrp.StreamArgs("fc32", "sc16"):

  • Host / CPU Format (fc32): Complex 32-bit floating point numbers (numpy.complex64), normalized between −1.0 and +1.0.
  • Over-the-Wire Format (sc16): 16-bit signed integer complex on the USB link and FPGA hardware (−32,768 to +32,767).

UHD handles automatic scaling, conversion, and quantization between fc32 and sc16.

Managing Streamer Cleanups

Explicitly closing transmit and receive streamers when streaming finishes is optional, but considered good practice:

# Explicitly close active streamers to release hardware resources
tx_streamer.close()
rx_streamer.close()

Calling .close() on your active TXStreamer and RXStreamer objects frees onboard FPGA DMA FIFO channels and hardware buffer allocations on the USRP, ensuring the device returns to a clean state ready for subsequent streaming tasks. While RemoteRF automatically reclaims device resources when your session ends, manually closing streamers prevents leftover buffer states or FIFO underflow/overflow issues across consecutive operations within the same session.