RemoteRF supports 2 × 2 MIMO operation on the ADALM-PLUTO (Pluto SDR) using devices equipped with or configured for dual-channel AD9361 RF transceiver operation. This includes official ADALM-PLUTO units unlocked to enable the second RF channel (2R2T mode) and extended tuning range (70 MHz to 6 GHz), as well as compatible third-party open-source hardware platforms such as the Pluto+ which natively feature dual receive and dual transmit SMA ports, onboard Gigabit Ethernet, and upgraded reference oscillators.

This guide covers how to initialize, configure, and stream synchronous two-channel baseband signals with a reserved Pluto MIMO SDR through RemoteRF using Python.

Figure 1
Figure 2
Left: Unlocked ADALM-PLUTO (Analog Devices). Right: Pluto+ dual-channel SDR.

Device Initialization

To interface with an ADALM-PLUTO MIMO through RemoteRF, import the PlutoMIMO driver from the remoterf library and initialize a pluto 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 Pluto MIMO SDR corresponding to that reservation.

from remoterf.drivers.adalm_pluto_mimo import PlutoMIMO
pluto = PlutoMIMO(token='YOUR_RESERVATION_TOKEN') # token issued when making a reservation

Once initialized, the rest of your Python script follows standard multi-channel Pluto SDR usage via pyadi-iio, exactly as if connected locally via USB.

Example Script

The Python script below demonstrates how to configure an ADALM-PLUTO in 2 × 2 MIMO mode and perform concurrent dual-channel transmission and reception via the RemoteRF platform. In this example, two distinct complex sinusoid tones are generated: a 100 kHz tone destined for TX Channel 0 and a 250 kHz tone destined for TX Channel 1. Both signals are properly scaled to full DAC range and simultaneously transmitted using pluto.tx([tx_sig0_scaled, tx_sig1_scaled]) at a carrier frequency of 915 MHz. Since the transmit buffer is set to cyclic mode (tx_cyclic_buffer = True), the Pluto continuously broadcasts both tones indefinitely. The command pluto.rx() then fetches complex baseband samples across both receive channels simultaneously (returning a 2D array of shape [2, rx_buffer_size]). The FFT spectra of both channels are then plotted, displaying independent peaks at 100 kHz on RX0 and 250 kHz on RX1.

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

# token provided when making reservation
TOKEN = "YOUR_RESERVATION_TOKEN"

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

# for interfacing with Pluto MIMO through RemoteRF
try:
    from remoterf.drivers.adalm_pluto_mimo import PlutoMIMO
except ImportError:
    from remoterf.drivers.adalm_pluto_mimo import PlutoMIMO

# ---------------------------------------------------------------
# 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)

# ---------------------------------------------------------------
# Pluto MIMO system parameters.
# ---------------------------------------------------------------
sample_rate = fs                # baseband sampling rate (Hz), ~600 kHz to 61.44 MHz
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_rf_bw_Hz = sample_rate * 2   # transmitter RF bandwidth (Hz)
rx_rf_bw_Hz = sample_rate * 2   # receiver RF bandwidth (Hz)
tx_gain_dB = -30                # transmit attenuation/gain in dB (-89.75 to 0 dB)
rx_gain_dB = 40                 # receive gain in dB (0 to 74.5 dB)
rx_buffer_size = 10_000         # receiver buffer size (samples per channel)
tx_cyclic_buffer = True         # cyclic nature of transmitter buffer

# ---------------------------------------------------------------
# Create transmit signals (2 Channels).
# ---------------------------------------------------------------
t = np.arange(rx_buffer_size) / sample_rate
dac_full_scale = 2**14 - 1

# Channel 0 (TX1): Complex exponential tone at +100 kHz
tone_freq_0 = 100e3
tx_sig0 = np.exp(2.0j * np.pi * tone_freq_0 * t)
tx_sig0 = tx_sig0.astype(np.complex64)
tx_sig0_scaled = (tx_sig0 / np.max([np.abs(np.real(tx_sig0)), np.abs(np.imag(tx_sig0))])) * dac_full_scale

# Channel 1 (TX2): Complex exponential tone at +250 kHz
tone_freq_1 = 250e3
tx_sig1 = np.exp(2.0j * np.pi * tone_freq_1 * t)
tx_sig1 = tx_sig1.astype(np.complex64)
tx_sig1_scaled = (tx_sig1 / np.max([np.abs(np.real(tx_sig1)), np.abs(np.imag(tx_sig1))])) * dac_full_scale

