[LimeSDR-Mini] Synchronized TX/RX Loopback Random Sample Offset

Howdy,

With my LimeSDR-Mini, I’m trying to send and receive a tone at a specific frequency offset from a carrier with a consistent phase offset from one measurement to the next. I was expecting that all I needed to do is make sure I’m using the same PLL for both RX and TX (true by default, I think) and make sure the RX and TX begin at the same time stamp. For using timestamps, I borrowed code from here: https://github.com/pothosware/SoapySDR/blob/master/python/apps/MeasureDelay.py

However, it only seems to work some of the time, while other times the two streams are offset by samples. In the following code, I transmit and receive my signal 10 times in a row.

import argparse
import os
import time
import numpy as np
import SoapySDR
from SoapySDR import * #SOAPY_SDR_ constants

def get_tone_from_freq_bin(samp_rate, bin_num, fft_size):
    spectrum = np.zeros((fft_size)).astype(np.complex64)
    spectrum[bin_num] = 1 + 1j*0
    time_signal = np.fft.fft(np.fft.fftshift(spectrum))
    time_signal /= np.max(np.abs(time_signal))
    return time_signal

samp_rate = 12.5e6
fft_size = 2 ** 10
bin_num = int(fft_size * 3 / 4)
time_signal = get_tone_from_freq_bin(samp_rate=samp_rate, bin_num=bin_num, fft_size=fft_size)

def get_receptions(
        args,
        rate,
        tx_signal,
        freq=None,
        rx_bw=None,
        tx_bw=None,
        rx_chan=0,
        tx_chan=0,
        rx_ant=None,
        tx_ant=None,
        rx_gain=None,
        tx_gain=None,
        clock_rate=None,
        dump_dir=None):

    sdr = SoapySDR.Device(args)
    if not sdr.hasHardwareTime():
        raise Exception('this device does not support timed streaming')

    #set clock rate first
    if clock_rate is not None:
        sdr.setMasterClockRate(clock_rate)

    #set sample rate
    sdr.setSampleRate(SOAPY_SDR_RX, rx_chan, rate)
    sdr.setSampleRate(SOAPY_SDR_TX, tx_chan, rate)
    print("Actual Rx Rate %f Msps"%(sdr.getSampleRate(SOAPY_SDR_RX, rx_chan) / 1e6))
    print("Actual Tx Rate %f Msps"%(sdr.getSampleRate(SOAPY_SDR_TX, tx_chan) / 1e6))

    #set antenna
    if rx_ant is not None:
        sdr.setAntenna(SOAPY_SDR_RX, rx_chan, rx_ant)
    if tx_ant is not None:
        sdr.setAntenna(SOAPY_SDR_TX, tx_chan, tx_ant)

    #set overall gain
    if rx_gain is not None:
        sdr.setGain(SOAPY_SDR_RX, rx_chan, rx_gain)
    if tx_gain is not None:
        sdr.setGain(SOAPY_SDR_TX, tx_chan, tx_gain)

    #tune frontends
    if freq is not None:
        sdr.setFrequency(SOAPY_SDR_RX, rx_chan, freq)
    if freq is not None:
        sdr.setFrequency(SOAPY_SDR_TX, tx_chan, freq)

    #set bandwidth
    if rx_bw is not None:
        sdr.setBandwidth(SOAPY_SDR_RX, rx_chan, rx_bw)
    if tx_bw is not None:
        sdr.setBandwidth(SOAPY_SDR_TX, tx_chan, tx_bw)

    timeout_us = int(5e5) #500 ms >> stream time
    tx_pulse = (tx_signal * 0.3).astype(np.complex64)
    
    delay_ms = 100
    delay_samples = int(delay_ms * 1e-3 * rate)
    delay_s = delay_samples / rate
    delay_ns = int(delay_s * 1e9)
    
    num_rx_samps = len(tx_pulse)
    tx_flags = SOAPY_SDR_HAS_TIME | SOAPY_SDR_END_BURST
    rx_flags = SOAPY_SDR_HAS_TIME | SOAPY_SDR_END_BURST
    
    receptions = []
    
    #create rx and tx streams
    rx_stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CF32, [rx_chan])
    tx_stream = sdr.setupStream(SOAPY_SDR_TX, SOAPY_SDR_CF32, [tx_chan])
    
    #let things settle
    time.sleep(1)
    
    for _ in range(10):
        sdr.activateStream(tx_stream)

        tx_time_0 = int(sdr.getHardwareTime() + delay_ns)
        receive_time = int(tx_time_0)

        status = sdr.writeStream(tx_stream, [tx_pulse], len(tx_pulse), tx_flags, tx_time_0)
        if status.ret != len(tx_pulse):
            raise Exception('transmit failed %s' % str(status))

        rx_buffs = np.array([], np.complex64)
        sdr.activateStream(rx_stream, rx_flags, receive_time, num_rx_samps)
        rx_time_0 = None

        #accumulate receive buffer into large contiguous buffer
        while True:
            rx_buff = np.array([0]*1024, np.complex64)
            status = sdr.readStream(rx_stream, [rx_buff], len(rx_buff), timeoutUs=timeout_us)
            #stash time on first buffer
            if status.ret > 0 and len(rx_buffs) == 0:
                rx_time_0 = status.timeNs   # Need this line
                if (status.flags & SOAPY_SDR_HAS_TIME) == 0:
                    raise Exception('receive fail - no timestamp on first readStream %s'%(str(status)))

            #accumulate buffer or exit loop
            if status.ret > 0:
                rx_buffs = np.concatenate((rx_buffs, rx_buff[:status.ret]))
            else:
                break

        #cleanup streams
        sdr.deactivateStream(rx_stream)
        sdr.deactivateStream(tx_stream)
        receptions.append(rx_buffs)

    sdr.closeStream(rx_stream)
    sdr.closeStream(tx_stream)
    
    #check resulting buffer
    if len(rx_buffs) != num_rx_samps:
        raise Exception(
            'receive fail - captured samples %d out of %d'%(len(rx_buffs), num_rx_samps))
    if rx_time_0 is None:
        raise Exception('receive fail - no valid timestamp')
        
    return receptions


