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) aGPS
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 anexternal
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.
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)