Skip to content

Working with Timestamps

As of AirStack 1.0, the AIR-T has the capability to start an RX stream or to transmit a TX buffer at a specified time. This tutorial shows how to use these new features in your application.

Setting the Time Source

When working with the new Time API, the first thing your application needs to do is to set the appropriate time and/or clock source. The AirStack Application Notes contain details on how to accomplish this. These notes also contain an important detail on how to detect the presence of a Pulse Per Second (PPS) input and should be reviewed prior to continuing with this tutorial.

Hardware Time vs. System Time

The AIR-T comes with a Linux based operating system that has a time applied to it. That is, the OS has some system time defined that is usually the current date/time. However, this system time is not used for any of the AIR-T's SDR hardware, unless the user explicitly sets up the system accordingly. An example on how to achieve this sync through the Time API is shown later on in this tutorial.

By default, the AIR-T will start each application at a hardware time of zero. That is, the device will reset its internal clock to zero ticks each and every time your application is launched. This means that by default, the hardware clock on the AIR-T can be interpreted as a "time since application started".

For some applications, resetting the hardware clock during initialization is desired or sufficient, since these applications can use relative times. That is, an application could get the current hardware time and then use an offset from that current time to start an RX stream or to transmit a particular buffer. See the section below on syncing channels in a multiple channel stream for an example on how to achieve this. That said, other applications require an absolute time (e.g., to perform an action at an exact time such as midnight of a particular date), and therefore must modify the hardware time.

AIR-T Hardware Time Detailed

The SoapySDR Time API provides an interface to getting or modifying a hardware time. This time is defined as nanoseconds before/after some epoch. As mentioned above, the default epoch (i.e., meaning of a time of zero) on the AIR-T is the time an application was launched. This timestamp is a signed (in order to represent times before epoch) 64-bit value. On the AIR-T, the time resolution (i.e, the time of one hardware tick) is 16 nanoseconds.

There are two types of hardware time available on the AIR-T. These are now and PPS. now relates to getting or setting the time immediately while PPS relates to a PPS event. That is, using the argument of PPS when getting the hardware time will return the time of the last PPS. Using PPS for setting the hardware time on the AIR-T will result in setting the time to some value when the next PPS is detected.

Setting the Hardware Time

Application requirements will dictate how and if the hardware time must be set. For example, an application may require to simply do a coarse sync to the OS's system time. This allows the application developer to use absolute times when starting an RX stream or transmitting a TX buffer. An example of how to do a coarse sync of the hardware time to the system time is shown below.

# Get the system time (seconds after Unix epoch), convert to nanoseconds, and
# then set the hardware time to that time. This will sync the AIR-T's hardware
# time to within a few milliseconds of system time.
sdr.setHardwareTime(int(time.time() * 1e9), "now")

Synchronizing Multiple AIR-Ts

Other applications may require setting the hardware time based on a PPS event. This allows multiple AIR-Ts to be synced to one another given a few key requirements are met:

  • First, the OS's system time must be synced to some external time source. That is, since we are using system time as the basis for setting hardware time, the system time must itself be coarsely synced across AIR-Ts. Since the AIR-T's OS is Linux based, we have the ability to use NTP or PTP in order to sync the system time across multiple AIR-Ts.
  • Second, the PPS we are using for each AIR-T must be synced across AIR-Ts. That is, we must be using either an external or (on 8201 model AIR-Ts) a GPS time source, which will ensure that the rising edge of the PPS for each AIR-T occurs at nearly the same instant in time.
  • Third, we assume that the PPS transition time is somewhat related to when a second increments in system time. That is, there is some coarse sync between the PPS rising edge and the system time itself. This is generally achieved by using a GPS time source (or an external PPS tied to GPS) since the GPS PPS occurs when a second increments in GPS time.

A useful helper function on how to use PPS hardware time the achieve a time sync across multiple AIR-Ts is shown below. This should be called only after first setting the time source to either external or GPS and then ensuring that the PPS signal is present.

def sync_hw_time(sdr):
    # We first wait for a PPS transition to avoid race conditions involving
    # applying the time of the next PPS
    init_pps_time = sdr.getHardwareTime("pps")
    while init_pps_time == sdr.getHardwareTime("pps"):
        continue
    # PPS transition occurred, should be safe to snag system time and apply it
    sys_time_now = time.time()
    full_secs = int(sys_time_now)
    frac_secs = sys_time_now - full_secs
    if frac_secs > 0.8:
        # System time is lagging behind the PPS transition
        full_secs += 1
    elif frac_secs > 0.25:
        # System time and PPS are off, warn caller
        warnings.warn("System time and PPS not synced, check NTP settings!",
                      RuntimeWarning)
    time_to_set_ns = int((full_secs + 1) * 1e9)
    sdr.setHardwareTime(time_to_set_ns, "pps")

Timed RX Streams

Now that the time source and hardware time have been properly configured, we can use the hardware time for various applications. For RX, the AIR-T has the capability of starting an RX stream at a particular time. This is done by setting the timeNs and flags parameters of activateStream() appropriately as shown in the example below.

rx_stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CS16, [0])
# Assumes that the hardware time has been set to where rx_start_time is now
# meaningful to the application.
sdr.activateStream(rx_stream, flags=SOAPY_SDR_HAS_TIME, timeNs=rx_start_time)
# Can now call readStream() as you would normally, but note that you will get
# SOAPY_SDR_TIMEOUT in the event rx_start_time has not yet occurred.
Also, for RX, certain applications can leverage the fact that hardware time is a signed value. It is possible to set the current hardware time to a negative value with the future start time of the stream set to be 0. Doing so allows the hardware time to be interpreted as the time since the first sample was received.