# ---------------------------------------------------------------
# Initialize Pluto MIMO object using issued token.
# ---------------------------------------------------------------
pluto = PlutoMIMO(token=TOKEN) # token issued when making reservation
pluto.sample_rate = int(sample_rate)

# Enable both channels for 2x2 MIMO operation
pluto.rx_enabled_channels = [0, 1]
pluto.tx_enabled_channels = [0, 1]

# ---------------------------------------------------------------
# Setup Pluto MIMO transmitter (TX1 and TX2).
# ---------------------------------------------------------------
pluto.tx_destroy_buffer()                   # reset transmit data buffer
pluto.tx_rf_bandwidth = int(tx_rf_bw_Hz)    # set transmitter RF bandwidth
pluto.tx_lo = int(tx_carrier_freq_Hz)       # set carrier frequency for transmission
pluto.tx_hardwaregain_chan0 = tx_gain_dB    # set transmit gain for TX1
pluto.tx_hardwaregain_chan1 = tx_gain_dB    # set transmit gain for TX2
pluto.tx_cyclic_buffer = tx_cyclic_buffer   # set cyclic buffer mode

# ---------------------------------------------------------------
# Setup Pluto MIMO receiver (RX1 and RX2).
# ---------------------------------------------------------------
pluto.rx_destroy_buffer()                   # reset receive data buffer
pluto.rx_lo = int(rx_carrier_freq_Hz)       # set carrier frequency for reception
pluto.rx_rf_bandwidth = int(sample_rate)    # set receiver RF bandwidth
pluto.rx_buffer_size = int(rx_buffer_size)  # set buffer size of receiver
pluto.gain_control_mode_chan0 = 'manual'    # set gain control mode for RX1
pluto.gain_control_mode_chan1 = 'manual'    # set gain control mode for RX2
pluto.rx_hardwaregain_chan0 = rx_gain_dB    # set hardware gain for RX1
pluto.rx_hardwaregain_chan1 = rx_gain_dB    # set hardware gain for RX2

# ---------------------------------------------------------------
# Transmit from Pluto MIMO!
# ---------------------------------------------------------------
pluto.tx([tx_sig0_scaled, tx_sig1_scaled])  # will continuously transmit when cyclic buffer is True

# ---------------------------------------------------------------
# Receive with Pluto MIMO!
# ---------------------------------------------------------------
pluto.rx_destroy_buffer() # reset receive data buffer to be safe
for _ in range(1):      # clear transient buffer
    _ = pluto.rx()

rx_data = pluto.rx()      # captures raw 2x2 samples (shape: [2, rx_buffer_size])

# ---------------------------------------------------------------
# Clean up buffers once done receiving.
# ---------------------------------------------------------------
pluto.tx_destroy_buffer() # reset transmit data buffer
pluto.rx_destroy_buffer() # reset receive data buffer

# ---------------------------------------------------------------
# Take FFT of received signals on both channels.
# ---------------------------------------------------------------
rx_chan0 = rx_data[0]
rx_chan1 = rx_data[1]

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, len(rx_fft_0))