receptions = get_receptions(
    args='driver=lime',
    rate=samp_rate,
    tx_signal=time_signal*np.hanning(len(time_signal)),
    freq=1e9,
    tx_gain=50,
    rx_gain=10,
    clock_rate=samp_rate
)

Then I check the magnitude and phase of the received tone for the 10 runs:

for i, r in enumerate(receptions):
    reception = r
    spectrum = np.fft.fftshift(np.fft.fft(np.flip(reception)))
    freq_bin = spectrum[bin_num]
    mag = np.abs(freq_bin)
    phase = np.angle(freq_bin) * 180 / np.pi
    print(np.round(mag, 2), np.round(phase, 1))

which results in the following:

23.87 -142.1
23.91 -51.8
23.85 -52.3
23.89 -142.2
23.85 -52.3
23.82 -142.1
23.81 -142.2
23.84 -52.3
23.8 -142.2
23.79 -142.3

So the phase offset seems to be either about -141 or -52 each run, but I can’t figure out why. I tried offsetting the runs that didn’t match the first by rolling them by one sample in time:

for i, r in enumerate(receptions):
    reception = r
    if i in [1, 2, 4, 7]:
        reception = np.roll(reception, -1)
    spectrum = np.fft.fftshift(np.fft.fft(np.flip(reception)))
    freq_bin = spectrum[bin_num]
    mag = np.abs(freq_bin)
    phase = np.angle(freq_bin) * 180 / np.pi
    print(np.round(mag, 2), np.round(phase, 1))

and I got:

23.87 -142.1
23.91 -141.8
23.85 -142.3
23.89 -142.2
23.85 -142.3
23.82 -142.1
23.81 -142.2
23.84 -142.3
23.8 -142.2
23.79 -142.3

And then all the phases match. So some of them were just off by one sample.

Could someone please help me figure out why this is happening and how to make the resulting phase consistent?

Thanks!