For all timed RX streams, the first call to readStream() will return useful metadata regarding when the hardware actually received the first sample. This metadata can be processed as shown below.

rx_result = sdr.readStream(rx_stream, buffs, samples_per_xfer)
if rx_result.flags & SOAPY_SDR_HAS_TIME:
    # Time provided in result will be the time the first sample was received.
    print(f"RX stream started @ {rx_result.timeNs}")

Fixed Delay

As discussed in the externally triggered RX stream tutorial, there are two mechanisms to address fixed amounts of delay that can occur in the signal path.

Calibration

Each RX channel can be configured to delay starting samples by a fixed number of clock ticks. This cal_delay value can be used to adjust the samples stream start time outside of the SoapySDR Time API. That is, times reported by readStreamStatus() do not include these lower level calibration adjustments. The default and minimum value is 0 and maximum is 255.

cal_delay = 0   # Calibration to delay start time
# Write calibration delay register
cal_reg_addrs = [0x0005006C, 0x00050070] # Control registers for each channel
sdr.writeRegister('FPGA', cal_reg_addrs[rx_chan], cal_delay)

JESD SysRef

The Triggering a Recording goes into additional detail regarding how the RX JESD link can be configured to have a constant datapath delay.

Timed TX Buffers

Similar to RX streams, the Time API allows for TX buffers to be transmitted at a specified time. This is a very similar concept to using an external trigger, which is discussed in the Transmit Tutorial. Please review the Transmit Tutorial to familiarize yourself with these concepts prior to proceeding with this tutorial.

The main difference between an externally triggered TX buffer (i.e., a TX buffer that goes out once an external signal is detected) and a TX buffer that is meant to begin transmission at a particular time is the call to writeStream(). A few parameters need to be modified as shown below.

# To send a TX buffer at a particular time, set the flags and timeNs parameters
# as shown below.
sdr.writeStream(tx_stream, buffs, samples_per_xfer,
                flags=SOAPY_SDR_HAS_TIME, timeNs=tx_start_time)

Like with externally triggered TX buffers, the call to writeStream() here will only queue up the samples. In order to obtain whether or not the samples have made it out of the radio, readStreamStatus() must be called as shown in the Transmit Tutorial. With timed TX buffers, the result of readStreamStatus() will also populate the timeNs parameter (and set the flags appropriately to indicate the presence of a timestamp) to report back the time the TX buffer was transmitted. That is, we can expand upon the example from the previous Transmit Tutorial and add a mechanism for reading out the time that the TX buffer went out on each channel. From there we can also detect issues where the transmission occurred after we intended it to, likely due to not giving the hardware enough time to queue up the buffer (i.e., the tx_start_time was not far enough in the future for the hardware to have enough time to queue up the buffer).

max_chans = sdr.getNumChannels(SOAPY_SDR_TX)
results_arr = [None] * max_chans
results_pending = True
while results_pending:
    tx_result = sdr.readStreamStatus(tx_stream, timeoutUs=10000000)
    if tx_result.ret != SOAPY_SDR_TIMEOUT:
        for i in range(max_chans):
            if tx_result.chanMask & (1 << i):
                if results_arr[i] is None:
                    results_arr[i] = tx_result
                else:
                    raise RuntimeError(f"Got multiple results for channel {i}!")

                # Check the timestamp we received from the device and see if the
                # TX buffer went out late.
                time_reported = tx_result.flags & SOAPY_SDR_HAS_TIME
                if time_reported and tx_result.timeNs > tx_start_time:
                    print(f"LATE TX ON CHANNEL {i}!")

        results_pending = False
        for chan in channel_indexes:
            if results_arr[chan] is None:
                results_pending = True

Fixed Delay Calibration

Each TX channel can be configured to delay starting samples by a fixed number of clock ticks. This cal_delay value can be used to adjust the samples stream start time outside of the SoapySDR Time API. That is, times reported by readStreamStatus() do not include these lower level calibration adjustments. The default and minimum value is 0 and maximum is 255.

cal_delay = 0   # Calibration to delay start time
# Write calibration delay register
cal_reg_addrs = [0x000500B8, 0x000500BC] # Control registers for each channel
sdr.writeRegister('FPGA', cal_reg_addrs[tx_chan], cal_delay)

Syncing Channels

An extremely useful application for timed RX streams or TX buffers is to use these mechanisms to ensure multiple channels are synced to one another. That is, in a multiple channel stream, there are normally no guarantees that each channel starts transmission or reception at the same time as the other channels in the stream. We can solve this problem by using timeNs appropriately when working with multiple channel RX or TX streams. For example, for RX streams, if we set timeNs when calling activateStream(), we will now ensure that all RX channels in the stream will start reception at the same instant in time. Similarly, for TX buffers, setting timeNs when calling writeStream() will ensure that all channels in the stream will transmit their buffer at the exact same time.

The example below shows how to use relative time with RX streams to ensure that each channel in the stream starts reception at the same time.

channel_indexes = [0, 1]
rx_stream = sdr.setupStream(SOAPY_SDR_RX, SOAPY_SDR_CS16, channel_indexes)

# Get the current time and set the rx_start_time to about three seconds from now
hw_time_full_secs = int(sdr.getHardwareTime("now") / 1e9)
rx_start_time = int((hw_time_full_secs + 3) * 1e9)  # in nanoseconds

# All channels in the stream will start reception at rx_start_time
sdr.activateStream(rx_stream, flags=SOAPY_SDR_HAS_TIME, timeNs=rx_start_time)


Last update: January 17, 2024