# ---------------------------------------------------------------
# Plot spectral response.
# ---------------------------------------------------------------
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), 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 - RX0 (Port 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 - RX1 (Port 1)")
ax2.grid(True)

fig.suptitle("ADALM-PLUTO 2x2 MIMO Received Spectrum", fontsize=14)
plt.show()

Properties and Methods

When interacting with an ADALM-PLUTO in MIMO mode through RemoteRF, client scripts communicate with a pluto object generated dynamically from RemoteRF’s built-in adalm_pluto_mimo schema. This interface exposes a curated, schema-exposed subset of pyadi-iio over the remote RPC connection, with multi-channel support for both Channel 0 and Channel 1.

Properties

Property Type Access Range / Options Description
sample_rate int Read / Write 600 kHz to 61.44 MHz Common baseband sampling rate in samples per second (complex IQ) for all enabled channels.
rx_lo int Read / Write 70 MHz to 6.0 GHz Receive local oscillator (LO) center frequency in Hz.
tx_lo int Read / Write 70 MHz to 6.0 GHz Transmit local oscillator (LO) center frequency in Hz.
rx_rf_bandwidth int Read / Write 200 kHz to 56 MHz Receive analog baseband low-pass filter cutoff / RF bandwidth in Hz.
tx_rf_bandwidth int Read / Write 200 kHz to 56 MHz Transmit analog baseband low-pass filter cutoff / RF bandwidth in Hz.
rx_hardwaregain_chan0 float Read / Write 0 to 74.5 dB RX channel 0 analog frontend gain in dB (when AGC is 'manual').
rx_hardwaregain_chan1 float Read / Write 0 to 74.5 dB RX channel 1 analog frontend gain in dB (when AGC is 'manual').
tx_hardwaregain_chan0 float Read / Write −89.75 to 0.0 dB TX channel 0 attenuation/gain setting in dB (0.25 dB steps).
tx_hardwaregain_chan1 float Read / Write −89.75 to 0.0 dB TX channel 1 attenuation/gain setting in dB (0.25 dB steps).
gain_control_mode_chan0 str Read / Write 'manual', 'slow_attack', 'fast_attack', 'hybrid' RX channel 0 gain control mode.
gain_control_mode_chan1 str Read / Write 'manual', 'slow_attack', 'fast_attack', 'hybrid' RX channel 1 gain control mode.
rx_buffer_size int Read / Write Positive integer Number of complex samples captured per enabled receive channel per buffer (pluto.rx()).
tx_cyclic_buffer bool Read / Write True, False Controls cyclic (repeating) buffer mode for continuous transmit operations.
rx_enabled_channels list[int] Read / Write [0], [1], [0, 1] List of enabled receive channels. Set to [0, 1] for dual-channel MIMO reception.
tx_enabled_channels list[int] Read / Write [0], [1], [0, 1] List of enabled transmit channels. Set to [0, 1] for dual-channel MIMO transmission.
filter str Read / Write Valid filter file path Path to an AD936x digital filter configuration file or filter configuration profile.
loopback int Read / Write 0 (disabled), 1 (digital), 2 (RF) Digital/RF loopback mode setting for self-testing.
repr str Read-only Diagnostic string Diagnostic string representation (repr()) of the underlying device driver object.
rx_channel_names list[str] Read-only e.g., ['voltage0', 'voltage1'] Available receive channel identifier names.
tx_channel_names list[str] Read-only e.g., ['voltage0', 'voltage1'] Available transmit channel identifier names.

Methods

Method Arguments Returns Description
pluto.rx() None numpy.ndarray (complex) Fetches a buffer of complex IQ samples. When both channels are enabled (rx_enabled_channels = [0, 1]), returns a 2D array of shape (2, rx_buffer_size) where row 0 corresponds to RX0 and row 1 corresponds to RX1. Sample values range from −2048 to +2047 (12-bit ADC).
pluto.tx(value) value (list or 2D array of complex) None Submits complex IQ samples to the transmit hardware. When both channels are enabled, pass a list of two complex arrays [tx_sig0, tx_sig1]. Both real and imaginary components must fall between −214 and +214−1 (−16,384 to +16,383) to match the 14-bit DAC range.
pluto.rx_destroy_buffer() None None Frees and flushes the hardware receive capture buffer.
pluto.tx_destroy_buffer() None None Deallocates and cancels active hardware transmit buffers.

Tips and Tricks

Common Configuration Template

For quick reference, the following template brings together the most commonly configured RF, filter, buffer, and gain properties for a Pluto MIMO 2 × 2 session:

# Common Pluto MIMO 2x2 Configuration Template
pluto.sample_rate = int(1e6)              # sampling rate, between ~600 kHz and 61.44 MHz

# Enable both channels
pluto.rx_enabled_channels = [0, 1]
pluto.tx_enabled_channels = [0, 1]

# Transmitter settings
pluto.tx_lo = int(915e6)                  # transmit carrier frequency (shared or per-channel)
pluto.tx_rf_bandwidth = int(2e6)          # transmitter RF bandwidth
pluto.tx_hardwaregain_chan0 = -30         # TX0 gain in dB (-89.75 to 0.0 dB)
pluto.tx_hardwaregain_chan1 = -30         # TX1 gain in dB (-89.75 to 0.0 dB)
pluto.tx_cyclic_buffer = True             # cyclic buffer mode (True -> continuous repeat)

# Receiver settings
pluto.rx_lo = int(915e6)                  # receive carrier frequency
pluto.rx_rf_bandwidth = int(2e6)          # receiver RF bandwidth
pluto.rx_buffer_size = int(10_000)        # samples per channel per buffer
pluto.gain_control_mode_chan0 = 'manual'  # RX0 AGC mode: 'manual', 'slow_attack', 'fast_attack'
pluto.gain_control_mode_chan1 = 'manual'  # RX1 AGC mode: 'manual', 'slow_attack', 'fast_attack'
pluto.rx_hardwaregain_chan0 = 40          # RX0 gain in dB (0 to 74.5 dB)
pluto.rx_hardwaregain_chan1 = 40          # RX1 gain in dB (0 to 74.5 dB)

Multi-Channel Buffer Dimensions

When operating with both channels enabled (rx_enabled_channels = [0, 1] and tx_enabled_channels = [0, 1]):

  • Receiving (pluto.rx()): Returns a 2D NumPy array with shape (2, rx_buffer_size). You can unpack the two channels directly:
  rx_data = pluto.rx()
  chan0_samples = rx_data[0]
  chan1_samples = rx_data[1]
  

Alternatively, tuple unpacking works as well: chan0_samples, chan1_samples = pluto.rx().

  • Transmitting (pluto.tx()): Pass a list or 2D array containing one waveform per enabled channel:
  pluto.tx([tx_sig0_scaled, tx_sig1_scaled])
  

Ensure both transmit arrays have identical length and datatype (complex64).

Scaling Your Transmit Signals

Pluto’s 14-bit DAC expects the real and imaginary components of transmitted baseband signals to range from −214 to +214−1 (−16,384 to +16,383). To maximize the dynamic range of your DACs and avoid clipping distortion, normalize and scale each channel independently before passing them to pluto.tx():

dac_full_scale = 2**14 - 1
tx_sig0_scaled = (tx_sig0 / np.max([np.abs(np.real(tx_sig0)), np.abs(np.imag(tx_sig0))])) * dac_full_scale
tx_sig1_scaled = (tx_sig1 / np.max([np.abs(np.real(tx_sig1)), np.abs(np.imag(tx_sig1))])) * dac_full_scale
pluto.tx([tx_sig0_scaled, tx_sig1_scaled])

Scale of Receive Signals

Received samples from Pluto’s 12-bit ADC range between −2048 and +2047. If your received signals clip near ±2048, the receiver front-end is saturating. Decrease the receive gain (rx_hardwaregain_chan0 / rx_hardwaregain_chan1) or reduce transmit power to restore linear operation.

Independent Channel Gain Control

Unlike the standard single-channel Pluto, Pluto MIMO exposes independent gain and AGC controls for each channel:

  • Channel 0: pluto.gain_control_mode_chan0 and pluto.rx_hardwaregain_chan0
  • Channel 1: pluto.gain_control_mode_chan1 and pluto.rx_hardwaregain_chan1

In MIMO experiments such as spatial multiplexing, beamforming, or channel estimation, setting both channels to manual gain control ensures consistent channel calibration and avoids AGC fluctuations between channels.

Flushing the Receive Buffer

To clear any stale or transient samples captured before your transmitter is fully settled, destroy the receive buffer and perform one or two discard reads before recording your measurement data:

pluto.rx_destroy_buffer() # reset receive data buffer to be safe
for _ in range(1):      # clear transient buffer
    _ = pluto.rx()

rx_data = pluto.rx()      # capture synchronized MIMO samples

Destroying Transmit and Receive Buffers

When tx_cyclic_buffer = True is used, the Pluto SDR loads the transmit waveforms into onboard hardware memory and continues repeating transmission over the air indefinitely. This transmission persists even after your Python script finishes executing until the hardware buffer is destroyed or your reservation expires.

To avoid emitting unwanted RF energy in the lab, always destroy the transmit and receive buffers at the end of your script:

pluto.tx_destroy_buffer() # reset transmit data buffer to be safe
pluto.rx_destroy_buffer() # reset receive data buffer to be safe

Detailed Block Diagram of Pluto’s RFIC

A detailed functional block diagram of the AD9361 dual transceiver architecture used for 2 × 2 MIMO is shown below for reference.

A detailed functional block diagram of the AD9361 integrated circuit.
A detailed functional block diagram of the AD9361 integrated circuit.