Cannot create consistent loopback

I’m trying to consistently send and receive a signal on a LimeSDR-mini, but the received signal is randomly shift by one sample, even though I use timed commands. I really cannot figure out what is wrong and so my project has been dead in the water for weeks now.

For example, I transmitted and received a step signal 20 times in a row using timed commands, and here is a plot of the received signals:

Whole plot:

Zoomed in:

As you can see, the signals arrive with either one sample offset or another, but I cannot figure out why.

Please let me know if there’s any more information that I can supply to help diagnose this. Thanks!

Code:

import argparse
import os
import time
import numpy as np
import SoapySDR
from SoapySDR import * #SOAPY_SDR_ constants
from bokeh.plotting import figure, output_file, output_notebook, show
from bokeh.models.tools import HoverTool
from bokeh.models import Legend
from bokeh import palettes
output_notebook()

def simple_plots(x, ys, color='limegreen'):
    p = figure(sizing_mode='scale_width', height=150)
    palette = palettes.Category20[min(20, len(ys))]
    for i, y in enumerate(ys):
        p.line(x=x, y=y, color=palette[i % len(palette)])
    p.background_fill_alpha = 0
    p.border_fill_alpha = 0
    p.axis.major_label_text_color = 'grey'
    p.toolbar.logo = None
    p.add_tools(HoverTool(tooltips='@y @ iteration @x', show_arrow=True, point_policy='snap_to_data'))
    show(p)


def get_receptions(
        args,
        rate,
        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)
        actual_clock_rate = sdr.getMasterClockRate()
        if actual_clock_rate != clock_rate:
            print('Clock rate set to', actual_clock_rate/1e6,'MHz instead of requested', clock_rate / 1e6, 'MHz')
    actual_clock_rate = sdr.getMasterClockRate()

    #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_signal = np.zeros(1024)
    tx_signal[int(len(tx_signal)/2):] = 0.3
    tx_signal = tx_signal.astype(np.complex64)
    
    delay_ms = 1000
    delay_clocks = int(delay_ms * 1e-3 * actual_clock_rate)
    delay_s = delay_clocks / rate
    delay_ns = int(delay_s * 1e9)
    
    num_rx_samps = len(tx_signal)
    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(20):
        sdr.activateStream(tx_stream)

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

        status = sdr.writeStream(tx_stream, [tx_signal], len(tx_signal), tx_flags, tx_time_0)
        if status.ret != len(tx_signal):
            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,
    freq=1e9,
    tx_gain=50,
    rx_gain=10,
    clock_rate=samp_rate
)
simple_plots(x=np.arange(len(receptions[0])), ys=[np.abs(y) for y in receptions])