RF Data Converter Loopback Tutorial

Table of Contents

Click here to go to the bottom of this tutorial.

Start

This tutorial shows how to build a project from scratch for the ZYNQ RFSoC that does the following:

  1. Streams an arbitrary complex waveform using DMA from a Python-created array into the FPGA, going into a dual-clock FIFO
  2. The FIFO feeds the RF Converter DAC input for Tile 228 DAC 0 which connects to the DAC_B SMA output
  3. Inside the RF Converter, mix the waveform with a carrier wave at some frequency $\omega_0$ that you specify in Python,
  4. Outputs a real analog waveform on the DAC_B SMA output using a sampling frequency $f_s=6.88128$ GSps that is specified when the FPGA project is built,
  5. You connect the DAC_B output to the ADC_A input
  6. Converts the incoming signal using a different sampling frequency $f_s=4.9152$ GSps,
  7. Mixes it with the a carrier frequency that you can set in Python
  8. Streams the captured complex data back into memory using DMA for analysis etc.

All is controlled from Python with PYNQ, and all filters will be set in the FPGA project to keep things in the 1st Nyquist zone.

We will use the RealDigital 4x2 RFSoC board running PYNQ version 3.1.1, and Vivado 2024.2. You should definitely work through the GPIO and DMA tutorials first, because we build directly on them. In particular we re-use the AXI DMA engine and the PS–PL interface ideas from the DMA tutorial, and assume you are comfortable with the Vivado block designer, generating a bitstream, and copying the .bit and .hwh files to the board.

The point of this project is to do what the canned PYNQ base overlay does not let you do easily: instead of just programming a static carrier (NCO tone), we feed real, sample-by-sample I and Q data into the DAC's digital up-converter from a buffer in DDR. That means you can synthesize any baseband waveform you like in numpy, send it, and recover it.

After each section in the above table of contents, where appropriate, there's an "IP Configuration summary" that has all of the steps needed to build the project in Vivado in one place, to make things easy.

Back to top

Archive

The files needed to recreate everything are here:

Back to top

Project Preamble

There are several complications that need to be discussed in order to make such a project work, notably 1) how to make sure that when we read data from the ADC in Python, we see the signal that we sent out (also in Python); 2) how to do this using the AXI DMA engines. The former is an issue of synchronization, and the latter involves details about AXI DMA, a powerful yet also constrained way to do direct memory access.

First, the synchronization issue. Imagine some python code where you first put some data into a large array, send it via DMA to the DAC, route the data from the DAC SMA output into the ADC SMA input, DMA read it back into an array, and then do something in Python (Fourier analyze, plot, etc). First of all, the DAC in the RF Converter runs continuously: data in, converted to analog, voltage out. There's no "start" or "stop" signal. So if you just send a burst of data to the DAC in the RF Converter, the DAC will immediately process this data and an analog voltage will appear on the SMA output connector. If you run the cable from this connector into the ADC, it will do the same thing the DAC does only in reverse: analog in, conversion, digital data out. No "start" or "stop", continuous operation. But in Python, you want to make sure that when you read the data from the ADC, you are seeing the waveform that you sent to the DAC. If you just DMA an array from DDR to the DAC and then read the ADC, you probably will see only noise unless you control the timing in some way.

The way we will solve this problem is simple: we define an array in DDR memory, and then tell the hardware to DMA this memory to a FIFO (and we always use a FIFO to cross clock boundaries) in a mode where when it gets to the last word in the memory and all the data is transferred, it immediately starts back at the beginning and does it again, in cyclic mode. This way as long as we read the same amount of data (or less) than we sent, we will always see the waveform.

There are 2 sub problems with this that we need to take care of. The first involves the waveform we are sending (waveform wrapping), and the second involves continuity in reading the FIFO that contains the ADC data (FIFO continuity).

Waveform wrapping

Imagine you want to send a waveform that contains a sine wave with frequency $f$ and period $T$ on top of some carrier wave (it doesn't matter what frequency). You just build it in an array in Python, and DMA it in cyclic mode to the FIFO that feeds that DAC. So you set up a large array with $N$ samples ($N$ is large), and set a "sampling" $f_s$ rate in Python to make the array. When the last element of the array has been sent, cyclic mode means that the 1st element will follow. So if the phase between the last element and the first is not continuous, the discontinuity will generate a spectral leakage tone in the ADC data that you read back.

To make sure there's no phase discontinuity, first define the buffer length in time as $T_b = NT_s=N/f_s$ where $T_s=1/f_s$ is the sampling period. Then the number of wavelengths $m$ of your wave in that buffer time $T_b$ would be given by $m = T_b/T$, and we want $m$ to be an integer. Substitute for $T_b=N/f_s$ to get $$m=N\cdot \frac{f}{f_s}\label{mint}$$ If $m$ is an integer, and $N$ is also an integer by definition, then this means that $f/f_s$ has to be a rational number.

Note that $f_s$ is not a free parameter, in fact this is what sets the time scale for your array, so it has to be related to the true sampling rate of the DAC you will use below, and the input to that DAC is actually where the true time scale comes from since that time scale is related to the DAC time scale by the interpolation. As you will see below, that input rate is carefully calculated, and is the read clock for the TX_FIFO that sits in front of the DAC. So you have to make $f_s$ equal to that read clock in order for the definition of time between elements in your array that you are sending to have a controllable meaning.

Your array size $N$ is probably something you want to specify carefully as well. If it's an even power of 2, then that helps with the FFT you might use if you want to send a more complicated array that comes from an inverse Fourier transform, and an even power of 2 also helps with the DMA alignment (DMA bursts are a power of 2, and you want your memory to be an integer multiple of bursts). So best to make $N$ some even power of 2.

So to satisfy $\ref{mint}$:

Then to make number of cycles $m$ for your tone of frequency $f$ to be an integer: $$m' = int(\frac{fN}{f_s})\nonumber$$

and use that in equation $\ref{mint}$ to modify $f$ slightly so that that the ratio of frequencies is a rational number to get $f'$: $$f' = \frac{m' f_s}{N}\nonumber$$ As a concrete example, below we have constructed a project that has a TX_FIFO read clock of 172.032 MHz. We want to generate a tone that has a $f=100$ MHz frequency, and we will send an array that has $2^{24}=16.7M$ points. If we use those to calculate $m$ we would get $m=f\cdot N/f_s=100M\cdot 2^{24}/172.032M=9752380.95$. Turning that into an integer gives $m'=9752389$, and that gives us a value for $f'=99.99999023$, which is different from $f$ by $\sim 9.8Hz$.

If your waveform is more complex, then you have to work a little harder, but not much. Say your waveform is generated from a spectrum of frequencies $f_i$, and you want to inverse Fourier that spectrum and put it into a buffer of size $N$ that is from a sampling with frequency $f_s$, just like above. Here making $N$ a power of 2 is even more important for the inverse FFT, and as above you keep $f_s$ equal to what the hardware requires. So you find $m_i'$ for each $f_i$ such that $m_i'$ is an integer, and find new frequencies $f_i'$ using $m_i'$ and equation $\ref{mint}$.

FIFO continuity

This is a problem that has to do with the FIFO that you read for the results of the ADC. A FIFO means first in, first out, and the usual way a FIFO works, once it's full, it keeps everything and won't let you write anything more into it. The problem with that is that the RF Converter will continually send data into the FIFO from the ADC, no "start" or "stop", as fast as it's supposed to go. Let's say you have a FIFO that is 1024 words deep. If you are not careful, then the first 1024 words will fill the FIFO, it'll stop, and then when you start reading it, it will allow more data to fill but that data will be discontinuous from the first 1024 words.

What you have to do then is to control the FIFO carefully by using the FIFO reset input. What you have to do is to send a signal (via GPIO) to reset the FIFO, and keep it reset. This prevents any data from coming into the FIFO from the RF Converter. Then initiate a DMA transfer from that FIFO. Since the FIFO will be empty, the DMA will wait until it has some data to transfer. Then you deassert the FIFO reset, and everything will be continuous.

This means you have to first add a GPIO that connects the output to the FIFO reset input, and write some Python code. But that's all you need to do.

Back to top

Radio theory

Before we start configuring the FPGA and RF Converter, first a little bit on radio theory might be in order.

Imagine you have 2 waveforms with different frequencies $\omega_1$ and $\omega_2$: $\cos\omega_1t$ and $\cos\omega_2t$ (but with the same amplitude). Multiplying these waveforms together is called "mixing" (as understood in the telecommunications industry), and the math is straightforward, using trigonometry identities: $$\begin{align} \cos([\omega_1+\omega_2]t)&=\cos\omega_1t\cos\omega_2t-\sin\omega_1t\sin\omega_2t \nonumber\\ \cos([\omega_1-\omega_2]t)&=\cos\omega_1t\cos\omega_2t+\sin\omega_1t\sin\omega_2t \nonumber\\ \sin([\omega_1+\omega_2]t)&=\sin\omega_1t\cos\omega_2t+\cos\omega_1t\sin\omega_2t\nonumber\\ \sin([\omega_1-\omega_2]t)&=\sin\omega_1t\cos\omega_2t-\cos\omega_1t\sin\omega_2t\nonumber \end{align}\nonumber$$ Adding and subtracting $\cos([\omega_1+\omega_2]t)$ and $\cos([\omega_1-\omega_2]t)$ gives: $$\begin{align} \cos\omega_1t\cos\omega_2t &=\frac{1}{2}\big[\cos([\omega_1-\omega_2]t)+ \cos([\omega_1+\omega_2]t)\big]\label{mixing}\\ \sin\omega_1t\sin\omega_2t&=\frac{1}{2}\big[\cos([\omega_1-\omega_2]t)- \cos([\omega_1+\omega_2]t)\big]\label{mixing2}\\ \end{align}$$ and adding and subtracting $\sin([\omega_1+\omega_2]t)$ and $\sin([\omega_1-\omega_2]t)$ gives: $$\begin{align} \sin\omega_1t\cos\omega_2t &=\frac{1}{2}\big[\sin([\omega_1-\omega_2]t)+ \sin([\omega_1+\omega_2]t)\big]\label{mixing3}\\ \cos\omega_1t\sin\omega_2t&=\frac{1}{2}\big[\sin([\omega_1+\omega_2]t)- \sin([\omega_1-\omega_2]t)\big]\label{mixing4}\\ \end{align}$$

Equation $\ref{mixing}$ (or any of these equations) tells us that when you mix 2 waveforms, the result is 2 waveforms, one which has the difference in frequency and one that has the sum. The actual multiplying of two analog waveforms together is not a trivial thing, and for analog waveforms you might have to build a special circuit using op-amps with matched diodes or transistors, but it's quite a common thing to do this!

Now let's say you have a wave $\cos\omega t$ (frequency $\omega$) that you want to send, and you want to send it on a "carrier wave" that has a larger frequency $\omega_0$ ($\omega_0\gt\omega$). Say both have the same amplitude. If the carrier wave is described by $\cos\omega_0t$ and you mix them together, you will get a transmitted waveform $$\cos\omega_0t\cos\omega t=\half\cos(\omega_0-\omega)t+\half\cos(\omega_0+\omega)t\nonumber$$ Note that if you mix the $\cos\omega t$ wave with $\sin\omega_0t$ instead of $\cos\omega_0 t$, you get $$\sin\omega_0t\cos\omega t=\half\sin(\omega_0-\omega)t+\half\sin(\omega_0+\omega)t\nonumber$$ Here you see clearly that the two transmitted waveforms consist of one with frequency $\omega_0-\omega$ and one with frequency $\omega_0+\omega$. The terminology is that you are sending information ($\cos\omega t$) in the "sidebands" of the carrier wave that is centered at $\omega_0$. What this also means is that the bandwidth needed to transmit information that consists of the frequency $\omega$ is $(\omega_0+\omega)-(\omega_0-\omega)=2\omega$. This is not an efficient use of bandwidth!

Let's say the transmitter mixes the incoming $\cos\omega t$ with a carrier wave $\cos\omega_0 t$. The transmitted wave will then be the product of the signal with the carrier: $$TX(t)=\cos\omega t\cos\omega_0 t\nonumber$$ The receiver then mixes the incoming signal with a tone from an oscillator that has the same frequency and phase as the carrier wave: $\cos\omega_0 t$. This gives you $$\begin{align} RX(t) &= TX(t)\cos\omega_0 t\nonumber \\ &= (\cos\omega t\cos\omega_0 t)\cos\omega_0 t\nonumber \\ &= \cos\omega t\cos^2\omega_0 t\nonumber \\ &= \half\cos\omega t(1+\cos 2\omega_0 t)\nonumber \\ &= \half\cos\omega t + \half\cos\omega t\cos2\omega_0 t\nonumber \\ &= \half\cos\omega t + \frac{1}{4}\big[ \cos(2\omega_0-\omega)t+\cos(2\omega_0+\omega)t\big] \end{align}\nonumber$$ You can see in the last step that what you are left with is the original signal $\cos\omega t$ (with half the amplitude) plus a signal centered at $2\omega_0$ with sidebands at $\pm \omega$. That last part is easily filtered by a low pass filter, leaving only the signal, at half the amplitude (because you've thrown half of the signal away with the filtering).

Now imagine that you want to send some information to someone over radio frequencies, encoded in some kind of time varying voltage that's not a simple $\cos\omega t$. For instance, say the information is of the form $f(t)$, which tells you the voltage as a function of time. $f(t)$ might be something simple like the waveform resulting from a microphone output, so the frequency range will be limited to within a few 10s of kHz. You can mix $f(t)$ with a carrier wave at a higher frequency, say $\cos\omega_0t$ where $\omega_0$ could be in the MHz radio range, and send all of the Fourier components of $f(t)$ mixed with $\cos\omega_0t$. The resultant waveform will have the Fourier components of $f(t)$ each mixed with $\cos\omega_0t$, so you will transmit the sum and the difference of each component with the carrier wave. At the receiver, you then generate the same carrier wave and mix it with the antenna output, and you will again get 2 waves: the difference, which will be $f(t)$, and the sum, which will be $f(t)$ mixed with twice the carrier wave. That higher frequency waveform is easy to filter out, leaving $f(t)$, which is what you want to send. Such is the power of the principle of superposition!

Voila, you've invented what is called homodyne radio transmission! (Note: heterodyne radio first mixes the incoming waveform down to an intermediate frequency (IF), mainly so that you can filter noise at a lower frequency than the high frequency carrier wave. It does this by mixing the incoming signal with a wave that has a frequency slightly different from the carrier wave, filtering the resultant IF waveform, and then mixing with another waveform that has the IF frequency to get the symbols.)

Back to the simple case of a carrier wave ($\omega_0$) and some information ($\omega$). The above works if you mix the wave with the information you want to transmit ($\cos\omega t$) with a carrier wave at the transmitter and the resulting wave with a carrier wave at the receiver, and both carrier waves have the same phase. In other words if you mix $\cos\omega t$ with $\cos\omega_0t$ at the transmitter, then you mix the resulting wave with $\cos\omega_0 t$ at the receiver. And same for if you use $\sin\omega_0t$, you have to use the same wave at the receiver. If you don't, and mix the $\cos\omega t$ with $\cos\omega_0t$ at the transmitter: $$TX(t)=\cos\omega t\cos\omega_0 t\nonumber$$ and $\sin\omega_0t$ at the receiver, you would get: $$\begin{align} RX(t)&=TX(t)\sin\omega_0 t\nonumber \\ &=\big[\cos\omega_0 t\cos\omega t\big]\sin\omega_0 t \nonumber \\ &= \cos\omega t\big[\cos\omega_0 t\sin\omega_0 t\big]\nonumber \\ &= \half\cos\omega t\sin 2\omega_0 t\label{cossin} \end{align}$$

This result eliminates the signal and leaves you with only the high frequency component, which when you filter out leaves you with nothing. In other words, the relative phase between the transmitter and receiver carrier wave matters greatly.

So what we really want to do is to construct things so that the receiver is "phase blind" and doesn't have to know what the phase of the transmitter carrier (whether it's $\sin$ or $\cos$). We would also like to decrease the required bandwidth of $2\omega$ for sending a signal at a carrier frequency of $\omega_0$. So to summarize, we now have 2 problems to solve here:

  1. Mixing the $\cos\omega t$ wave with a carrier wave requires twice the bandwidth, since the result sent will be 1 wave with the difference in frequencies and one with the sum
  2. The receiver needs to know the phase of the transmitter carrier wave to recover the signal
The solution is in something called "single sideband" transmission, or SSB.

SSB transmission

Trigonometry is amazing, and the beginnings of the solution to the above 2 problems was due to an engineer working at General Electric in the 1940s and 1950s named Donald Norgaard, who pioneered the phasing method for single sideband communication. He designed hardware that split an analog signal into 2 paths, one phase shifted by $90^\circ$, and mixed them with 2 carriers that had a relative phase of $90^\circ$. So we start with a signal that has 2 phases: an "in-phase" component (I) and a "quadrature" component (Q), where "in-phase" is relative to the original signal, here $\cos\omega t$. The information wave would have the form: $$\begin{align} I(t)&=\cos\omega t\nonumber \\ Q(t)&=\sin\omega t\nonumber \\ \end{align}\nonumber$$ Then you mix the $I(t)$ component with the $\cos\omega_0 t$ carrier and the $Q(t)$ component with the $\sin\omega_0 t$ carrier, you would get 2 waves: $$I(t)\cos\omega_0 t = \cos\omega t\cos\omega_0 t = \half[\cos(\omega_0-\omega)t+\cos(\omega_0+\omega)t]\nonumber$$ and $$Q(t)\sin\omega_0 t = \sin\omega t\sin\omega_0 t = \half[\cos(\omega_0-\omega)t-\cos(\omega_0+\omega)t]\nonumber$$ and subtract them together to make the transmitted wave $TX(t)$: $$\begin{align} TX(t) &=I(t)\cos\omega_0 t-Q(t)\sin\omega_0 t\nonumber\\ &=\cos\omega t\cos\omega_0t-\sin\omega t\sin\omega_0t\nonumber\\ &=\cos(\omega_0+\omega)t\label{iq} \end{align}$$ By subtracting, we are choosing the upper sideband $\omega_0 + \omega$. If we were to add we would be choosing the lower sideband $\omega_0 - \omega$. This is just a convention, both are equally valid, but the point is that we now are sending only 1 sideband, decreasing the bandwidth needed for transmission. This shows clearly that we've already solved problem 1 above: we are now sending a signal that has a single frequency $\omega_0+\omega$, which means we need a smaller bandwidth than if we send one with the sum and one with the difference in these two frequencies.

At the receiver, you take this signal and split it into two equal parts by dividing the power, so each part will have an amplitude: $$\frac{1}{\sqrt 2}\cos(\omega_0+\omega)t\nonumber$$ Then you mix one of them with $\cos\omega_0 t$ ($I$) and the other with $\sin\omega_0 t$ ($Q$) . To see this explicitly: $$\begin{align} I &= \big[\frac{1}{\sqrt 2}\cos(\omega_0+\omega)t\big]\cos\omega_0 t\nonumber \\ &= \frac{1}{\sqrt 2}\big[\cos\omega t\cos\omega_0t-\sin\omega t\sin\omega_0t\big]\cos\omega_0 t\nonumber\\ &= \frac{1}{\sqrt 2}\big[\cos\omega t\cos^2\omega_0 t - \sin\omega t\sin\omega_0 t\cos\omega_0 t\big]\nonumber\\ &= \frac{1}{\sqrt 2}\big[\half\cos\omega t(1+\cos 2\omega_0t)-\half\sin\omega t\sin 2\omega_0 t\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[\cos\omega t+(\cos 2\omega_0 t\cos\omega t-\sin 2\omega_0 t\sin\omega t)\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[\cos\omega t+\cos(2\omega_0+\omega)t\big]\nonumber\\ \end{align}\nonumber$$ Voila, we recover the original transmitter $I(t)$ and filter out the higher frequency part near $2\omega_0$. The other half of the signal is mixed with $\sin\omega_0 t$ at the receiver to get: $$\begin{align} Q &= \big[\frac{1}{\sqrt 2}\cos(\omega_0+\omega)t\big]\sin\omega_0 t\nonumber \\ &= \frac{1}{\sqrt 2}\big[\cos\omega t\cos\omega_0t-\sin\omega t\sin\omega_0t\big]\sin\omega_0 t\nonumber\\ &= \frac{1}{\sqrt 2}\big[\cos\omega t\sin\omega_0 t\cos\omega_0 t - \sin\omega t\sin^2\omega_0 t\big]\nonumber\\ &= \frac{1}{\sqrt 2}\big[\half\cos\omega t\sin 2\omega_0 t-\half\sin\omega t(1-\cos 2\omega_0t)\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[-\sin\omega t+(\sin 2\omega_0 t\cos\omega t+\cos 2\omega_0 t\sin\omega t)\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[-\sin\omega t+\sin(2\omega_0+\omega)t\big]\nonumber\\ \end{align}\nonumber$$ So we recover what we sent.

This is exactly how the ZYNQ RF Converter works, except that it does it in the digital domain.

So far we assumed the receiver's oscillator has exactly the same phase as the transmitter's carrier. But problem 2 was precisely that the receiver should not have to know that phase. So let's let the receiver oscillator be off by some unknown phase $\phi$, mixing the incoming signal with $\cos(\omega_0 t+\phi)$ on the $I$ branch and $\sin(\omega_0 t+\phi)$ on the $Q$ branch. At the receiver, the steps are identical to above, just carrying $\phi$ along: $$\begin{align} I &= \Big[\frac{1}{\sqrt 2}\cos(\omega_0+\omega)t\Big]\cos(\omega_0 t+\phi)\nonumber\\ &= \frac{1}{2\sqrt 2}\big[\cos([\omega_0+\omega+\omega_0]t+\phi)+ \cos([\omega_0+\omega-\omega_0]t-\phi)\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[\cos(\omega t-\phi)+\cos((2\omega_0+\omega)t+\phi)\big]\nonumber \\ Q &= \Big[\frac{1}{\sqrt 2}\cos(\omega_0+\omega)t\Big]\sin(\omega_0 t+\phi)\nonumber\\ &= \frac{1}{2\sqrt 2}\big[\sin([\omega_0+\omega+\omega_0]t+\phi)- \sin([\omega_0+\omega-\omega_0]t-\phi)\big]\nonumber\\ &= \frac{1}{2\sqrt 2}\big[-\sin(\omega t-\phi)+\sin((2\omega_0+\omega)t+\phi)\big]\nonumber \end{align}\nonumber$$ After the low pass filter removes the terms near $2\omega_0$, the two branches are $$\begin{align} I &= \frac{1}{2\sqrt 2}\cos(\omega t-\phi)\nonumber \\ Q &= -\frac{1}{2\sqrt 2}\sin(\omega t-\phi)\nonumber \end{align}\nonumber$$

Now treat the two branches as the real and imaginary parts of a single complex number, which is what "$I+iQ$" really means. Recombining (the minus just absorbs the sign flip on the $Q$ branch we already noted above): $$I-iQ = \frac{1}{2\sqrt 2}\big[\cos(\omega t-\phi)+i\sin(\omega t-\phi)\big] = \frac{1}{2\sqrt 2}\,e^{i(\omega t-\phi)} = \frac{1}{2\sqrt 2}\,e^{i\omega t}\,e^{-i\phi}\nonumber$$

The transmitter sent the complex baseband $e^{i\omega t}$, and we recovered it multiplied by a constant phasor $e^{-i\phi}$. The unknown receiver phase $\phi$ does not destroy the signal, it simply rotates the $(I,Q)$ vector by a fixed angle. Contrast this with the single-mixer case of equation $\ref{cossin}$, where the wrong phase wiped the signal out completely.

And if you care about the signal power, exactly the situation when you're hunting for an axion conversion signal in ADMX, the phase drops out entirely: $$I^2+Q^2 = \frac{1}{8}\big[\cos^2(\omega t-\phi)+\sin^2(\omega t-\phi)\big]=\frac{1}{8}\nonumber$$ independent of $\phi$. The receiver is genuinely phase blind: by keeping both quadratures it recovers the full complex amplitude up to a known rotation, and the power $I^2+Q^2$ comes out intact regardless of the relative phase between the two oscillators. That solves problem 2.

Back to top

Amplitude Dithering

You have a sine wave that has a peak-to-peak range of $V_{pp}$, and you want to sample it with an $N$ ADC at some sampling rate. The ADC can return $2^N$ different values, and each bit of the ADC covers a voltage range of $V_{pp}/2^N$. This is called the $LSB$. $$LSB \equiv \frac{V_{pp}}{2^N}\label{LSB}$$ If $V_{pp}=2$ volts (say the ADC is bipolar between $\pm 1$ volt) and $N_{bits}=8$, then $LSB=2/256=7.8$ mV. If the output of the ADC returns a value, call this $V_{adc}$ and the signal that gets sampled is called $V$, then your quantization error is defined as: $$\delta = \frac{V_{adc}-V}{LSB}\label{qe}$$

Because standard rounding quantizes to the nearest integer of the ADC, this "error" is really an uncertainty, and is bounded within $\pm 0.5 LSB$, which means that any value returned by the ADC is only accurate up to half an LSB.

In the following, we plot a $107.3 kHz$ sine wave quantized with an 8-bit ADC, showing the pure sine wave, and the white sampled points after quantization.

It looks pretty good, but in fact the points are not exactly on the line, and if you blow up this plot around any point, you will see the quantization effect:

As you can see, the effect is pretty small. In the next plot we see the actual quantization error as defined in $\ref{qe}$.

The points do not sit on a perfect sine wave, and so when you Fourier analyze the digitized values, you should expect to see some spurious signals (called "spurs"). In the next plot we show the power spectrum, plotted in dBFS (dB full scale). You can see the signal clearly. If you were to look at all of the frequency components and take the one with the largest power, then the difference between the signal power and that largest "noise" power is called the "spurious-free dynamic range", or SFDR. In the plot below, the signal is at -1.94 dBSF and the largest noise is at -66.8 dBFS, so SFDR = -66.8+1.94=-64.9 dBFS. And the average of the noise is at -97.6 dBFS. It is important to note that this spur is due to the quantization, and is -66.8-(-97.6)=30.8 dB above the noise. And if you run the same experiment again and again, you will always see this spur at around the same level.

The noise is low, but there are frequencies outside of the main sine wave that are showing up in the Fourier plot, and that means that those frequencies are somewhat enhanced. This is a consequence of the quantization, because you are sampling at a fixed window in time, and the sine wave has a fixed period, so there will be repeatable places where the quantization error is large. And that will inject "noise" at a fixed rate, boosting the SFDR. And we might want to get rid of those "spurs" in the frequency spectrum.

So the way to deal with those spurs is to actually add noise, and do it in a way that will smear the power of those spurs into adjacent bins, bringing down the SFDR. But it will add to the noise floor.

Note that we want to add enough noise to the analog signal before quantization so that the quantization error pushes the quantized values past digitization boundaries. So if we added noise that has an RMS of less than 0.5 LSB, it will usually not due a whole log to change the digitized values from what they would be without dithering, so let's add noise that has an RMS of 0.8 LSB. The effect is shown in the next plot, where we see the difference between the digital values before and after dithering.

You can see that sometimes there's no effect (Delta=0) but often the dithering will change the ADC value by 1 bit. When we Fourier analyze the dithered waveform, we see the following:

You can see that the highest peak is now at -68.3, and since the signal is at the same -1.94 dBFS, the SFDR is now 66.4 dB, which is 1.5 dB better than before.

So what did dithering accomplish? It improved SFDR by 1.5 dB, and it raised the noise floor from -97.6 dBFS to -79.4 dBFS, which looks like we made the nose 18.2 dB worse. But more importantly, the spur that we see is not really a spur, it's a noise fluctuation, and it's 11.1 dB above the noise, as opposed to 30.8 dB, an improvement of over 20 dB. And, if you do the experiment again, you will find the maximum in a different place.

When you have an analog signal you want to digitize, and the amplitude is small compared to the LSB, standard quantization doesn't just create spurs, but instead turns the continuous sine wave into a harsh square wave or step function. Just imagine that the amplitude goes below 1 LSB, then it becomes a flat spectrum, even worse! With amplitude dithering, the noise constantly bounces the weak signal across the quantization threshold, and with averaging you can reconstruct the signal wherease without dithering you will get the signal and a lot of spurs at similar amplitudes.

In applications like radar, you absolutely do not want spurs since you can't tell those apart from real signals. So dithering is important there.

If the signal is so weak that it's at the same level as the spurs, then you can't tell the signal from the spur, but dithering will still help if the signal stays at a fixed frequency and the spurs average out to zero. With enough averaging the signal will remain.

Back to top

What the RF Data Converter does

The RFSoC board is basically implementing a digital version of homodyne radio transmitting and receiving: for transmission (using the DAC), you can supply a waveform $f(t)$, specify a carrier wave frequency (it will generate the carrier wave), and it will mix the two together and send it along. For receiving (using the ADC) you supply the carrier wave frequency and it will mix and filter and present you with the result.

The RF Data Converter on the ZU48DR (ZYNQ) chip groups 2 ADCs into what they call a tile, and the same for DACs (2 DACs per tile). This is done to save resources to some extent, as each of the 2 converters in a tile share the same clock and PLL resource, but the additional benefit of grouping into a tile is that because each converter in the tile runs off the same clock and PLL, they are inherently phase locked, and this can be important when generating signals or converting analog to digital. Also, the chip allows you to use 2 DACs in a tile to output a complex I and Q waveform, hence phase coherence will be important. For our purposes, we will only use the DAC to send out real waveforms.

Tiles that contain ADCs are numbers 224-227 and tiles that contain the DACs are numbered 228-231. These numbers may seem odd but they are simply the numbers of the I/O banks the converters connect to on the FPGA, and they are sequential to keep the clock routing simple (and minimize jitter). The ADCs and DACs are referred to by Vivado (2024.2) as "ADC 0" and "ADC 1" on a tile, and same for the DACs ("DAC 0" and "DAC 1"). If you look at the 4x2 board, you will see the ADCs and DACs labeled at the SMA connectors with names ADC_A, ADC_B, ADC_C, ADC_D, DAC_A, DAC_B. The mapping from SMA label to tile to Vivado label (not an easy thing to get as it involves quite a bit of cross-checking!) is the following:

SMA LabelComponentRFDC TileVivado Block
DAC_ADAC230DAC 0
DAC_BDAC228DAC 0
ADC_AADC226ADC 1
ADC_BADC226ADC 0
ADC_CADC224ADC 1
ADC_DADC224ADC 0

The tiles you can use will be 228 and 230 for the DACs (with only 1 DAC available on each tile), and 224 and 226 for the ADCs (with both ADCs available on each tile). We will use DAC 0 on tile 228, which means DAC_B on the 4x2 output, and ADC 1 on tile 226, which will mean inputs are on ADC_A on the board.

When we say there are 2 ADCs per tile, by "ADC" we really means a shorthand for all of the function blocks that go into digitizing an analog signal. And the same for the 2 DACs per tile, these include all of the digital processing blocks that result in an analog output. Both of these are described in the Zynq UltraScale+ RFSoC RF Data Converter IP product guide (PG269).

In the following diagram, the RF Converter is shown as a component in the ZU48DR chip. The block labeled "PL fabric I/Q via DMA" describes the FPGA parts that lets I and Q data flow via AXI DMA into some FIFOs (not shown), and from the FIFO injected into the DAC path. The data is first up converted and mixed to produce a real stream (this is called the "Digital Up Converter", or DUC stage), and then some processing (filtering) is performed before the digital signal is converted to analog and sent out to the SMA connector. We will loop that signal back into one of the ADC SMA connectors, where the analog signal undergoes some processing, is converted to a digital signal by the ADC at some sampling rate, and that real digital data stream is mixed and down converted into a complex I and Q stream and sent into a FIFO for DMA transmission into memory for the PS to process.

ZU48DR RF Converter DAC and ADC dataflow

Back to top

DAC Path

Imagine that you want to create an analog output that consists of a carrier wave with some symbols mixed in. With a ~10 GSps DAC, by the Nyquist theorem you are limited to carrier waves at 5GHz or less. You could create the I and Q components in a Python array that lives in the DDR, and DMA the data to the FPGA and RF Converter and have it added together and digitized by the DAC and output. But if the DAC is digitizing at 10 GSps, then you'd have to clock your data in at that rate, and there's no FPGA that can handle data rates that fast. In fact, try running with a fabric clock above maybe 400 MHz on the ZU48DR and you will find it difficult to meet timing constraints, which means the design will be prone to setup/hold violations and the kind of hard-to-debug behavior (from things like race conditions) that comes with them. And the same goes for the ADC: digitizing at 5 GSps means I and Q data appear at that rate, and this is impossible to handle inside the FPGA.

The solution is to "up convert" from the digital data to the sampling rate. Up conversion is done by the "Digital Up Converter" (DUC) circuit, described next.

Digital Up Converter (DUC)

The DUC's job is actually twofold:

So for example, say you have some data representing a sine wave with a 50MHz frequency, and you want to convert it to an analog waveform using a DAC running at 400MSps. We will generate the data at 200MSps, comfortably above the Nyquist rate for a 50MHz signal, and let the DUC interpolate by 2x to bring it up to the 400MSps DAC conversion rate. In the figure, the markers show the coarse 200MSps data sent toward the DAC, and the line shows the sine wave that these markers fall on. You can see that the period is 20ns=1/50MHz and we are sending 4 samples per period, which is what a 200MSps data rate gives you.

And the Fourier spectrum will just be a spike in the 1st Nyquist zone. Note that we are Fourier analyzing the incoming data stream at a sampling rate that is the incoming data stream rate (200MSps). In the plot below, only the positive frequencies are shown. We see frequencies in the 1st 2 Nyquist zones: the 50MHz signal and the image at 200MHz-50MHz=150MHz.

Now for the interpolation. Since you are running the DAC conversion at twice the rate as the incoming "coarse" data, you need to produce data at twice the rate that you can feed into the converter. So what the DUC first does in "interpolating" is to increase the data rate by using each coarse data point and adding a zero in every other clock cycle. The following plot shows this new data stream superimposed on the incoming sine wave with the coarse data.

The following plot shows the 50MHz sine wave superimposed on the coarse data with zeros added:

Since you've added zeros in between each coarse data point, we now have a data stream with twice the number of points per second, so this is effectively sampling at twice the rate (and that higher rate is the DAC sampling frequency we are going to use). Also, those extra zeros mean we now have a data stream with quite a bit more transitions, so we've effectively added a higher frequency component to the data.

If we Fourier analyze this new data stream, since the rate is twice the rate of the coarse data, we are "sampling" at a higher rate , and the 1st Nyquist zone is now twice what it was for the Fourier analysis of the coarse data. The new frequency that shows up is exactly the image of the 50MHz incoming wave in the 2nd Nyquist zone. So by adding zeros you aren't really adding new frequencies, you are just increasing the sampling rate so that the images show up as harmonics in the new choppy (zero added) data stream. Having twice the sampling means that the 1st image from the 50MHz data now falls inside the analysis band. This is seen in the Fourier plot below:

Zero stuffing allows you to up convert to the DAC sampling rate, but it adds those higher frequencies. So you have to filter those higher frequencies out. What the DUC does then is to take the I and Q streams that have been zero stuffed (we increased the rate by x2, so our "interpolation" is 2), and apply a filter that filters out everything in the higher Nyquist zones (keeping only the 1st). This filtering is on each clock cycle of the new double rate data. For 2x interpolation the filter is a so-called "half-band" FIR, where FIR stands for "Finite Impulse Response" which describes a filter that uses a finite amount of the incoming data. To illustrate, start with an incoming data stream $y[n]$ where $n$ changes at some rate, and you want to up convert by x2. So conceptually, now you have an array $z[m]$ where $m$ changes at twice the rate of $n$, and every other value for $m$ is the zero you've added. You form the FIR which is a finite array $h[k]$ of size $L$. Now you apply the FIR to all values of $z[m]$ to make $z_{out}[m]$: $$z_{out}[m] = \sum_{i=-T}^{T} z[m-i]h[i]\label{FIR}$$ Each element of the filter is called a "tap", which runs from $-T$ to $T$. The $k=0$ element is called the "center tap", and you set that equal to 1, and every other tap that is an even number away from the center tap is set to 0. This way, when the filter lands on a non-zero data element, it just gets copied over as the filter output. When it lands on a zero element, it then uses the non zero data on either side of that zero to form the filter output. So a typical half-band FIR with 5 non-zero coefficient pairs (10), 5 zero pairs (10) plus the center tap, for a total of 21 taps, and might look like the following:

   h[-10] = +0.000000   (zero -- half-band)
   h[ -9] = +0.003783
   h[ -8] = -0.000000   (zero -- half-band)
   h[ -7] = -0.019580
   h[ -6] = +0.000000   (zero -- half-band)
   h[ -5] = +0.061492
   h[ -4] = -0.000000   (zero -- half-band)
   h[ -3] = -0.165014
   h[ -2] = +0.000000   (zero -- half-band)
   h[ -1] = +0.619393
   h[ +0] = +1.000000   <-- center tap
   h[ +1] = +0.619393
   h[ +2] = +0.000000   (zero -- half-band)
   h[ +3] = -0.165014
   h[ +4] = -0.000000   (zero -- half-band)
   h[ +5] = +0.061492
   h[ +6] = +0.000000   (zero -- half-band)
   h[ +7] = -0.019580
   h[ +8] = -0.000000   (zero -- half-band)
   h[ +9] = +0.003783
   h[+10] = +0.000000   (zero -- half-band)
Note that this filter constitutes a delay since it has to buffer 5 data points as they pass through. Also, this is a symmetric filter, because an asymmetric filter will mean that different frequencies will be subject to some phase delay (and therefore distortion) through the filter. Also, being symmetric, this reduces hardware costs, because instead of having to do two multiplications and add: $$h[-9]\cdot x[n-9] + h[+9]\cdot x[n+9]\nonumber$$ it can do $$h[9]\cdot (x[n-9]+x[n+9])\nonumber$$ which is a big advantage since multiplication is quite a bit more costly than addition.

There are also 3x and 5x interpolation stages we will meet shortly that don't have this pass-through property, and there, every output sample is a filtered combination. Also note that this means that there will be some delay (latency) inside the DUC due to the fact that it has to wait to accumulate the data points that come after each sample to use in the filter.

One more subtlety: stuffing zeros between the samples cuts the amplitude of the original signal in half, because the signal energy is now shared with the image. The interpolation filter therefore includes a compensating gain of 2 (a gain of $N_i$ in general for an interpolation factor $N_i$). That's why, after applying the filter, the data points in the figure below land back on the original 50MHz sine wave at full amplitude. The new data points fit pretty well on the original sine wave, as they should. This should not be a surprise: by applying what is effectively a low pass filter, if it filters out the higher frequencies then it also smooths out the data.

So the output of the DUC is a smoothed out stream of data that is at the higher rate: $f_{DAC} = N_i\cdot f_{in}$ where $f_{in}$ is the sample rate of the data driven into the DUC, $f_{DAC}$ is our DAC conversion rate, and $N_i$ is our interpolation factor. One caution here: $f_{in}$ is the input sample rate, which is not necessarily the same as the FPGA fabric clock frequency. The AXI4-Stream interface into the RF Data Converter normally carries more than one sample per fabric clock cycle (this is the "samples per AXI4-Stream cycle" setting in the Vivado IP configuration), so the fabric clock is $f_{DAC}/(N_i \times W)$ where $W$ is the number of samples per cycle. This is exactly what makes the high converter rates usable: with, say, 8x interpolation and 8 samples per cycle, a 6.4 GSps DAC only needs a 100 MHz fabric clock.

Note that the way this is implemented in the ZU48DR is pretty clever. Imagine that you want to use a large interpolation, like 40x. That would mean a very large FIR that will have quite a bit more than the 5 data samples on either side of the non-zero one, and the more samples you have, the more calculations you have to do, and that means more time and more power. So instead, they construct filters that only need to do 2x, 3x, 4x, and 5x. Then to get to 40x, they can apply these filters in series, each time increasing the interpolation by that factor. So 40x would be from applying 2x, 4x, and 5x in succession. The ZU48DR can apply up to 3 FIR filters in the last part of the DUC stage, with the first FIR (FIR1) able to do 2x, 3x, or 4x interpolation, FIR2 can do 2x, 3x, or 5x, and FIR3 can do 2x.

Next in the DAC stage as seen in RF Converter figure, the output of the DUC is fed into another processing stage. Only now the output is upconverted, so it is at the DAC converting rate and it is a real signal with I and Q properly mixed. In the "Pre-processing" stage shown in that figure, there are 2 things that happen:

The IMR is there to allow you to shift to the 2nd Nyquist band if you want to, and it does this by applying another 2x interpolation followed by either a low pass filter passing the 1st Nyquist band or a high pass filter passing the 2nd Nyquist band. One thing to be aware of: the IMR is not always in the signal path. Whether it is active depends on the "Datapath Mode" selected for the DAC in the Vivado IP configuration (or via the rfdc driver) — in the default full-DUC datapath mode the IMR is bypassed, and the IMR datapath modes come with a restriction on the usable input signal bandwidth. See the DAC datapath modes section of PG269 for the details of which combinations are allowed.

Note this means that for the DAC path, if you dial in an interpolation of for example 20x, and the IMR is in the path, then the full interpolation will be 40x, and Vivado will know this and use it to calculate clock frequencies. (More on that below.)

The inverse sinc filter is there because of the way the DAC works: for each data point that arrives in the DAC, the output has to hold that data point for the duration of the time sample. That means a rectangle in voltage vs time, and a rectangle in the time domain becomes a sinc wave in the frequency domain. So the analog frequency response is no longer flat, and this is what the inverse filter does: compensates so that the output doesn't droop in the 1st Nyquist zone. (The gen3 DACs also provide a second version of the inverse sinc filter that flattens the response in the 2nd Nyquist zone, for use with Mix-Mode described below.)

Finally, the data is fed to the DAC to produce an analog waveform. There are 2 analog adjustments on chip that can be made:

What Mix-Mode ON allows you to do is to focus power into the 2nd Nyquist band. And it modifies the inverse sinc filter accordingly. This setting can be changed inside Vivado (under "Analog Settings" in the DAC configuration set "Nyquist Zone" to "Zone 2" to enable Mix-Mode) but it can also be changed in PYNQ. Here's an example:

from pynq import Overlay
import xrfdc

ol = Overlay("base.bit")

# Grab the specific DAC Tile and Block.
# Note: dac_tiles is indexed by absolute tile position, regardless of
#       which tiles are enabled in your design:
#       dac_tiles[0] = tile 228, dac_tiles[1] = tile 229,
#       dac_tiles[2] = tile 230, dac_tiles[3] = tile 231
# On the 4x2 that means DAC_B (tile 228) is dac_tiles[0] and
# DAC_A (tile 230) is dac_tiles[2].
dac_tile = ol.usp_rf_data_converter_0.dac_tiles[0]   # DAC_B
dac_block = dac_tile.blocks[0]

# Switch to Mix-Mode (Nyquist Zone 2)
dac_block.NyquistZone = 2

# To maximize power flatness in Zone 2, turn on the inverse sinc filter
# 0 = Disabled, 1 = Zone 1 enabled, 2 = Mix-mode (Zone 2) enabled
# (Check help(dac_block) to confirm the property name your xrfdc
# version exposes for the inverse sinc setting.)
dac_block.InvSincFIR = 2

The VOP adjustment allows you to change the output power by changing the DAC full-scale output current; the default of 20mA gives about 1 dBm into a 50Ω SMA connector. On the gen3 parts the VOP feature can in principle adjust the current over a 2.25 to 40.5mA range, which at the top end would give you 6.5-7.0dBm. But the full range is only available if the board supplies the DAC_AVTT power rail at 3.0V, and this is a board design choice, not a chip setting. The RealDigital 4x2 supplies DAC_AVTT at 2.5V (you can verify this in the board schematics), so the full 40.5mA is not available; for reference, in the legacy gen1/gen2-compatible output modes, 2.5V on DAC_AVTT corresponds to 20mA operation and 3.0V to 32mA (about 4.5-5dBm). Check the VOP section of PG269 and the RF-DAC tables in DS926 for what is achievable at 2.5V before counting on more than the default output power. A separate consideration is coupling: the 4x2's DAC outputs go through baluns (AC coupled), which is the configuration VOP was originally specified for; support for VOP with DC coupled outputs (over a reduced range) was added in later versions of the rfdc driver. The ZCU208 board routes the raw DAC outputs to a connector and requires a breakout card (the XM655) to get to SMA connectors.

The VOP setting can be changed either inside Vivado, or in PYNQ, using

from pynq import Overlay
import xrfdc

ol = Overlay("base.bit")

# Grab the specific DAC Tile and Block (see the tile indexing note above:
# dac_tiles[0] = tile 228 = DAC_B on the 4x2)
dac_tile = ol.usp_rf_data_converter_0.dac_tiles[0]
dac_block = dac_tile.blocks[0]

# View current structural parameters for the DAC
print(dac_block.OutputCurr)

# Adjust the full-scale output current to attenuate/amplify the signal
# analog-side (value in uA, within the range your board's DAC_AVTT allows)
dac_block.OutputCurr = 32000
#
# Note: it's probably best to check via help(dac_block) to make sure that the
#       right value has been entered, and to verify that the value "32" should
#       be a raw integer and not a floating point number

Back to top

ADC Path

Analog For the ADC path, an analog waveform at the SMA connector goes through the transformer coupling components (AC coupled) to become a differential signal, headed to the analog-to-digital converter (ADC). This signal then enters the "Pre Processing" stage inside the Zynq chip, as seen in the RF Converter figure. The ADC on the gen3 ZU48DR expects a 1 volt peak-to-peak (1Vpp) signal, and since the ADC is bipolar, that means the voltage swings over $\pm V_{max}=\pm 0.5$V. For a full-scale sine wave, the RMS voltage is $V_{rms}=V_{max}/\sqrt{2}=0.35$V, and the corresponding average power is $P=V_{rms}^2/R=1.25$mW, or $1$dBm. All of these are specs at the 100Ω differential input to the chip, where the currents are $I_{rms}=V_{rms}/100\Omega=3.5$mA and $I_{peak}=V_{peak}/100\Omega=5$mA.

At the SMA connector, which has a 50Ω impedance, the transformer ideally conserves power, so the same $1.25$mW gives $V_{rms}=\sqrt{PR}=\sqrt{1.25mW\cdot 50\Omega}=0.25$V, or $V_{peak}=V_{rms}\sqrt{2}=0.35$V, with currents $I_{rms}=V_{rms}/50\Omega=5$mA and $I_{peak}=V_{peak}/50\Omega=7mA$. This is just the balun's 1:2 impedance step-up at work, going from the $50\Omega$ SMA to the $100\Omega$ differential input, voltages scale by $\sqrt{2}$ and currents scale down by $\sqrt{2}$ keeping the power fixed. So at full scale on the ADC pins, given that the transformer coupling is not completely loseless, you need a bit more than $1.25$mW at the SMA, but I'm not really sure by how much!

For the RealDigital 4x2 board, there is no active termination, so no board level protection for over (or under) voltage external to the ZU48DR. But if you have a signal that has a known bigger voltage swing than what the ADC is expecting, you can attenuate it before it hits the ADC using the chip's "Digital Signal Attenuator", or DSA. It's called "digital" even though it works on an analog input, because you can configure it with discrete digitally-programmed steps of 1dB between 0 and 27dB. With the DSA set to 0dB, full scale is limited to the above 1Vpp. Drive past this and the samples clip, giving you harmonics and a raised noise floor. Way beyond that and you can damage the chip! In fact, what the ZU48DR does is to monitor the inputs to the ADC pins, and if the average power exceeds 14.6 dBm (29mW, or Vpp=4.8V), it immediately (or as fast as it can) slams in 15dB of attenuation in the DSA. if the threshold of 14.6 dBm is reached above the 15dB attenuation, the transistors will melt. That would correspond to a Vpp of around 27 volts at the input to the ADC, or around 19 volts at the SMA connector. That's pretty good overvoltage protection as you have to work pretty hard to send that much signal into the 4x2, and must really want to melt it!

For the ADC path, there is no filtering, and no positive gain, and any Nyquist zone selection and alias rejection are entirely the responsibility of who is using the board. The front end "Pre Processing" in the RF Converter figure consists of the on-die 100Ω differential termination, DSA attenuation (signal conditioning) and over-voltage detection (with flags set in the FPGA on detection) and protection, and an active differential input buffer to isolate the input pins from the usual switch-capacitor charge kickback of conversion.

ADC The analog-to-digital conversion (ADC) channel consists of 8 interleaved ADCs running at 1/8th of the total sampling rate, sampling the analog input sequentially, and staggered by 45° in phase. This allows faster overall sampling, but does introduce gain and time-skew systematics. To deal with these, the ZU48DR has what are called "Calibration Blocks":

Digital Down Converter (DDC)

Data is now coming out of the ADC at the sampled rate, and as discussed above, this rate is usually way more than the FPGA fabric can handle. And sometimes it's way more than the user needs. For our purposes, we will be running the ADC in complex mode, which means we want what's presented at the output to be the I and Q quadrature waveforms. So the first thing that the DDC does is to route the raw digital data, at the sample rate, into the digital mixer, which multiplies the signal with a complex carrier wave at a frequency $\omega_0$ generated by the Numerically Controlled Oscillator (NCO, see below). This starts with the output of the ADC, $x[n]$, and produces $I[n]$ and $Q[n]$ using: $$\begin{align} I[n] &= x[n]\cos\omega_0 nT_s\nonumber\\ Q[n] &= -x[n]\sin\omega_0 nT_s\nonumber \end{align}\nonumber$$ where $n$ is the nth data, and $T_s=1/f_s$, the sampling period of the ADC.

Decimation and Filtering

The next job of the DDC is to bring the data rate down, and this can be done easily by throwing away every other data point. Or perhaps doing something more intelligent, like averaging every 2 higher rate points to make a lower rate point if the decimation is x2. But there's a problem doing this: imagine that you are sending data at some carrier frequency into the board, and you pick your sampling frequency so as to meet the Nyquist condition. But that there is some noise in the system in the 2nd (or higher) Nyquist zones. For illustration, say you have a signal that consists of a 150MHz sine wave, and you pick a 2GSps sampling time and a decimation factor of x2. If you average, or throw away every other data point, that should give you data that is presented at the output of the RF Converter ADC at 1GSps, your signal is still in the 1st Nyquist zone, so if you Fourier that waveform, you should see the 150MHz peak. But imagine that you also have some noise that comes from a sine wave at 650MHz with say $30\%$ of the signal amplitude. Your data looks like this, both waveform and Fourier spectrum:

When you decimate, the new sampling frequency $f_{s_{new}}=\half f_s=1000$MHz, so the new Nyquist frequency is half that, or $500$MHz. Now your 650MHz noise is in the new 2nd Nyquist zone, so it is aliased and folds back into the first Nyquist zone at $f_{s_{new}}-f_{noise}=1000-650=350$MHz.

That signal at 350MHz is the aliased noise, which is now in your 1st Nyquist zone.

So to get rid of this, you apply another FIR that effectively decimates and low pass filters to filter out any energy in the 2nd Nyquist zone of the decimated signal. This FIR is exactly the same form as for the DUC DAC path, with the same number of taps, and that makes sense because both need low pass filters.

For both the DUC (DAC path) and DDC (ADC path), you can dial in various interplations/decimations in Vivado. These can be 1x, 2x, 3x, 4x, 5x, 6x, 8x, 10x, 12x, 16x, 20x, 24x, and 40x. The way they do this is to have 2x, 3x, and 5x FIR filters that you can add in 4 stages. Below is a diagram of what filters are in what stages:

The 2x filter contains 59 taps (29 symmetric pairs plus 1 center tap, or 2x29+1=59), the 3x filter contains 89 taps (44 + 1), and the 5x filter contains 143 taps (71 + 1), and the reason the 3x contains more than the 2x can be more easily understood by thinking about the DUC case, where you have to fill in 2 zeros between data for the 3x interpolation as opposed to 1 zero for the 2x. So there's more smoothing to do. Same for 5x, it has to fill in 4 zeros per data, so more coefficients to get a better fit.

This means that for the DUC case (the DAC), the rate is lower at the beginning of the filter chain, so the filter runs at a slower clock speed, so the first stage contains either 2x, 3x, or 5x: data runs left to right, from FPGA to conversion, in the diagram above. All of the next stages are 2x. For the DDC case (the ADC), the stages are hooked up the same way, only the data runs right to left in the diagram, and the hardest work is done at the stage nearest to the FPGA fabric, which runs slower than the conversion side.

One thing to keep in mind: because of the IMR (see above), there's an extra 2x interpolation in the DAC path, which means that you can interpolate up to 80x, but in the ADC path, you are maxed out at the 40x in the FIR filter path as shown in the diagram above.

Below are plots of the 3 filters: 2x, 3x, and 5x just to see how these filters behave. Multiplications inside the DUC and DDC are done digitally, not in floating point, but these are shown as floating point numbers. The 5x filter shows coefficients greater than 1 because the filter also has to do some gain compensation along the way. The main point is to see how these behave.

Back to top

Numerically Controlled Oscillator

Each DUC and each DDC in the ZU48DR contains a complex mixer driven by its own "Numerically Controlled Oscillator", or NCO, which allows you to choose a programmable digital frequency $f_0$ with 48-bit resolution, with no analog synthesizer required. There is also a "coarse" mixer option that mixes at exactly $f_0=f_s/2$ or $f_0=\pm f_s/4$. At those special frequencies the local oscillator samples are just $0$ and $\pm 1$, so the mixer "multiplication" reduces to sign flips and I/Q swaps, costing essentially no power and introducing zero spurs. To see this explicitly, note that quadrature functions are sequences of numbers, so $I(t)\to I[nT_s]$ and $Q(t)\to Q[nT_s]$ where the time $t\to nT_s$ and $T_s = 1/f_s$. If we start with any signal $s(t)$, then to form $I$ we multiply the signal by: $$\begin{align} I[t] &= s(t)\cos(\omega_0 t)\nonumber\\ &= s(nT_s)\cos(2\pi f_0 n/f_s)\nonumber\\ &= s(nT_s)\cos(2\pi \frac{f_s}{2} n/f_s)\nonumber\\ &= s(nT_s)\cos(n\pi)\nonumber\\ &= (-1)^ns(nT_s)\nonumber \end{align}\nonumber$$ So all the mixer has to do is to flip the sign of every other $s(t)$. It's also easy to show that $Q[nT_n]=0$ since $Q$ is formed by multiplying by $\sin(\omega_0 t)$.

If you choose $f_0=f_s/4$ we get $I[nT_n]=s(t)\cos(n\pi/2)$ which means multiplying $s(t)$ by successive $+1, 0, -1, 0$ and repeat. And the same for $Q[nT_n]$. So there really is no need for any multiplication when using the coarse mixer for any of these 3 mixing frequencies.

But the coarse mixer only gives you those three fixed frequencies. For anything else you use the fine mixer and its NCO, described here. The primary reference is PG269, Chapter 4, "RF-DAC Mixer with NCO" and "RF-ADC Mixer with NCO".

The NCO is worth understanding: how does it produce a sine and cosine with arbitrary frequencies without tuning an analog oscillator? The way it works is conceptually simple (with added complications): imagine a lookup table (LUT) that has $2^{48}$ addresses, and that lookup table traces out 1 period of a sine wave between $0$ and $2\pi$. We start with an input $i[t]$ and $q[t]$, and want to form $I[t]=i[t]\cos\omega_0 t$ and $Q[t]=q[t]\sin\omega_0 t$. Now let's say you want to produce an NCO frequency that is a quarter of the sampling frequency: $f_0=f_{NCO}=f_s/4$. If you use the LUT values to sweep out a sine with that value for $f_{NCO}$, you would want to pull out 4 values of the LUT for each cycle of the NCO. So you define a "Frequency Control Word", or FCW, and set $FCW=2^{48}/4=2^{46}$, and define a 48-bit "phase accumulator", ($PA$), that starts at 0, and at each tick of the sampling clock, you do 2 things:

  1. use the value of $PA$ to point to the LUT and return the sine and cosine (they are just offset by 1/4 of the period of the LUT)
  2. increment the $PA$ by the FCW value
Then form $I[t] - Q[t]$ and send to the DAC.

The NCO frequency $f_{NCO}$ is therefore set by the $FCW$: $$f_{NCO}=\frac{FCW}{2^{48}}\times f_s\nonumber$$ If on the other hand let's say you would pick a sampling frequency of $9.85 GHz$ (the DAC maximum) and you would like a carrier wave with $f_0=f_{NCO}=150$MHz. Then solving for $FCW$ gives $$FCW = \frac{f_{NCO}}{f_s}\times 2^{48} = 4286420965136 = 0x3e602995710\nonumber$$ Any truncation to integer would be off by an exceedingly small fraction of the sampling frequency, or the frequency resolution, given by $$\Delta f=\frac{F_s}{2^{48}}\nonumber$$ which at the ZU48DR's 5GSps ADC rate is about $17.8\mu Hz$ and $35\mu Hz$ at the DAC 9.85GSps rate. You can specify the NCO value in a Python notebook in the PYNQ environment, and it will actually allow you to type in a value beyond $\pm F_s/2$ but then will just fold it to the equivalent alias inside the first Nyquist zone.

This is basically how it works, except that in actuality it employs a few tricks. For instance, we want the $FCW$ to be 48 bits in order to be able to specify a very precise value for the NCO frequency. The actual LUT for the sine wave can be 18 bits wide with plenty of precision to minimize spurious frequencies (aka spurs). This makes sense as $2^{48}$ different phases between $0$ and $2\pi$ would be the definition of overkill! Also, the sine wave is very symmetric over the 4 quarters of a cycle, so we only really need a LUT that has $2^{16}=65536$ entries that span $0$ to $\pi/2$.

Another nice trick: split the 16-bit quarter-wave phase as $\phi = a + b$ with $a$ the top 10 bits, $b$ the bottom 6. Then $$\sin(a+b)=\sin(a)\cos(b)+\cos(a)\sin(b)\nonumber$$ and since $b=(\pi/2)/2^{10}$ is tiny, expand $\cos(b)\sim 1$ and $\sin(b)\sim b$ to get $\sin(a+b)\sim\sin(a)+b\cos(a)$. So we only need a 10-bit (1024 wide) LUT for $\sin(a)$, and one small multiply and one add. Storage drops from $\sim 65k$ to $\sim 1k$. They can also use some second-order Taylor expansion tricks to get the LUT to be even smaller. And as you can imagine, there's an entire industry of tricks to use, but that's the general idea.

Also note that the $FCW$ is relative to the sampling frequency, which you can set in Vivado but can also be set in software. So if you set it at one value in the Vivado, then change it in Python and ask for a particular $NCO$ frequency, it could easily use the value from Vivado (which it gets from the hwh file). So you should always check it in Python.

You can also specify the initial phase setting of $PA$ with an 18-bit signed number spanning $\pm 180^\circ$ giving a phase-offset granularity of $360^\circ/2^{18}=1.37m^\circ$.

This phase resolution is small, but any subsequent phase truncation introduces a periodic error in the time domain, which manifests as a spur in the RF frequency spectrum of the DAC (and ADC), which would add a very small triangle wave to the waveform. Using the lowest Fourier component of a triangle wave you can show that the amplitude of any spurious frequency from phase quantization goes like $6.02\times P$ dBc (dB relative to the carrier power), and for $P=18$ as it is here, that's a spur that is down by 108 dB relative to the carrier. Which is quite small. Also, spurs from the RF Converter's physical imperfections have been measured to be in the $70-80$ dBc range, so spurs from the phase quantization is $25-35$ dB below that. This is why 18 bits seems like a sensible choice for the phase resolution of the NCO.

The 48 bits buy you extraordinarily fine granularity, but the absolute accuracy and stability of the output frequency are exactly those of the sampling clock, since $f_{\rm NCO} = ({\rm FCW}/2^{48}) f_s$ inherits every ppm of error and all the phase noise of $f_s$. On the 4x2 that means the Si5395 → LMK04828 → LMX2594 chain, and ultimately the 48 MHz crystal ($\pm 25$ ppm or so) unless you feed the board an external 10 MHz reference, as discussed in the Clocking section of the main tutorial. If you care about absolute frequency calibration, the external reference is what matters; the NCO adds essentially nothing to the error budget on top of it.

The NCO in the DAC path. In the DUC, the mixer sits after the interpolation filters, running at the full DAC rate. For the usual case of an I/Q datapath driving a single real DAC output, the fine mixer computes $$y[n] = I[n]\cos(\omega_0 n T_s) - Q[n]\sin(\omega_0 n T_s) = {\rm Re}\left\{\big(I[n] + jQ[n]\big)e^{+j\omega_0 n T_s}\right\}$$ which is single-sideband upconversion: your baseband I/Q waveform ends up centered at the NCO frequency $\omega_0/2\pi$. The multiply-and-sum happens inside the mixer block itself. After the mixer, the (now real) sample stream passes through the optional QMC block (only relevant if you drive an external analog quadrature modulator from a DAC pair; irrelevant for direct-RF real output), the optional IMR image-rejection filter (a final $\times 2$ half-band interpolation, selectable low-pass or high-pass), the optional inverse sinc filter (compensates the DAC's zero-order-hold $\sin(x)/x$ droop, with coefficient sets for first or second Nyquist zone), and then the 14-bit DAC core.

The NCO in the ADC path. The DDC is the mirror image: the mixer sits immediately after the converter core, running at the full ADC rate, and before the decimation filters. The real sample stream $x[n]$ from the ADC fans out to two multipliers: $$I[n] = x[n]\cos(\omega_0 n T_s), \qquad Q[n] = -x[n]\sin(\omega_0 n T_s)$$ which together form $x[n]\,e^{-j\omega_0 n T_s}$, the complex downconversion. Park the NCO on your band of interest, and the decimation filters then narrow the bandwidth and drop the sample rate on both rails before the data crosses to the PL as an AXI4-Stream. One practical note: if your input signal lives in the second Nyquist zone (direct RF sampling above $f_s/2$, which is standard practice), the sampling process spectrally inverts it. Setting the Nyquist zone in the driver (NyquistZone = 2) makes the mixer arithmetic account for the flip so your requested center frequency and sideband orientation come out right; without it your spectrum appears mirrored.

Setting the NCO from python. From PYNQ, the NCO lives in the MixerSettings dictionary of a converter block. For example, to tune the mixer of ADC tile 0, block 0 to 1500 MHz:

import xrfdc

adc = base.usp_rf_data_converter_0.adc_tiles[0].blocks[0]

adc.MixerSettings['Freq']        = 1500.0    # MHz; sign selects rotation direction
adc.MixerSettings['PhaseOffset'] = 0.0       # degrees, -180 to +180
adc.MixerSettings['EventSource'] = xrfdc.EVNT_SRC_TILE
adc.UpdateEvent(xrfdc.EVENT_MIXER)           # latch the new settings

The frequency and phase registers are double-buffered: writing MixerSettings stages the new values, and they take effect on an "update event" whose source you choose (per-slice, per-tile, SYSREF, etc.). This is what lets you retune several NCOs and have them all switch on the same clock edge. Note that with this ordinary update procedure the phase restarts; the frequency change is not phase-continuous. Gen 3 parts (the ZU48DR is Gen 3) also support a fast frequency-hopping mode with phase-coherent switching, where multiple phase accumulators run in the background and a multiplexer selects among them; see "NCO Frequency Hopping" in PG269 if you need that.

Warning: when you set Freq in MHz, the driver converts to an FCW using the sample rate it believes the tile is running at, and that belief comes from the hwh file, i.e. from the sample rate you typed into the RF Data Converter customization dialog in Vivado. Nothing ever measures the actual clock. The LMX2594 synthesizers that generate the sampling clocks are programmed independently (by xrfclk), and if they are set to a different rate than the Vivado configuration declared, every frequency you program will be silently scaled by the ratio of actual to assumed $f_s$, and every readback will confidently report the wrong-but-self-consistent number. The Vivado IP setting, the LMX programming, and your python assumptions are three copies of the same number that nothing reconciles automatically. Keep them in sync by discipline.

Quick reference for the NCO parameters (all from PG269 chapter 4 unless noted):

ParameterValueNotes
Frequency word48-bit signedspans $-f_s/2$ to $+f_s/2$; driver folds requests beyond that to the equivalent alias
Frequency resolution$f_s/2^{48}$$\approx 18\ \mu$Hz at 5 GSPS; $\approx 35\ \mu$Hz at 9.85 GSPS
Phase offset18-bit signed$\pm 180^\circ$, granularity $360^\circ/2^{18} \approx 1.4$ millidegrees
Phase reset1 bitaligns NCO phases across converters; used with MTS
Update latchevent sourceslice, tile, SYSREF, marker, or PL signal; double-buffered registers
Coarse mixer alternative$f_s/2$, $\pm f_s/4$trivial LO values ($0, \pm 1$): no multipliers, no spurs, low power
Frequency hoppingGen 3phase-coherent, phase-continuous, or phase-reset switching; see PG269 "NCO Frequency Hopping"

Back to top

Create a new Project

Create a new Project

Now we are ready to create the FPGA project. Run Vivado, create a new project, and call it "Loopback_v1" since this will be version 1. In "Project Type" make sure "RTL Project" is selected, hit Next, and skip "Add Sources" and "Add Constraints (optional)" by hitting Next twice. That brings you to the "Default Part" window. Assuming you already ran the previous GPIO project, you have the right board files loaded, so click on "Boards" (next to "Parts"), type "4x2" in the Search window, click near the blue title to select the 4x2 board entry, and hit Next, then Finish.

You will now see the usual Vivado windows. On the left, click "Create Block Design", type "Loopback" in the "Design name" window, and hit OK. This brings up an empty "Diagram" window.

Back to top

Add ZYNQ

Click the thick "+" in the "Diagram" window, type "ZYNQ" in the Search window, and choose "Zynq UltraScale+ MPSoC" (the ARM processing system, the PS). Do not choose the one with "RF Data Converter" in the title yet, that is a separate IP we will add in a moment. Double click the "MPSoC" entry to drop it in, then click the blue "Run Block Automation" link, leave everything selected, and hit OK. Rename the block to "ZYNQ" in the "Block Properties" window.

While you have the ZYNQ block open, we need to make sure a PL clock is available to clock the memory-mapped and control sides of our logic. Double click the ZYNQ block, go to the "Clock Configuration" tab, expand "Output Clocks" → "Low Power Clock Domains" → "PL Fabric Clocks", and make sure PL0 is enabled (100 MHz is fine for now). This "PL0" clock will enable an output port called pl_clk0 on the ZYNQ block, and this port will clock the DMA's AXI4 and AXI4-Lite ports. The fast sample-rate clocks for the converters come from a completely different place (the on-board clock chips), which we will get to.

You should see "Run Block Automation" at the top of the "Diagram" window. Click that now. This adds important board preset capability, which configures the DDR4 controller, PS clocking, and wires up the clocks and resets. When you click it, it opens a new window called "Run Block Automation". Make sure "Apply Board Preset" is checked, and everything on the left is checked ("All Automation" etc) and hit OK. You should see this in the Diagram window:

Configuration summary

Back to top

Add Constraints

Add a constraints file exactly as in the GPIO and DMA tutorials so the bitstream is compressed and the over-temperature protection is on. In the "Sources" window click the "+" symbol, select "Add or create constraints", hit Next, click "Create File", call it "Loopback", and click OK, then Finish. In the sources window, you should see the Constraints change to "Constraints(1)", which means the new file is there. Expand and you should see "Loopback.xdc". Double click to open and an editable window should appear to the right, where you add:

## compress the bitstream to make it smaller
set_property BITSTREAM.GENERAL.COMPRESS TRUE [current_design]
# enable the over-temperature shutdown features
set_property BITSTREAM.CONFIG.OVERTEMPSHUTDOWN ENABLE [current_design]
#
# set LE0D to show the RX_FIFO reset condition
set_property -dict { PACKAGE_PIN AR11 IOSTANDARD LVCMOS18 } [get_ports { LED0 }]
# set LED3 for the heartbeat
set_property -dict { PACKAGE_PIN AU10 IOSTANDARD LVCMOS18 } [get_ports { LED3 }]

The first 2 constraints compress the bit file and enable over-temperature shutdown features, and the next 2 set up so that we use the leds on the top left of the board to show a few interesting things (talked about below).

Save the file (control-s).

Back to top

Add the AXI DMA

We want to drive the DAC output with a cyclic DMA, but we want the ADC to be a one time write into DDR using the RX_FIFO reset to trigger. Cycle vs regular is best done with 2 different DMA engines, so we will need to add both here:

Add two "AXI Direct Memory Access" blocks exactly as in the DMA tutorial. One will be for the DDR4 to DAC path, and one will be for the ADC to DDR4 path, and they will have to be configured differently. To do this, go back to the Diagram window, click the plus sign to add a block, search for "AXI DMA", select "AXI Direct Memory Access", and double click. A new block appears, the title underneath whold be "AXI Direct Memory Access". Then add another (copy/paste should work but if not, do it by hand). Name one "AXI_DMA_SG" and the other "AXI_DMA_NOSG" just so we can differentiate in Python.

AXI_DMA_SG takes data from DDR4 and sends it towards the TX_FIFO, so we want the MM2S channel enabled, and disable the S2MM channel. The AXI_DMA_NOSG is the opposite - we are streaming into DDR4 so we want the S2MM channel enabled and MM2S disabled. Double click on the AXI_DMA_SG, and you will see both "Enable Read Channel" and "Enable Write Channel" enabled. In the 2024.2 version of Vivado, read and write is with respect to DMA, so MM2S is a read and S2MM is a write. Disabling the "Enable Write Channel" on AXI_DMA_SG disables S2MM, which is what you want, uncheck and click OK. Then double click on AXI_DMA_NOSG and disable the read channel and click OK.

Next we have to configure these 2 DMA enginesTo figure the "AXI_DMA_SG", double click to configure it:

Click OK at the bottom to accept those settings.

For the AXI_DMA_NOSG engine, double click, unselect "Enable Scatter Gather Engine" and set the "Width of Buffer Length Register" to 26, and disable "Enable Read Channel" since here we will be writing from the ADC to the DDR4, which is considered a "Write Channel". Click OK to save.

Then click on the "Run Connection Automation" in blue at the top of the Diagram page. The default should be that all is selected on the left, but if not, select "All Automation". You should see that both the AXI_DMA_SG and AXI_DMA_NOSG are listed, and under each is "S_AXI_LITE". This is to hook up the AXI-Lite path, which is for controls. Hit OK. If after that you still see a "Run Connection Automation" at the top, do it again, connection automation is sometimes iterative, and this time it will probably want to hook up the AXI-Lite ports on the DME engines to the ZYNC (PS) chip. Click OK. Then click "Regenerate Layout", the button with the clockwise circle at the top of the Diagram page. The Diagram window should show this:

Configuration summary

Probably a good time to save the project.

Back to top

Heartbeat

A "heartbeat" is just some way for you to be sure that the FPGA is alive and reasonably well. One classic way to do this is to just flash one of the LEDs on the board at a reasonable rate, like 1 Hz, so if it's flashing (the heart is beating) you know it's alive.

We will be setting the FPGA system clock to 250MHz below. To use this clock for a heartbeat, we will have to slow it down to about 1 Hz. The easiest way to think of how to do this this is do make a counter that is so large that the MSB toggles at around 1 Hz. If the system clock is 250MHz, then that clock changes state every 4ns, which means we need a counter that can count up to around $250\times 10^6$, which means the counter has to be arond 28 bits. So first let's make a verilog file that will do that:

module heartbeat (
    input  aclk,
    input  aresetn,   // active low
    
    output  heartbeat
);

reg [27:0] counter;
assign heartbeat = counter[27];

always @ (posedge aclk) 
    if (aresetn) counter <= counter + 1;
    else counter <= 28'b0;

endmodule

Create this file somewhere on the PC you are using to build the project and call it "heartbeat.v". In Vivado, click the "+" sign to add sources, select "Add or create design sources", click "Add Files" in the next window, navigate to this new file and select it, and then make sure "Copy sources into project" is checked. Then hit Finish. You should see a new file appear under "Design Sources" in the Sources tab.

Now go to the Diagram window, right click, and select "Add Module". You should see "heartbeat.v" in the list of sources, select it and hit OK. This creates a new block called "heartbeat_0". Rename it "HEARTBEAT", and connect "pl_clk0" on ZYNQ to "aclk" on HEARTBEAT, and connect "aresetn" on HEARTBEAT to "peripheral_aresetn" on the Processor System Reset block. Then right click on the Diagram window again and select "Create Port", give it the name "LED3", set Direction to Output and Type to Data and hit OK, and connect the "heartbeat" output of HEARTBEAT to this new port.

Configuration summary

Back to top

Add GPIO

We will need 3 GPIO blocks here:

As in the DMA tutorial, add the "AXI GPIO" blocks. Rename one "VERSION", the other "RX_FIFO_RESET", and the other "RX_FIFO_TLAST".

Double click VERSION and go to the "IP Configuration" tab, and enable "All Inputs". This makes sure that the inputs are not tristated, but if you don't do it it's ok because the default tristate is for input. Click OK. Open the VERSION port "GPIO" so it shows the 32-bit input gpio_io_i[31:0]. Add a "Constant" block, change the name to "FIRMWARE", double click and set "Const Width" to 32 and "Const Val" to 0xa5000001 (bump the low byte each time you change the firmware), and wire the constant to the VERSION GPIO input. This lets you confirm from Python exactly which bitstream is loaded.

Open the "RX_FIFO_RESET" by double clicking and click on the "IP Configuration" tab. You will see "GPIO" and "GPIO 2". For GPIO, click "All Outputs" so that it doesn't turn the output into a tristate, and set "GPIO WIdth" to 1. Notice that the "Default Output Value" will be 0x00000000, which is ok. Click OK. Then if you open the GPIO port on that block, you will see it's "gpio_io_o[0:0]" which means it's a 1 bit output. Note that since the default will be 0, and this will drive the FIFO reset, which is an active low signal, which means that when the project is loaded, the relevant FIFO will be in reset mode. In Python we will drive it to 0 for reset and 1 for not reset. Let's bring this signal out to an LED so that we can see it going on and off. To do that, right click and "Create Port". That brings up a "Create Port" window. For the "Port name:", type in the same name as in the last line in the constraint file: "LED0". Change "Direction" to Output and "Type" to Data and hit OK. Then tie the output of RX_FIFO_RESET to this port.

Next open "RX_FIFO_TLAST", go to the "IP Configuration" tab, select "All Outputs" under GPIO, and set the "GPIO Width" to 32. This is explained below when we discuss the TLAST Saga. Hit OK.

Hit "Run Connection Automation", and in the "Run Connection Automation" window, check "All Automation", but uncheck "GPIO" for RX_FIFO_TLAST, since will be routing this ourselves, and we don't want Vivado to route that and have to undo it later, but we do want Vivado to connect these GPIO blocks to the AXI-Lite bus so that the data gets there ok. After clicking OK you will still see "Run Connection Automation", but don't run it, it's trying to connect your GPIO output to something. We will do that ourselves.

Click "Regenerate Layout" (the button with the clockwise arrow in the Diagram window). You should see something like this:

If your diagram shows any output from the GPIO ports on RX_FIFO_TLAST and RX_FIFO_RESET, don't worry, just select the output line and any ports that those go to, right click to delete.

Don't forget to periodically save the project.

Configuration summary

Back to top

Add stream FIFOs

The DMA delivers data from DDR in bursts, but the DAC drains its input at a continuous line rate, so to keep from starving the DAC at burst boundaries we put in an elastic buffer. The same goes for the other direction, taking data out of the ADC and writing it into DDR, so we need a buffer there too, and we will use FIFOs. So click on the "+" to add blocks, and search for "AXI FIFO", and click on "AXI4-Stream Data FIFO". Careful, not "AXI-Stream FIFO", that's a different beast, "AXI4-Stream Data FIFO" is the lightweight streaming FIFO, same one we used in the DMA tutorial. Double click to add. Then copy and past to add another. Rename one "TX_FIFO" and the other "RX_FIFO".

The names follow the usual radio convention: TX (transmit) is the outbound path, where data flows from DDR out to the DAC, and RX (receive) is the inbound path, where digitized data flows from the ADC back into DDR. Lining that up with the DMA (whose "read" and "write" are both with respect to DDR): TX_FIFO sits in the MM2S path (DMA reads DDR, streams out), so its output feeds the DAC input of the RF converter. RX_FIFO sits in the S2MM path (DMA writes DDR), so its input is driven by the ADC output of the converter.

For each FIFO, double click and make the following changes in the "Re-customize IP" window:

Each FIFO has an S_AXIS port and an M_AXIS port. TX_FIFO is the transmitting data into the DAC, and that will be cyclic so we will use AXI_DMA_SG. So the TX_FIFO S_AXIS port connects to the M_AXIS_MM2S port on the AXI_DMA_SG block, and the TX_FIFO M_AXIS port will connect to the right port on the RF Converter (see below).

For the RX_FIFO, it is sending data from the ADC to the DDR, no cyclic, so it goes through AXI_DMA_NOSG, so you connect the M_AXIS port on RX_FIFO to the S_AXIS_S2MM port on AXI_DMA_NOSG, and you connect the S_AXIS port on RX_FIFO to the right port on the RF Converter to accept data from the ADC (see below).

So the next thing to do is to make these connections:

Now let's connect the RX_FIFO_RESET line to the RX_FIFO reset. We could just connect the line by hand, but there's another consideration: a FIFO is a very synchronous object (that is, it does things by clock) whereas GPIO is asynchronous (it does things whenever you tell it via GPIO, which is not necessarily synchronous with any clocks). So we should take care. Now, when we issue the reset, that will clear the FIFO and prevent any new data from entering. So we don't have to worry about anything being synchronous. But when we release the reset, the FIFO will start up, and we don't want that to happen near the clock edge or we could get race conditions, which different flip-flops might change differently, which generates unknown effects. So we are going to have to synchronize our GPIO with the RX_FIFO write clock (which we will get to later), because that's the clock that defines "t=0" in the FIFO.

The way to do this is to instantiate a new block called "Processor System Reset". (One of these is already in the project, wired in when you hit "Run Connection Automation" for the ZYNQ block but this one you will instantiate will be it's own and not used for anything else.) Click on "+" in the Diagram window and type "Processor System Reset", double click, and rename it to "RX_FIFO_RESET_SYNC".

Then connect the following signals from "RX_FIFO_RESET_SYNC" to "RX_FIFO":

What this block is built to do is precisely what we need: at the rising edge of "slowest_sync_clk", "peripheral_aresetn" follows "aux_reset_in". So it's basically just a flip-flop. Here's the timing diagram, below. Remember, the thing we want to be sure of is that when the GPIO transitions from 0 to 1, that's the release of the FIFO reset, and we want to be sure that that line is synchronous with the clock.

There's two more lines to connect on the "RX_FIFO_RESET_SYNC" block: the line "dcm_locked" has to be set to 1 and "mb_debug_sys_rst" set to 0. The former is the signal that says the input clock is good, but there's no way to generate that since that clock will come from the RF Converter, so we set it to 1. The latter is a reset that we won't be using, so set it to zero. To do this, you instantiate 2 "Constant" blocks, rename one of them "ZERO" and the other "ONE", set the width to 1 bit and "ZERO" to 0 and "ONE" to 1 and tie them in.

Go ahead and hit the "Run Connection Automation" link at the top. If you've followed without any mistakes or omissions, you should see the options are to connect RX_FIFO/m_axis_aclk, RX_FIFO_RESET_SYNC/slowest_sync_clk, RX_FIFO_TLAST/GPIO, and TX_FIFO/s_axis_aclk. All of these are going to be connected by hand, so you don't want Vivado to route them because then you just have to undo it. If you have any other options, then probably it's ok to enable them and let Vivado route it, so it OK. But if not, then hit Cancel.

Hit the "Regenerate Layout" button. You should see a block diagram that looks like this:

Configuration summary

Back to top

Add DDR4 Ports

Next we want to enable the ports on the PS (the ZYNQ block) so we can do DMA from the DDR4 to the AXI_DMA engine, and we should increase the FPGA fabric clock so that we are sure we are reading the RX_FIFO faster than we are writing it, just to leave us some comfort!.

Double click on the ZYNQ block (it's the one with "Zynq UltraScale+ MPSoC" caption), and that opens up the "Re-customize IP" window. We will follow what we did in more detail in the DMA tutorial. Click "PS-PL Configuration" on the left, then expand "PS-PL Interfaces" on the right, then expand "Slave Interfaces" and "AXI HP". Enable "AXI HP0 FPD" and "AXI HP1 FPD". If you expand each one of those, it should show that the data width is 128, change them both to 32. This is probably not necessary, as the hardware will convert, but let's make things easier for it.

Next, to change the clock speed, click on "Clock Configuration" on the left, select the "Output Clocks" tab, and expand "Low Power Domain Clocks" and then "PL Fabric Clocks". You should see "PL0" checked, so change "100" to "250".

Click OK, and again don't hit the "Run Connection Automation". We will be routing what's left by hand (below).

The block for ZYNQ should change to show 2 new ports: "S_AXI_HP0_FPD" and "S_AXI_HP1_FPD", both coming out of the top left corner of the ZYNQ block.

Don't click Run Connection Automation yet. And don't forget to save the project.

Configuration summary

Back to top

AXI Bus Connections

Now we want to connect the AXI bus (not the AXIS stream side!) lines between the ZYNQ ports we just made and the AXI_DMA ports. And there are actually 3 ports on the AXI_DMA block that we need to connect: M_AXI_SG (gets scatter-gather info), M_AXI_MM2S (grabs data from DDR and sends it to the TX_FIFO), and M_AXI_S2MM (takes data from RX_FIFO and sends it to DDR). So there are 3 AXI master ports on AXI_DMA and 2 AXI slave ports on the PS to access DDR. Since AXI is a complicated thing, we always want to route lines from master to slave on AXI through an AXI Smartconnect block. This block will handle all of the conversions necessary, and keeps things in order.

So back to the Diagram, click "+" and search for Smartconnect, and add 2 "AXI SmartConnect" blocks. Open up one of them and in the configuration, change "Number of Slave Interfaces" to 1 and hit OK, and rename that block "SMART_NOSG". Rename the other AXI Smartconnect to "SMART_SG", and make sure it has 2 slave ports.

The MM2S direction is where the AXI_DMA_SG engine pulls data from DDR4 and sends it into the RFDC DAC input, cycling. So that one will have the MM2S direction and the scatter-gather ports on AXI_DMA_SG. AXI_DMA_NOSG will connect to SMART_NOMSG. So make the connections that are outlined in the following table. And be careful to connect M_AXI_* and not M_AXIS_*, we are connecting on the AXI DDR side of the DMA engine!

SMART_SG
Slave on SMART_SGMaster on AXI_DMA_SGMaster on SMART_SGSlave on ZYNQ
S00_AXIM_AXI_MM2SM00_AXIS_AXI_HP0_FPD
S01_AXIM_AXI_SG
SMART_NOSG
Slave on SMART_NOSGMaster on AXI_DMA_NOSGMaster on SMART_NOSGSlave on ZYNQ
S00_AXIM_AXI_S2MMM00_AXIS_AXI_HP1_FPD

Then by hand connect "aresetn" on both of these SmartConnects to the "peripheral_aresetn" output on the rst_ZYNQ_99M Processor System Reset block.

Don't "Run Connection Automation", as we are not finished connecting things by hand!

Hit "Regenerate Layout". Your block diagram should now look something like this:

This would be a good time to save the project.

Configuration summary

Back to top

"TLAST" Saga

Before working on the RF Converter, there's one more issue. When we want to read data from the RX_FIFO into DDR4, we ask the DMA engine to to initiate the transfer, all through Python. So the code knows the size of the buffer, but this size is really a maximum as far as the DMA goes: don't send more than this into the buffer. But the DMA engine is not counting data that gets sent, it's only sending data to the DDR4 using the AXI protocols, which involve the following control signals:

Data only moves across the bus when both TVALID and TREADY are high at the same time. This is how they "handshake".

The problem is, who sends TLAST? For the transfer from DDR4 to the AXI DMA engine to the TX_FIFO to the DAC, the AXI DMA engine itself generates TLAST. But when data goes the other way, from the FIFO into the DMA engine, there's no way to know how much data to then send into the DDR4. So no one generates TLAST and the system hangs.

But TLAST is known by the system and actually is sent to the DMA engine on the AXI_LITE bus as a slow control. So we will have to make our own module that sits in series between the RX_FIFO M_AXIS port and the AXI_DMA S_AXIS_S2MM port that gets this information, and when the transfer starts, counts, and asserts TLAST when it's done.

Here's some verilog code that does the trick:

// -----------------------------------------------------------------------------
// axis_tlast_gen
//
// Inserts TLAST on an AXI4-Stream feeding an AXI DMA S_AXIS_S2MM port.
// Counts handshaken beats and asserts TLAST on the final word of each packet,
// so the DMA closes out the transfer and reports the actual length.
//
// The packet length (in words/beats) is supplied by software on `word_count`,
// e.g. via an AXI GPIO register written from PYNQ. It does NOT come from the
// DMA -- the DMA learns the length FROM the TLAST this module generates.
//
// Placement:   RX_FIFO (AXIS master)  -->  axis_tlast_gen  -->  S_AXIS_S2MM
//
// Notes:
//   * word_count is sampled-and-held at packet start, so a mid-packet register
//     write can't corrupt an in-flight transfer.
//   * word_count must be >= 1. A value of 0 would never assert TLAST.
//   * If your final beat is a partial word, drive TKEEP as well (not handled
//     here -- this assumes full-width beats).
// -----------------------------------------------------------------------------

module axis_tlast_gen #(
    parameter integer DATA_WIDTH  = 32,
    parameter integer COUNT_WIDTH = 32    // max words per packet = 2^COUNT_WIDTH - 1
)(
    input  wire                    aclk,
    input  wire                    aresetn,

    // Words per packet, from software (AXI GPIO / custom AXI-Lite register).
    input  wire [COUNT_WIDTH-1:0]  word_count,

    // Upstream: from RX_FIFO (AXIS master)
    input  wire [DATA_WIDTH-1:0]   s_axis_tdata,
    input  wire                    s_axis_tvalid,
    output wire                    s_axis_tready,

    // Downstream: to AXI DMA S_AXIS_S2MM (AXIS slave)
    output wire [DATA_WIDTH-1:0]   m_axis_tdata,
    output wire                    m_axis_tvalid,
    input  wire                    m_axis_tready,
    output wire                    m_axis_tlast
);

    reg [COUNT_WIDTH-1:0] beat_cnt;    // words accepted so far in this packet
    reg [COUNT_WIDTH-1:0] len_latch;   // length captured at packet start

    // A beat transfers only when both sides handshake.
    wire beat = m_axis_tvalid & m_axis_tready;

    // Use the live register on the first beat, the latched value thereafter.
    wire [COUNT_WIDTH-1:0] active_len = (beat_cnt == 0) ? word_count : len_latch;

    // True on the last word of the packet (guard against word_count == 0).
    wire is_last = (active_len != 0) && (beat_cnt == active_len - 1'b1);

    always @(posedge aclk) begin
        if (!aresetn) begin
            beat_cnt  <= {COUNT_WIDTH{1'b0}};
            len_latch <= {COUNT_WIDTH{1'b0}};
        end else if (beat) begin
            if (beat_cnt == 0)
                len_latch <= word_count;   // capture length for this packet
            if (is_last)
                beat_cnt <= {COUNT_WIDTH{1'b0}};   // packet done -> restart
            else
                beat_cnt <= beat_cnt + 1'b1;
        end
    end

    // Pure pass-through of data / valid / ready; we only synthesize TLAST.
    assign m_axis_tdata  = s_axis_tdata;
    assign m_axis_tvalid = s_axis_tvalid;
    assign s_axis_tready = m_axis_tready;
    assign m_axis_tlast  = is_last;

endmodule
Note that in the verilog file, there are 2 parameters:
    parameter integer DATA_WIDTH  = 32,
    parameter integer COUNT_WIDTH = 32    // max words per packet = 2^COUNT_WIDTH - 1

"DATA_WIDTH = 32" is the width of the AXIS bus and the AXI bus. This has to match! Also, "COUNT_WIDTH = 24" matches what we put into the DMA engine configuration when we set "Width of Buffer Length Register" to 26, the maximum. This matches, because if we send $2^{24}$ words each of which is 32 bits, or 4 bytes, that's $2^{26}$ bytes which is what "CNT_WIDTH" means. Note that this limit of $2^{24}$ words means that in Python, the largest size you can set will be $2^{24}-1$ due to how the system counts using.

Copy this file to the PC you are using to build the project. Then go into the "Sources" window in Vivado, click the "+" sign to Add Sources, and select "Add or create design sources" and hit Next. Then click "Add File" and it brings up a file browser, find the source file axis_tlast_gen.v and select it. Then in the window "Add Sources" check the box that says "Copy sources into project". Then hit Finish. This should add "axis_tlast_gen" at the same level as "Loopback" in the "Design Sources" line of the Sources window.

Now we want to turn that into a block module. So go back to the diagram canvas and right click and select "Add Module". It should bring up a window called "Add Module" and it should have "axis_tlast_gen (axis_tlast_gen.v)" selected. Hit OK, and it should instantiate a new module in the Diagram window that is unconnected to everything already there. Rename it "AXI_TLAST_GEN". It should look like this:

Note that due to the way the verilog code named the input and output ports, it allowed Vivado to recognize them as AXIS ports and grouped them accordingly, which is why you see "s_axis" and "m_axis" in the port listing on the module.

The first think to do is to delete the connection between RX_FIFO "M_AXIS" and AXI_DMA_NOSG "S_AXIS_S2MM", which is the output of the RX_FIFO into the AXI_DMA_NOSG engine. Select the line, right click, and delete. We want the output of RX_FIFO to go into the AXI_TLAST_GEN module so it can count the number of words sent, and we want all of that AXI stream to just pass through. So the RX_FIFO "M_AXIS" goes into AXI_TLAST_GEN "s_axis" port, and we want the "m_axis" output of AXI_TLAST_GEN to go into the AXI_DMA_NOSG "S_AXIS_S2MM" port. Connect them up.

Next open up the GPIO port on RX_FIFO_TLAST and connect "gpio_io_o[31:0]" to "word_count" on AXI_TLAST_GEN so it knows how many to count. And connect "aresetn" on AXI_TLAST_GEN to the "s_axi_resetn" port on any of the GPIO blocks. And lastly, connect "aclk" on AXI_TLAST_GEN to "m_axis_aclk" on the RX_FIFO block, because remember axis_tlast_gen is counting words that come out of the RX_FIFO and into the AXI_DMA block, so we want to count with the same clock. Note that neither of these drives that clock, that will be dealt with in the RF Converter Configuration section below.

Hit the "Regenerate Layout" button. But still, don't hit "Run Connection Automation", we have more to connect by hand!

Your diagram should look like this (although at this point it might be difficult to see details, the topology should be the same).

At this point, all we have left to do is instantiate the RF Converter, configure it, and make sure we know what clocks we want to employ and connect them up to the relevant FIFO ports that connect into the RF Converter. Now would be a good time to save the project!

Configuration summary

Save the project!

Back to top

RF Converter Configuration

Click the "+" button in the block design window to add another IP, search for ZYNQ and add "Zynq Ultrascale+ RF Data Converter". Change the name to "RFDC" in the "Block Properties" window.

Note that the "Run Block Automation" and "Run Connection Automation" links are still there, which is good. The "Run Block Automation" is for the RFDC that you just instantiated, it sets it with what works with the RealDigital 4x2 board. So this should be run first. But it's safer to first configure the RF Converter for the ADCs and DACs you want to use, before "Run Block Automation" just to be sure it doesn't configure something you have to change later.

Double click on the RFDC module you just instantiated. It will bring up a window that will look like this:

You will see that the "RF-ADC" tab is selected, and you see the 4 tiles 224, 225, 226, and 227 are tabbed below. Let's use "ADC_A" on our 4x2 board, and that input is connected to "ADC 1" of tile 226, so unselect "Enable ADC" under "ADC Tile 224", then click on "ADC Tile 226" and click "Enable ADC" under "ADC 1".

Next click on "RF-DAC". Let's enable the "DAC 0" in tile 228, and that one comes out the "DAC_B" SMA connector on the 4x2. So click on "tile 226" and enable "DAC 0", and make sure "DAC 1" on that tile and no other DACs are enabled (they shouldn't be). Then hit "OK". This will change the RFDC block, which should now look like this:

You will see these inputs and outputs:

On these inputs and outputs, the "s_" and "m_" prefixes refer to "slave" and "master", and this is from the point of view of the RFDC block relative to the FPGA fabric. So "m_" means that the RFDC drives it, meaning an ADC conversion of the analog input that delivers digital data from the ADC out of the RFDC into the FPGA. "s_" means that the FPGA sends data into the RFDC so that it can convert it at the DAC to an analog output.

System Clocking

Double click RFDC again and select the "System Clocking" tab. This is probably the most complex part of the project, so let's go slow.

First, at the top you should see "AXI4-Lite Interface Configuration" and under that, "AXI4-Lite Clock (MHz)" set to 100. Change that to 250, since we are going to be running the FPGA fabric with that clock. Hit OK, and connect "s_axi_aclk" on the RFDC to any "aclk", which should come out of the "pl_clk0" port on the ZYNQ block.

Next double click RFDC again and go back to the "System Clocking" tab. We are using the ADC from Tile 226 and the DAC from Tile 228, so check "PLL" in the table for both of those rows. Notice that the "Sampling Rate (GSPS)" column shows 2.0 for Tile 226 and 6.4 for Tile 228 (or maybe other random numbers), but we will change those. When you select "PLL" for each Tiles 226 and 228, it calculates a reference clock such that the sampling rate will be a multiple of it, and you will see maybe 250 for row "ADC 226" and 400 for "DAC 228". What we have to do first is set the sampling rates, and then everything flows from those numbers.

Note that as discussed in the clocking section of the RFSoC introduction, on the 4x2 board the boot-time clock tree has the LMK chip sending 122.88MHz to the PL (FPGA) and 245.76 MHz to the two LMX chips. These clocks are not just random, they have to do with telecom standards. Each LMX chip multiples the 245.76 MHz clock by x2 so the RFDC internal PLL actually sees 491.52 MHz as its reference clock. So we should make use of this and set up the sampling to be some integer multiple of 491.52 MHz. On the 4x2 board, the DAC's can run at around 7 GSps (it can actually go up to 9.85 GSps but that's another story) and the ADC's at around 5. So for the DAC, if we use x14 to multiply 491.52, that gets us to 6881.28, which is under the 7 GSps limit and we should be ok. So in the row "DAC 228" set the "Sampling Rate" to 6.88128. When you set it and click anywhere else on that window, you will see the Reference Clock change. Click the down arrow next to the number there and scroll down to 491.52 and select that. You will also see that in the "PLL Summary Settings" table at the bottom, the "Vco" frequency of "DAC 228" changed to some large number. This is a read only table and just for information.

Now let's set the ADC sampling clock. We want a number under 5 GSps, so if we use 10x the 491.52 Reference Clock we will be ok, so put 4.9152 in the "Sampling Rate" column on the row labeled "ADC 226", and change the "Reference Clock" value to 491.52 as well in that row.

ADC Setup

Go back to the "Basic" tab at the top, and click on "RF-ADC" and then "ADC Tile 226". You should see "ADC 1" enabled. You should also see "Dither" enabled. As explained above, amplitude dithering injects a small controlled amount of pseudorandom noise into the analog input signal prior to digitizing, so that small repeatable spurious frequencies that might be in the incoming analog signal get smeared out, scattering their energy across the entire Nyquist band. This is useful if you have a very quiet system with a very small signal that might not be seen if it's less than 1 LSB, so dithering can make an ADC more linear for such signals. In ADMX, the thermal and quantum noise is greater than the signal from an axion, and so the noise dithers for you. All that is to say, disable "Dither".

Below "Dither" you will see "Data Settings". In the "Data Settings" subwindow change the "Digital Output Data" to "I/Q". In the "Mixer Settings" subwindow set "Mixer Type" to "Fine", and "Mixer Mode" to "Real->I/Q" since we want the ADC to take a real analog input and mix it to give us the I and Q signals.

We are sampling at 4915.2 MHz, and we want to send data to the RX_FIFO at a rate that is less than the RX_FIFO read clock, which is 250 MHz. The ratio of those two rates is around 20, but we want to write to the FIFO appreciably less than the read clock so that there's no change the FIFO can be full, because if it is, then the ADC data won't be written into the FIFO, and there will be a phase discontinuity in the waveform resulting in spurs. So if we choose a decimation of 40, which is the max, then the RX_FIFO write clock will be 4915.2/40=122.88MHz. So set the "Decimation Mode" to 40x. Then change the "Samples per AXI4-Stream Cycle" (let's call it SPC) to 1 (since for the ADC output, if you use the mixer and output I and Q they go out on different master ports). That should show "Required AXI4-Stream clock 122.880 MHz" just below the SPC window. So what we want to do is to generate a 122.88 MHz clock that will go into the RX_FIFO clock port, and this clock is actually something that we can get from the RFDC block.

What the ZU48DR gen 3 chip (which is what the 4x2 uses) does for the ADC is to send the I and Q out separate ports. So when you set the SPC to 1, it will change the ports to be 16 bits each. You will see this on the left where you see the decal, it should show "m22_axis" and "m23_axis", click the large "+" sign to open them up and you will see "m22_axis_tdata[15:0]", a 16 bit data word. Same for "m23_axis". Here's how you know which is I and which is Q: the port name is "m<tile><stream>_axis". The <tile> digit is the index for the tile, so tile 224 has tile index 0, 225 has 1, 226 has 2, and 227 has 3. The <stream> index is even for I and odd for Q. So of the two ports "m_22_axis" will have the I stream and m_23_axis will have the Q stream, and since we have SPC set to 1, they will be 16 bits.

Now click back on the "System Clocking" tab. You will see that the "ADC 226" row has a "Fabric Clock" of 122.88 MHz, which is what we want, but you also see that the "Clock Out" is set to 76.8 MHz. That's because the "Clock Out" is an actual clock that is generated by dividing the sampling clock by some integer that is a power of 2, which 122.88 MHz is not (it's 4915.2/40=122.88). We will need to take "Clock Out" and use a PLL in the FPGA to generate the actual FIFO write clock. Because this clock can only be 4915.2 divided by some power of 2, it sets "Clock Out" to 4915.2/64=76.8 MHz. This is fine, we can use this clock output and make a 122.88 MHz clock later. If it's not 76.8, click the down arrow next to that number and select 76.8, which is closer to the 122.88 clock that we will need to generate. Now we are done setting up the ADC. Note however that we have 2 ADC output ports, 16 bits each. We will have to combine them before sending to the RX_FIFO, which we will do below.

DAC Setup

Go back to the "Basic" tab, click on the "RF-DAC" tab, and "DAC Tile 228". You should see "DAC 0" already enabled. Underneath that, enable "Inverse Sinc Filter" (see discussion on the DAC Path above). Make sure "Analog Output Data" is set to "Real".

Now we have to do the same calculation that we did for the ADC. We start with a DAC sampling of 6881.28 MHz, and an input from the TX_FIFO output that will be less than the 250 MHz FIFO input rate. 6881.28/250=27.5 and we want the FIFO read clock to be less than that, so if we use 40, we would get 6881.28/40=172.032 MHz. Now as discussed above, the DAC path actually has an internal x2 interpolator that can be implemented, but this is only useful if your carrier wave is known to be in the bottom quarter of the sampling rate (or bottom half of the 1st Nyquist zone). For a sampling of 6881.28, the Nyquist frequency is 3440.14 MSps, so let's not make this restriction, so that we can output waveforms higher than half that, or 1770.07 MHz. So set "Datapath Mode" to "DUC 0 to Fs/2", which disables the x2 and IMR filter. set "Interpolation Mode" to 40, and SPC to 2. What the ZU48DR gen 3 chip does for the input to the DAC is to expect it to be packed into a single AXIS stream, 16 bits at a time, with I being the lower 16 bits and Q the upper. So you should see the s00_axis port on the left side of the decal, open it and it should should "s00_axis_tdata[32:0]" indicating the 32 bits. This is in line with the 32 bit AXI and AXIS bus width we've specified above.

Clock settings summary

Below is a table with a summary of all the numbers. "Dec/Int" means "Decimation/Interpolation" and "SPC" means "Samples per AXI4-Stream Cycle".

ComponentSamplingRefClkFabric Clock OutDec/IntSPC
ADC4.9152491.52122.88 76.8401
DAC6.88128491.52172.032 107.52402

We are done configuring the RFDC. Click ok.

Configuration summary

RX_FIFO output concatenation

So the RFDC will send us 16 bits of I on m_22 and 16 bits of Q on m_23, and we want to read those into the AXIS stream as a 32 bit number with I in the low bits and Q the upper ones. A small verilog file to do this makes it easy. Here's the code:

module axis_iq_packer (
    input  wire        aclk,
    input  wire        aresetn,
    
    // Slave 0: I stream (16-bit)
    input  wire [15:0] s_axis_i_tdata,
    input  wire        s_axis_i_tvalid,
    output wire        s_axis_i_tready,
    
    // Slave 1: Q stream (16-bit)
    input  wire [15:0] s_axis_q_tdata,
    input  wire        s_axis_q_tvalid,
    output wire        s_axis_q_tready,
    
    // Master: Packed IQ stream (32-bit)
    output wire [31:0] m_axis_tdata,
    output wire        m_axis_tvalid,
    input  wire        m_axis_tready
);

    // Join I and Q into a single 32-bit word [Q | I]
    assign m_axis_tdata  = {s_axis_q_tdata, s_axis_i_tdata};
    
    // Synchronize handshakes (both must be valid)
    assign m_axis_tvalid   = s_axis_i_tvalid && s_axis_q_tvalid;
    assign s_axis_i_tready = m_axis_tready && s_axis_q_tvalid;
    assign s_axis_q_tready = m_axis_tready && s_axis_i_tvalid;
endmodule

As you can see in the above code, we define the inputs so that Vivado will recognize them as AXIS, and just concatenate every input word coming in on the s_axis_i_tdata and s_axi_q_tdate port to go out the m_axis_tdata port packed with I in the lower 16 bits and Q in the upper.

Copy this to some area on the PC you use to build the project, go into the "Sources" window, click the "+" sign, click "Add or create design sources" in the first window, hit Next, then "Add Files", browse to the file and select it, make sure "Copy sources into project" is selected, and hit Finish. That will set up another source files in the "Sources" window under "Design Sources". Then right click on the Diagram window, select "Add Module", and select the name of the source you just added. Hit OK and it places a block on the diagram. Rename it to "AXIS_IQ_PACKER". It looks like this:

Route "m_22_axis" and "m_23_axis" from RFDC into "s_axis_i" and "s_axis_q" of the packer respectively (that will put m_22, which is I on the lower 16 bits and m_23, which is Q, on the upper). Then route the output of packer "m_axis" into the "S_AXIS" port on RX_FIFO, which sends data into the FIFO. Then connect "aclk" and "aresetn" on the packer to "s_axis_aclk" and "s_axis_aresetn" on RX_FIFO.

FIFO clocks

Now we want to set up the FIFO clocks that clock data between the FIFOs and the RFDC, aka "Fabric Clock" in the table above and in the RFDC configuration. (Note that they call this "Fabric Clock" because anything outside the RFDC is part of the FPGA fabric.) For TX_FIFO, this will be 172.032 MHz and will be on the master side that writes from the TX_FIFO and into the RFDC. For RX_FIFO this will be 122.88 MHz and will be on the slave side that writes data from the RFDC into the RX_FIFO.

As in the above table, the RFDC will generate a 107.52 MHz "Clock Out" on the DAC side, which comes out on the "clk_dac0" pin, and 76.8 MHz on the ADC side, which comes out on the "clk_adc2" pin. These will be phase locked clocks and we can use them to generate the necessary "Fabric Clocks" for the RFDC side of the 2 FIFOs. So click the "+" sign to add blocks, search for "clocking", and double click on "Clocking Wizard". Make another (copy/paste or just add another) and rename one of them "RX_FIFO_CLK" and the other "TX_FIFO_CLK".

Double click TX_FIFO_CLK, click on the "Clocking Options" tab, change "Input Frequency" to MANUAL and change the clock to 107.52. Then click on the "Output Clocks" tab, and change the "Output Freq" to 172.032, and deselect "reset" and "locked" at the bottom as we have no need for those signals. Hit OK.

Then connect "clk_dac0" from RFDC to "clk_in1" on TX_FIFO_CLK, and connect "M_AXIS" on the TX_FIFO into the RFDC "s00_axis" port, for the FIFO data input.

The output clock "clk_out1" of TX_FIFO_CLK has to go to several places:

Double click on RX_FIFO_CLK, and click on the "Clocking Options" tab. At the bottom, in the row that has "Primary", in the "Input Frequency" column, change the setting to "Manual" and type in 76.8 MHz. Then click on the "Output Clocks" tab and change the "Output Freq" column in the "clk_out1" row (which should be enabled) to 122.88. At the bottom of that window, deselect "reset" and "locked" and hit OK. The RX_FIFO_CLK decal should change to just having a "clk_in1" and "clk_out1" port.

Then connect "clk_adc2" from RFDC to "clk_in1" of RX_FIFO_CLK. The output clock "clk_out1" of RX_FIFO_CLK has to go to several places:

The following table summarizes the clocks and connections.

RX_FIFO_CLKTX_FIFO_CLK
clk_in176.8clk_adc2 on RFDC 107.52clk_dac0 on RFDC
clk_out1122.88 s_axis_aclk on RX_FIFO
slowest_sync_clk on RX_FIFO_RESET_SYNC
aclk on axis_iq_packer_0
m2_axis_aclk on RFDC
172.032 m_axis_aclk on TX_FIFO
s0_axis_aclk on RFDC

One more thing to configure. The RFDC receives data on the "s00_axis" port from the TX_FIFO. And we've connected the "s0_axis_aclk" on RFDC to the "m_axis_aclk" on TX_FIFO, but we still have to connect the reset line, "s0_axis_aresetn" on RFDC to something. In principle we can connect it to any resetn line and just have the reset be asynchronous, but that's a risk, and it has to do with when the reset is deasserted (going from reset to not reset). If that reset is not synchronous with the 172.032 "s0_axis_aclk" line on the RFDC, then there could be race conditions that can have unknown effects in the RFDC internals. So best to synchronize that reset with that clock. To do that, we add another reset block like before: click "+" and search for "reset" and add "Processor System Reset" and make the following connections:

The reset can be unconnected.

Configuration summary

Now we can click on "Run Block Automation". It will bring up a window, and if everything has been done right, the only thing it will need to do is to set up the reference clock inputs and the analog input and output for the RFDC. So you should see "RFDC" on the left, it should be enabled but if not, enable it. Block automation should take care of all required clocking connections depending on the configuration and options selected below, which tells you what this block automation does. There should be 2 options:

ADC 2 AXI-Streaming Clock Source
DAC 0 AXI-Streaming Clock Source
both set to "Custom", which we will leave alone. What is happening here is that the RFDC needs something to clock the AXI4-Stream (fabric facing) side of each tile, and the RFDC can generate it's own clock from clock sources, or you can give it the clock from somewhere else. As described in the main
RFSoC tutorial on clocking, on the 4x2 board this will come from the LMX2594 chip. So we want to use "Custom" here. And this is what we want since we will be connecting the RFDC inputs and outputs to FIFOs.

Hit "OK" to finish this. You will see a bunch of clocks that are now connected to the RFDC block. These are what the 4x2 will need. Then hit "Regenerate Layout". When I did mine, it spread things quite a bit making it hard to see.

Next we want to click on "Run Connection Automation", but we will have to be careful here since we are going to connect AXI4 streams to the RFDC through FIFOs. When you click on it, it brings up a window "Run Connection Automation" that has a checklist of interfaces it can connect. Most of these should just be clocks that we haven't connected yet. The two most important are the RX_FIFO AXIS side, which is a master, so you should see "m_axis_aclk", and the TX_FIFO AXIS side, which is a slave, so you should see "s_axis_aclk". You should not see "s_axis_aclk" on RX_FIFO, as that should already be connected to the synthesized clock that should come out of RX_FIFO_CLK. And you should not see "m_axis_aclk" on TX_FIFO as that should already be connected to synthesized clock that should come out of TX_FIFO_CLK. All of the others that need connection should be on the AXIS side of the 2 FIFOs that are connected to the DMA engines (AXI_TLAST_GEN/aclk, the "m_axi*" clocks for the 2 DMA engines, and some clocks on ZYNQ). If all seems well, select "All Automation", and click OK.

For some reason, there are also reset lines on the 2 SmartConnects SMART_SG and SMART_NOSG that are left unconnected. This might be ok, but best to connect them to any of the GPIO areset lines.

Your project should now have everything connected. It's pretty big and spread out so I moved things around by hand to make it more compact and easy to see in this tutorial:

Save the project.

Then press F6, which does a validation. Mine came back to tell me that there are 9 unassigned address segments. You can see what they are by clicking "Cancel", then clicking on the "Address Editor" tab in the Diagram window, and deselect "Assigned" and "Excluded" at the top leaving only "Unassigned (9)". Open those up and you will see that they are all ports on the AXI_DMA that are connected to ports on the ZYNQ (the PS) with labels like /ZYNQ/SAXIGP3 etc. These are interface mappings from DMA into the PS memory space, and that's perfectly ok to let Vivado take care of those. So hit F6 again, and this time say "Yes" to the auto-assign question. If all goes well you should see "Validation successful". If not, then something went wrong and you will have to get an expert or start over (which is why it's good to do this tutorial carefully!)

The last thing we have to do before building into a downloadable project is to create the top level wrapper, just like in the other tutorials. Go to the "Sources" tab (upper window to the left of the diagram), open "Design Sources", and right click on "Loopback (Loopback.bd)" and select "Create HDL Wrapper". Choose "Let Vivado manage wrapper and auto-update" and hit OK. You should then see "Loopback_Wrapper (Loopback_wrapper.v) (1)" in the sources window under "Design Sources".

Build the project

Before you build the project, it's a good idea to go back into the RFDC and 2 clock generators RX_FIFO_CLK and TX_FIFO_CLK and check that all of the numbers agree with what's in the table in the clock summary section above. A typo somewhere can be very difficult to debug later!

Assuming all goes well, generate the bitstream and hwh file by clicking "Generate Bitstream" in the Project Manager window on the left.

Back to top

Python

Next we delve into how to control all of this using Python in a Jupyter notebook. The first thing to make sure you do is to copy the .bit and .hwh file to the ARM chip where your notebook lives, and you do that by clicking the upload button (shown inside the red square below).

You can find these files from the archive section above. Note that PYNQ requires them to have the same name, and they don't come out of Vivado that way. For this project, since we named it "Loopback_v1", and it exists in a directory called "Loopback_v1/", the .bit file can be found in the "Loopback_v1/Loopback_v1.runs/impl_1" directory, and it will have a .bit filetype and will be the only .bit file there. The .hwh file can be found in "Loopback_v1/Loopback_v1.gen/sources_1/bd/Loopback/hw_handoff" and should be the only file there.

Now for the Python. The code below is explained cell by cell. The entire Jupyter notebook can be found here, and a Python script made from this notebook can be found here

Parameters

In the first cell, we set up parameters. version_name, rfdc_name, rxfifo_reset_name, and tlast_gen_name allow you to communicate with some of the AXI blocks, which you need in order to get the firmware version, program the RFDC, etc.

Then come parameters that have to do with PYNQ's way of allowing you to use the radio class methods, by knowing what "tiles" and "blocks" you have instantiated in the firmware project. The RF sampling and interpolation/decimation rates are next, the ADC and DAC carrier frequencies adc_carrier and dac_carrier, and a variable ps_clock declared followed by the file name of the firmware.

Next is a logical dither. This controls whether we will use amplitude dithering on the incoming analog signal that we will digitize. As you can see, it's set to False just as a default, because the dither capability was disabled for the particular ADC we are using. But it can be controlled via software. It should probably be set to True for any data, especially ADMX data where the noise floor is way above the power of the quantization noise for a 14 bit ADC, and dithering the input signal costs very little in the way of power. This is illustrated below.

Finally, the firmware bit and hwh files that we will load followed by a list of AXI object names that are used by pl_quiesce to tidy up (see above).

#
# parameters for the FPGA project Loopback, version 0xa5000001
#
# AXI GPIO:  (do gen.ip_dict to verify)
version_name = "VERSION"     # for the firmware version
rfdc_name = "RFDC"           # the RFDC data converter
rxfifo_reset_name = "RX_FIFO_RESET"   # to reset the RX_FIFO to keep it empty until we want to start reading
tlast_gen_name = "RX_FIFO_TLAST"      # to set the number of transfers to generate TLAST
#
# adc tiles are number 0-3 for 224-227 and block is 0/1 for ADC 0/ADC 1.  we are using 226, adc 1
#
adc_tile = 226
adc_tile_n = adc_tile - 224
adc_block_n = 1
#
# dac tiles are number 0-3 for 228-231 and same for block.  we are using 228, dac 0
#
dac_tile = 228
dac_tile_n = dac_tile - 228
dac_block_n = 0
#
# frequencies in MHz, decimation and interpolation values
#
rfdc_dac_sampling = 6881.28
rfdc_adc_sampling = 4915.2
rfdc_dac_interpolation = 40
rfdc_adc_decimation = 40
#
# clock frequency for the PS in MHz
#
ps_clock = 0
#
# carrier frequency in MHz
#
adc_carrier = 1000
dac_carrier = 1000
#
# dither should be on when looking at DAC output data.  For ADMX, the amplifier
# noise is a lot higher than the ADC noise due to quantization, so we can keep
# dither on with a very small noise penalty.  Set it to False below to reflect
# the project settings and controlled in software below, but probably should be 
# turned on in the project
#
dither = False
#
# firmware next:
#
firmware = "Loopback_v1.bit"
#
# DMA instance names to quiesce before download (see gen.ip_dict to verify)
#
dma_names = ["AXI_DMA_SG", "AXI_DMA_NOSG",   # Loopback
             "axi_dma_1", "axi_dma_2"]       # cyclic_clock

Class and function definitions

In the next cell we set up the overlay classes so we can use PYNQ,

"""
Import python libraries, and input pl_quiesce.py.  This is a python script Claude wrote to make sure that the PS and PL are in sync when redownloading.  

When PYNQ downloads a bitstream, any AXI master still actively transacting on an HP port belongs to the *previous* design.  Reconfiguration erases the logic but not the outstanding transaction, which can leave the new design's first memory-side access wedged.  Nothing in the new design's RTL can prevent this -- the offending logic no longer exists by the time the new design does -- so the fix has to happen on the PS side, before download.

What it does is:

1. Ask the PL server for ip_dict — the metadata for the design loaded right now, i.e. the previous one, since you haven't downloaded yet. If unavailable, print and return.
2. Filter it to IPs that are named in the parameter DMA_NAMES and have axi_dma in their type field.
3. Print matched names. If nothing matched, return — that's the normal path on the first download after boot.

Then for each matched DMA, map its registers from phys_addr, and run these steps on MM2S, then again on S2MM:

4. Read DMACR and check bit 0, Run/Stop. Record it as was_running.
5. If it's set, clear it. Per PG021 the engine finishes its current transaction and then asserts Halted — this is the graceful stop, and it's what lets an in-flight memory transaction complete rather than being abandoned mid-burst.
6. Poll DMASR bit 0 for up to 500 ms waiting for Halted. Record whether it got there.
7. Set DMACR bit 2, the soft reset, whether or not step 6 succeeded. It's the harder stop if the graceful one timed out, and free if it didn't.
8. Poll for that bit to self-clear, up to 500 ms. Record it.
9. Print the per-channel line and append the result dict.

Every step that can fail is caught and recorded rather than raised, so a partial teardown still lets the download proceed — with a visible INCOMPLETE in the log.

This was developed for the Loopback projects, which uses names "AXI_DMA_SG" and "AXI_DMA_NOSG" for the 2 DMA engines.  if you want to add to the list edit DMA_NAMES below
-----

Here is the mapping from board label to tile/block in the radio function:

Board label Tile Driver path ADC_A 226 adc_tiles[2].blocks[1] ADC_B 226 adc_tiles[2].blocks[0] ADC_C 224 adc_tiles[0].blocks[1] ADC_D 224 adc_tiles[0].blocks[0] DAC_A 230 dac_tiles[2].blocks[0] DAC_B 228 dac_tiles[0].blocks[0]

For this project we are using ADC on tile 226, adc 1, so that's ADC_A, and DAC on tile 228, dac 0, so that's DAC_B
"""


from pynq import Overlay, MMIO, allocate, Clocks
from pynq.lib import AxiGPIO
from pynq.overlays.base import BaseOverlay
from rfsoc4x2 import oled
from xrfdc import RFdc
import math
import time
import xrfclk
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import scipy.signal as sig
import scipy.fft as sfft
from scipy.signal import find_peaks
from pl_quiesce import quiesce_pl
from hwh_check import check_hwh

class Gen(BaseOverlay):
    def __init__(self, quiesce=True):
        # 
        # Run pl_quiesce BEFORE Overlay.__init__() reconfigures the PL: the master we
        # need to stop belongs to the design loaded right now, not the new one.
        #
        if quiesce:
            quiesce_pl(dma_names)
        #
        # load the new firmware
        #
        Overlay.__init__(self,firmware)
        #
        # Check to see if we forgot to connect anything obvious in the project!
        #
        check_hwh(self.bitfile_name)
        #
        # Dig out the firmware version 
        #
        version_ip = self.ip_dict[version_name]
        version_ptr = AxiGPIO(version_ip).channel1
        self.version = version_ptr.read()
        #
        # Do some housekeeping
        #
        if self.is_loaded():
            #
            # self.radio is used to point to the radio class
            #
            self.radio = getattr(self, rfdc_name)   # i.e., self.RFDC
            #
            # initialize the LMK and LMX chips
            #
            self.init_rf_clks()
            #
            # set default carrier frequencies (not really needed...)
            #
            self.freq_adc = 700.0      # MHz
            self.freq_dac = 700.0      # MHz
                
    def get_version(self):
        return self.version
    
    def set_external_rf_clock(self):
            print("Switching to external 10MHz clock...")
            for lmk in xrfclk.lmk_devices:
                with open(lmk['spi_device'], 'rb+', buffering=0) as f:
                    data = b'\x01\x47\x0A' # Only works for LMK0482x series
                    f.write(data)

    def set_internal_rf_clock(self):
        print("Switching to internal 10MHz clock...")
        for lmk in xrfclk.lmk_devices:
            with open(lmk['spi_device'], 'rb+', buffering=0) as f:
                data = b'\x01\x47\x1A' # Only works for LMK0482x series
                f.write(data)                
            
    def dac_status(self, settle=0.02, verbose=False):
        #
        # so we can tell the AI if something goes wrong what happened!
        #
        rm   = self.AXI_DMA_SG.register_map
        tile = self.RFDC.dac_tiles[dac_tile_n]
        blk  = tile.blocks[dac_block_n]

        cr, sr = rm.MM2S_DMACR, rm.MM2S_DMASR

        # poll until the descriptor pointer moves, or until 'settle' elapses.
        # (only 2 descriptors in the ring, so a fixed 2-sample gap aliases badly)
        d0 = int(rm.MM2S_CURDESC.Current_Descriptor_Pointer)
        d1 = d0
        advancing = False
        t0 = time.time()
        while time.time() - t0 < settle:
            d1 = int(rm.MM2S_CURDESC.Current_Descriptor_Pointer)
            if d1 != d0:
                advancing = True
                break
            time.sleep(0.0005)

        ts = self.RFDC.IPStatus['DACTileStatus'][dac_tile_n]
        bs = blk.BlockStatus

        r = {
            'rs':        int(cr.RS),
            'cyclic':    int(cr.Cyclic_BD_Enable),
            'halted':    int(sr.Halted),
            'idle':      int(sr.Idle),
            'errors':    int(sr.DMAIntErr or sr.DMASlvErr or sr.DMADecErr or
                             sr.SGIntErr  or sr.SGSlvErr  or sr.SGDecErr),
            'curdesc0':  hex(d0),
            'curdesc1':  hex(d1),
            'advancing': advancing,
            'tile_state':   ts['TileState'],
            'tile_power':   ts['PowerUpState'],
            'tile_pll':     ts['PLLState'],
            'dp_clocks':    bs['DataPathClocksStatus'],
            'fifo_flags':   bs['IsFIFOFlagsAsserted'],   # latches; informational only
            'fifo_enabled': tile.FIFOStatus,             # informational until baselined
            'nco_mhz':      blk.MixerSettings['Freq'],
        }
        r['ok'] = (r['rs'] and r['cyclic'] and not r['halted'] and not r['idle']
                   and not r['errors'] and r['advancing']
                   and r['tile_state'] == 15 and r['tile_pll'] == 1
                   and r['dp_clocks'] == 1)
        if verbose:
            for k, v in r.items():
                print(f"   {k:12s} {v}")
        return r

    def dac_hard_stop(self, timeout=0.05, reset_timeout=2.0):
        #
        # Stop MM2S with a timeout. pynq's stop() spins forever on 'Halted',
        # which never arrives while the stream side is backpressured.
        #
        rm = self.AXI_DMA_SG.register_map
        ch = self.AXI_DMA_SG.sendchannel
        rm.MM2S_DMACR.RS = 0
        t0 = time.time()
        while not rm.MM2S_DMASR.Halted:
            if time.time() - t0 > timeout:
                print("MM2S would not halt; forcing soft reset. DMASR:", rm.MM2S_DMASR)
                rm.MM2S_DMACR.Reset = 1
                t1 = time.time()
                while rm.MM2S_DMACR.Reset:
                    if time.time() - t1 > reset_timeout:
                        raise RuntimeError("MM2S soft reset did not complete")
                    time.sleep(0.001)
                print(f"soft reset completed in {time.time() - t1:.3f} s")
                break
            time.sleep(0.001)
        # keep pynq's channel bookkeeping consistent, so transfer() works next
        ch._cyclic = False
        ch._transfer_started = False
        return bool(rm.MM2S_DMASR.Halted)
    

    @property 
    def freq_dac(self): # MHz
        return self.radio.dac_tiles[dac_tile_n].blocks[dac_block_n].MixerSettings['Freq']
    
    @freq_dac.setter
    def freq_dac(self,freq): # MHz
        blk = self.radio.dac_tiles[dac_tile_n].blocks[dac_block_n]
        # get the setting and create the dictionary, then change the values and write it back, 1 write
        s = blk.MixerSettings      # single read
        s['EventSource'] = 0       # same dict object → goes out with the Freq write
        s['Freq'] = freq
        blk.UpdateEvent(1)


    @property 
    def freq_adc(self): # MHz
        return self.radio.adc_tiles[adc_tile_n].blocks[adc_block_n].MixerSettings['Freq']
    
    
    @freq_adc.setter
    def freq_adc(self, freq):  # MHz
        blk = self.radio.adc_tiles[adc_tile_n].blocks[adc_block_n]
        # get the setting and create the dictionary, then change the values and write it back, 1 write
        s = blk.MixerSettings      # single read
        s['EventSource'] = 2       # ADC needs 2, not 0!  same dict object → goes out with the Freq write
        s['Freq'] = freq
        blk.UpdateEvent(1)

def fft_process(data, do_window=False, floor_db=-300):
    """
    Computes the peak-normalized power spectrum (in dB) of a complex waveform.
    
    Parameters:
        data (array-like): Input complex waveform.
        do_window (bool): Applies a Hanning window if True.
        floor_db (float): Minimum dB floor to avoid log(0) warnings.
        
    Returns:
        np.ndarray: Normalized power spectrum with peak at 0 dB.
    """
    data = np.asarray(data)
    
    # 1. Apply Hanning window if requested
    if do_window:
        data = data * np.hanning(len(data))
        
    # 2. Compute FFT and magnitude
    fft_mag = np.abs(np.fft.fft(data))
    
    # 3. Shift zero-frequency component to center
    fft_shifted = np.fft.fftshift(fft_mag)
    
    # 4. Normalize peak to 1.0 (safely handling all-zero arrays)
    max_val = fft_shifted.max()
    if max_val > 0:
        fft_norm = fft_shifted / max_val
    else:
        fft_norm = fft_shifted
        
    # 5. Convert minimum floor from dB to linear scale (e.g., -300 dB -> 1e-15)
    floor_linear = 10 ** (floor_db / 20.0)
    
    # 6. Clip values below floor to keep 1.0 at exactly 0.0 dB
    return 20 * np.log10(np.maximum(fft_norm, floor_linear))

def analyze_fft_noise(fft_db, a_peak, fs_adc, decim, v_fs=32767, enbw=1.5,
                      excess_db=12.0, grow_bins=8, dc_width_bins=15,
                      edge_bins=None, n_iter=3):
    """
    Noise floor, NSD and ENOB from a peak-normalised power spectrum.

    ARGUMENTS
    ---------
    fft_db : ndarray
        Peak-normalised power spectrum in dB, fftshifted so DC is at the
        centre -- i.e. exactly what fft_process() returns.  The carrier's
        tallest bin sits at 0 dB by construction.

    a_peak : float
        Carrier amplitude in ADC counts, measured from the TIME-DOMAIN
        waveform, e.g. (max - min)/2 over the I or Q array.  Do NOT apply a
        window correction to this: a_peak is measured before any window is
        applied, so the window has not touched it.
        This is the ONLY link between the spectrum's arbitrary units and the
        converter's physical scale.  The FFT cannot supply it -- full scale
        never appears in the sampled data -- which is why NSD needs it.

    fs_adc : float
        Raw converter sample rate in Hz, BEFORE decimation.  4.9152e9 for
        your setup.  Note Hz, not MHz.  Used for the bin width and for the
        intrinsic-ENOB Nyquist bandwidth (fs_adc/2, since the ADC digitises
        a real input).

    decim : int
        DDC decimation factor (40).  fs_adc/decim gives the complex I/Q
        output rate, which is also the full bandwidth of the decimated data
        -- not half of it, because the output is complex.

    v_fs : float, default 32767
        Full-scale AMPLITUDE in counts.  For bipolar 16-bit data the range is
        +/-2^15, so the amplitude full scale is 2^15 - 1 = 32767.  (Careful:
        the peak-to-peak span is 2^16.  Using that here would put every dBFS
        figure 6 dB off.)

    enbw : float, default 1.5
        Equivalent noise bandwidth of the window, in bins.  1.5 for Hann,
        1.0 for no window (rectangular), 1.36 for Hamming, 1.73 for Blackman.
        MUST match the window fft_process actually applied -- the function has
        no way to check this for you, and getting it wrong costs 1.76 dB.
        It is a ratio of POWERS, so it enters as 10*log10(enbw), never
        20*log10.  It appears because fft_db is normalised to the peak BIN,
        while the carrier's true power is spread across its whole main lobe.

    excess_db : float, default 12.0
        How far above the running noise floor a bin must sit to be called a
        spur and excluded.  Per-bin noise power is exponentially distributed,
        so 12 dB gives a false-rejection rate of about 1e-7 -- across 65536
        bins you would expect 0.01 statistical false positives, meaning
        essentially every rejected bin is a real spur.  Lower it to catch
        weaker spurs at the cost of nibbling into your own noise.

    grow_bins : int, default 8
        Bins masked either side of each detected spur.  A windowed tone is
        never confined to one bin, so its skirt has to be excluded too or it
        contaminates the floor average.

    dc_width_bins : int, default 15
        Bins masked either side of DC (the centre bin after fftshift).  This
        removes the DC offset spike and any LO leakage.  15 is plenty.

    edge_bins : int or None, default None -> 8% of n_bins
        Bins masked at EACH END of the band.  This is a completely different
        job from dc_width_bins and needs a much larger value.  The DDC's
        decimation filter rolls off well before the band edge, so bins out
        there hold ATTENUATED noise.  Averaging them in biases the floor low
        and flatters your NSD.  If your NSD keeps improving as you raise this,
        you have not yet cleared the filter skirt.

    n_iter : int, default 3
        Passes of the spur-rejection loop.  Each pass re-estimates the floor
        from the surviving bins and re-thresholds, so a large spur that
        initially inflated the estimate stops hiding smaller ones.  The mask
        is rebuilt from scratch each pass rather than shrunk, which prevents
        runaway rejection.

    RETURNS
    -------
    dict -- see keys at the bottom.  The headline number is
    'nsd_dbfs_per_hz': it is the only converter-intrinsic quantity here,
    independent of your drive level, FFT length, decimation and window.
    """
    n_bins = len(fft_db)
    p = 10 ** (fft_db / 10.0)          # linear power per bin, peak = 1

    # ---- bins eligible to be called noise -------------------------------
    if edge_bins is None:
        edge_bins = int(0.08 * n_bins)
    base = np.ones(n_bins, dtype=bool)
    base[:edge_bins] = False           # DDC filter rolloff, low side
    base[-edge_bins:] = False          # DDC filter rolloff, high side
    c = n_bins // 2
    base[max(0, c - dc_width_bins):min(n_bins, c + dc_width_bins)] = False

    # ---- iteratively reject spurs ---------------------------------------
    mask = base.copy()
    thresh = 10 ** (excess_db / 10.0)
    kern = np.ones(2 * grow_bins + 1, dtype=int)
    for _ in range(n_iter):
        # Median is robust to spurs; convert to the mean we actually want.
        # Per-bin power of complex Gaussian noise is exponential, whose
        # median is ln(2) times its mean.  (Only true for a single
        # periodogram -- with Welch averaging this factor changes.)
        floor_lin = np.median(p[mask]) / np.log(2.0)
        bad = p > thresh * floor_lin
        if grow_bins:
            bad = np.convolve(bad.astype(int), kern, mode="same") > 0
        mask = base & ~bad
        if mask.sum() < 0.05 * n_bins:
            raise RuntimeError("noise mask collapsed - loosen excess_db")
    
    # ---- extract the rejected spurs as a peak list -----------------------
    spur_bins = base & ~mask                    # rejected for being spurs,
                                                # not for being DC/edge
    spurs = []
    if spur_bins.any():
        # split into contiguous islands
        idx = np.flatnonzero(spur_bins)
        splits = np.flatnonzero(np.diff(idx) > 1) + 1
        for island in np.split(idx, splits):
            k = island[np.argmax(p[island])]    # tallest bin in the island
            spurs.append((k, fft_db[k]))
        spurs.sort(key=lambda s: -s[1])         # loudest first

    # ---- signal ----------------------------------------------------------
    p_noise = p[mask]
    p_peak = p.max()                   # tallest BIN (= 1.0 by construction)
    p_signal = p_peak * enbw           # carrier's TRUE power: the whole lobe

    # Two references for the same noise floor.  Keep them straight:
    #   _raw  is vs the peak bin  -> matches the fft_db scale, use for plots
    #   _dbc  is vs the carrier   -> physically meaningful, use for NSD
    noise_floor_raw = 10 * np.log10(p_noise.mean() / p_peak)
    noise_floor_dbc = 10 * np.log10(p_noise.mean() / p_signal)

    # ---- integrated SNR ---------------------------------------------------
    # Parseval: total noise = mean-per-bin * n_bins.  NO enbw division here.
    # ENBW converts per-bin power to power per Hz; it is not a Parseval
    # correction.  (The original code divided here AND used the bare peak bin
    # as signal -- two errors of 1.5x that cancelled, so snr_db came out
    # right by luck.  Both are fixed now, so it is right on purpose.)
    total_noise = p_noise.mean() * n_bins
    snr_db = 10 * np.log10(p_signal / total_noise)

    # ---- noise spectral density -------------------------------------------
    #   NSD = (noise vs carrier) + (carrier vs full scale) - (bin width)
    # Each term swaps one reference for another; the carrier cancels between
    # the first two, leaving noise-per-Hz relative to full scale.
    fs_out = fs_adc / decim            # complex I/Q rate = full bandwidth
    bin_hz = fs_out / n_bins
    peak_dbfs = 20 * np.log10(a_peak / v_fs)
    nsd = noise_floor_dbc + peak_dbfs - 10 * np.log10(bin_hz)

    # ---- the three ENOBs --------------------------------------------------
    # Same NSD, three choices of (signal level, noise bandwidth):
    #   enob            : your drive level, your decimated band
    #   enob_inband_fs  : full scale,       your decimated band
    #   enob_intrinsic  : full scale,       converter's full Nyquist zone
    return {
        "noise_mask": mask,
        "noise_bins_kept": int(mask.sum()),
        "noise_fraction": mask.sum() / n_bins,
        "noise_floor_per_bin_dbc": noise_floor_raw,    # vs peak bin (plots)
        "noise_floor_vs_carrier_dbc": noise_floor_dbc,  # vs carrier (physics)
        "peak_dbfs": peak_dbfs,
        "bin_hz": bin_hz,
        "nsd_dbfs_per_hz": nsd,
        "integrated_snr_db": snr_db,
        "enob": (snr_db - 1.76) / 6.02,
        "enob_inband_fs": (-nsd - 10 * np.log10(fs_out) - 1.76) / 6.02,
        "enob_intrinsic": (-nsd - 10 * np.log10(fs_adc / 2) - 1.76) / 6.02,
        "spurs": spurs,
    }

def adc_report(r, label=""):
    """
    Format the dict from analyze_fft_noise() as a readable block.

        print(report(results_on, "dither on"))

    Grouped so the physical result (NSD) is separated from the bookkeeping,
    and so the three ENOBs are shown together with what distinguishes them --
    they are the same measurement under different choices of signal level and
    noise bandwidth, not three competing estimates.
    """
    W = 68
    L = []
    L.append("=" * W)
    L.append("  RF-ADC noise analysis" + (f"  --  {label}" if label else ""))
    L.append("=" * W)

    n = r["noise_bins_total"] if "noise_bins_total" in r else None
    L.append("\n  Capture")
    L.append(f"    Bin width           {r['bin_hz']:.1f} Hz")
    L.append(f"    Bins used as noise  {r['noise_bins_kept']}"
             f"   ({100*r['noise_fraction']:.2f}% of spectrum)")

    L.append("\n  Signal")
    L.append(f"    Level               {r['peak_dbfs']:+.2f} dBFS"
             f"      ({-r['peak_dbfs']:.1f} dB of range unused)")

    L.append("\n  Noise floor per bin")
    L.append(f"    vs peak bin         {r['noise_floor_per_bin_dbc']:.2f} dBc"
             f"      <- matches the fft_db plot scale")
    L.append(f"    vs carrier power    {r['noise_floor_vs_carrier_dbc']:.2f} dBc"
             f"      <- ENBW folded in")

    L.append("\n  Noise spectral density")
    L.append(f"    NSD                 {r['nsd_dbfs_per_hz']:.2f} dBFS/Hz")
    L.append("      The converter's own figure. Independent of drive level,")
    L.append("      FFT length, decimation and window. Compare against the")
    L.append("      DS926 table at your input frequency and DSA setting.")

    L.append("\n  Effective bits")
    L.append(f"    As measured         {r['enob']:6.2f} bits"
             f"   (SNR {r['integrated_snr_db']:.2f} dB)")
    L.append(f"      your drive level, your decimated band")
    L.append(f"    At full scale       {r['enob_inband_fs']:6.2f} bits")
    L.append(f"      full scale, your decimated band")
    L.append(f"    Converter intrinsic {r['enob_intrinsic']:6.2f} bits")
    L.append(f"      full scale, converter's full Nyquist zone")

    d1 = r["enob_inband_fs"] - r["enob"]
    d2 = r["enob_inband_fs"] - r["enob_intrinsic"]
    L.append(f"\n    +{d1:.2f} bits from driving to full scale"
             f"  ({-r['peak_dbfs']:.2f} dB)")
    L.append(f"    -{d2:.2f} bits from widening to full Nyquist"
             f"  ({6.02*d2:.2f} dB of decimation gain given back)")

    L.append("\n    The intrinsic figure assumes the noise density measured in")
    L.append("    your decimated band holds across the full Nyquist zone. The")
    L.append("    DDC discarded the evidence, so this data cannot check it.")
    L.append("=" * W)
    return "\n".join(L)
               
def analyze_single_long_capture_fast(raw_buffer, f_fabric):
    """
    Ultra-fast high-resolution peak analysis.
    Uses 32-bit floats, multi-threaded FFT across 4 ARM cores, 
    and lazy-slicing to process 2^24 samples in under 2 seconds.
    """
    N_raw = len(raw_buffer) // 2
    f_sample_hz = f_fabric * 1e6
    T_total = N_raw / f_sample_hz
    rbw = f_sample_hz / N_raw

    # 1. Convert to 32-bit Complex IQ (np.complex64) -> 2x faster, 50% memory
    iq_raw = raw_buffer[0::2].astype(np.float32) + 1j * raw_buffer[1::2].astype(np.float32)

    # 2. Window and FFT using ALL ARM CPU CORES (workers=-1)
    window = sig.windows.hann(N_raw, sym=False).astype(np.float32)
    # FAST (pads 1 sample to N = 16,777,216 -> triggers Radix-2 FFT kernel)
    N_fft = 1 << 24  # 2^24 = 16,777,216
    fft_complex = sfft.fft(iq_raw * window, n=N_fft, workers=-1)
    #fft_complex = sfft.fft(iq_raw * window, workers=-1)

    # 3. Find Peak Index on UN-SHIFTED magnitude squared (no sqrt, no fftshift)
    mag2 = fft_complex.real**2 + fft_complex.imag**2
    peak_idx_raw = np.argmax(mag2)

    # 4. Extract ONLY a slice (±500 bins) around the peak
    span = 500
    slice_indices = (np.arange(peak_idx_raw - span, peak_idx_raw + span)) % N_fft
    sub_complex = fft_complex[slice_indices]

    # Calculate frequencies ONLY for the sliced region
    raw_freqs = np.fft.fftfreq(N_fft, d=1.0 / f_sample_hz)
    sub_freqs = raw_freqs[slice_indices]

    # Sort sliced bins so frequencies increase monotonically
    sort_order = np.argsort(sub_freqs)
    sub_freqs = sub_freqs[sort_order]
    sub_complex = sub_complex[sort_order]

    # 5. Perform math ONLY on the 1,000 sliced points
    sub_mag = np.abs(sub_complex)
    sub_peak_idx = np.argmax(sub_mag)
    sub_mag_norm = sub_mag / sub_mag[sub_peak_idx]

    # FWHM measurement on 1,000 points
    widths, _, left_ips, right_ips = sig.peak_widths(sub_mag_norm, [sub_peak_idx], rel_height=0.5)
    f_left = np.interp(left_ips[0], np.arange(len(sub_freqs)), sub_freqs)
    f_right = np.interp(right_ips[0], np.arange(len(sub_freqs)), sub_freqs)
    fwhm_hz = f_right - f_left

    sub_db = 20 * np.log10(sub_mag_norm + 1e-12)
    peak_freq_hz = sub_freqs[sub_peak_idx]

    return fwhm_hz, f_left, f_right, sub_freqs, sub_db, rbw, T_total, N_raw, peak_freq_hz

print("Initialization done")
The first thing to do is to import the libraries Python needs You can see all the pynq libraries needed, followed by oled to program the onboard OLED and and the xrfdc/RFdc library that allows on-chip RF Data Converter IP block control, allowing you to configure that RF-ADC and RF_DAC hardware features. At the bottom of the list are 2 files were written with the help of Claude:

Then the class declaration, which is called Gen but you can name it anything you like. The only argument is a logical controlling whether to implement pl_quiesce.py or not, default to True. pl_quiesce is run, then the Overlay to actually load the firmware, then once that's done the code checks for dangling lines in the firmware project and reports accordingly. Following that, it digs out the firmware version. The actual downloading of the firmware bitfile happens in parallel, so the next line checks if it's finished and if so, does some housekeeping: sets up the radio class, and importantly calls init_rf_clks(). This is a convenient wrapper function that automatically programs the board's external (to the Xilinx chip) clock synthesizers to the default reference frequencies required. The default for the 4x2 and the ZCU208 is to use frequencies that are telecom standards. In the telecom world, 122.88 MHz is the standard reference clock and sampling frequency used in 5G because it easily divides into and aligns with common digital data and audio rates. On the 4x2, it is recommended to use $4\times 122.88=491.52$MHz, which is what is used in this project. Calling init_rf_clks() automatically programs the clock chips so that this is the clock produced, locked and loaded.

Next in the class declaration are convenience functions that we can use. get_version returns the firmware version in case you need it for some reason:

set_external_rf_clks allows you to use the external CLK_IN SMA input to put a precision clock into the board, usually 10MHz, and set_internal_rf_clks changes it to internal. These functions do not program any of the clock cleanup chips, they only change a mux that determines which clocks are used by the cleanup.

As discussed in the clocking section of the main tutorial, the internal clocks have a precision of around 15ppm, which is not so great if we are in the several GHz regime. So what you can do is to run a highly disciplined 10MHz clock into CLK_IN, then in the code you would setup by calling init_rf_clks and then tell it to switch to the external clock via set_external_rf_clks.

If you want to find out exactly what the clock chips are programmed to do, you can put these next 2 functions in the class definition and call via gen.report_lmk_config()

    def get_lmk_regs(self):
        """
        Reads and parses the LMK04828 register configuration from xrfclk 
        and live hardware SPI.
        """
        import glob
        import os
        import struct
        import fcntl

        info = {
            'board': getattr(xrfclk, 'attributes', {}).get('board', 'Unknown'),
            'devices': xrfclk.lmk_devices,
            'regs': {},
            'config_file': None
        }

        # 1. Locate the LMK config file loaded by xrfclk for this board
        try:
            board_dir = os.path.dirname(xrfclk.__file__)
            board_name = info['board'].lower()
            txt_files = glob.glob(f"{board_dir}/board_files/{board_name}/*LMK*.txt") + \
                        glob.glob(f"{board_dir}/board_files/*/*LMK*.txt")
            if txt_files:
                info['config_file'] = txt_files[0]
                with open(txt_files[0], 'r') as f:
                    for line in f:
                        line = line.strip()
                        if line.startswith("0x"):
                            parts = line.replace('\t', ' ').split()
                            val = int(parts[0], 16)
                            addr = (val >> 8) & 0x1FFF
                            data = val & 0xFF
                            info['regs'][addr] = data
        except Exception as e:
            print(f"Warning: Could not parse xrfclk config file: {e}")

        # 2. Try live SPI readback on key LMK registers over /dev/spidev*
        # LMK04828 24-bit SPI Read Frame: [R/W=1 (1 bit) | W1-W0=00 (2 bits) | Addr (13 bits) | Data (8 bits)]
        SPI_IOC_MESSAGE_1 = 0x40206B00  # Linux SPI transfer ioctl command
        target_addrs = [0x0147, 0x0153, 0x0154, 0x0156, 0x0157, 0x0159, 0x015A, 0x015B, 0x015C]
        
        for lmk in xrfclk.lmk_devices:
            dev_path = lmk.get('spi_device')
            if not dev_path or not os.path.exists(dev_path):
                continue
            try:
                with open(dev_path, 'rb+', buffering=0) as f:
                    for addr in target_addrs:
                        # Construct 3-byte read packet
                        cmd = 0x800000 | ((addr & 0x1FFF) << 8)
                        tx_buf = struct.pack('>I', cmd)[1:]  # 3 bytes
                        rx_buf = bytearray(3)
                        
                        # SPI ioctl structure: tx_buf, rx_buf, len=3, speed, delay, bits
                        tr = struct.pack('QQIIHBBBB',
                            id(tx_buf) + 32, id(rx_buf) + 32, 3, 1000000, 0, 8, 0, 0, 0)
                        try:
                            fcntl.ioctl(f.fileno(), SPI_IOC_MESSAGE_1, tr)
                            info['regs'][addr] = rx_buf[2]
                        except OSError:
                            pass # Fallback to file-parsed values if SPI read is restricted
            except Exception:
                pass

        return info

    def report_lmk_config(self):
        """
        Prints a detailed report of the LMK04828 clock configuration, MUX state,
        dividers, and the exact expected external reference frequency.
        """
        cfg = self.get_lmk_regs()
        regs = cfg['regs']

        print("=" * 65)
        print(f" LMK04828 CLOCK CONFIGURATION REPORT ({cfg['board'].upper()})")
        print("=" * 65)

        if cfg['config_file']:
            print(f"Loaded Register File : {os.path.basename(cfg['config_file'])}")
        for dev in cfg['devices']:
            print(f"SPI Device Path      : {dev.get('spi_device', 'N/A')}")

        if not regs:
            print("\nError: Unable to read LMK registers or config files.")
            print("=" * 65)
            return

        # --- 1. MUX & Input Selection ---
        r147 = regs.get(0x0147, 0x1A)
        sel_mode = (r147 >> 3) & 0x07
        clkin_mux = r147 & 0x07

        mux_map = {0: "CLKin0", 1: "CLKin1", 2: "CLKin2", 3: "Bypassed/FB"}
        sel_map = {0: "CLKin0 Manual", 1: "CLKin1 Manual", 2: "CLKin2 Manual", 
                   3: "Pin Select Mode", 4: "Auto Mode"}

        print("\n--- [1] INPUT MUX STATE (Register 0x0147) ---")
        print(f"Raw Reg 0x0147 Value : 0x{r147:02X} (Binary: {r147:08b})")
        print(f"Active CLKin MUX     : {mux_map.get(clkin_mux, 'Unknown')} (Code {clkin_mux})")
        print(f"Selection Mode       : {sel_map.get(sel_mode, 'Unknown')} (Code {sel_mode})")

        # --- 2. Dividers ---
        r_clkin0 = ((regs.get(0x0153, 0) & 0x3F) << 8) | regs.get(0x0154, 0)
        r_clkin1 = ((regs.get(0x0156, 0) & 0x3F) << 8) | regs.get(0x0157, 0)
        r_clkin2 = ((regs.get(0x0159, 0) & 0x3F) << 8) | regs.get(0x015A, 0)
        n_pll1   = ((regs.get(0x015B, 0) & 0x3F) << 8) | regs.get(0x015C, 0)

        print("\n--- [2] PLL1 DIVIDERS ---")
        print(f"CLKin0 R-Divider     : {r_clkin0}")
        print(f"CLKin1 R-Divider     : {r_clkin1}")
        print(f"CLKin2 R-Divider     : {r_clkin2}")
        print(f"PLL1 N-Divider       : {n_pll1}")

        # --- 3. Calculated Frequencies ---
        # On RFSoC 4x2 / ZCU111, onboard VCXO (OSCin) is traditionally 122.88 MHz
        f_oscin = 122.88  # MHz

        print("\n--- [3] EXPECTED REFERENCE CLOCK FREQUENCIES ---")
        print(f"Assumed Board VCXO   : {f_oscin} MHz")

        if n_pll1 > 0:
            pdf1 = f_oscin / n_pll1  # Phase Detector Frequency in MHz
            print(f"PLL1 Phase Det Freq  : {pdf1 * 1000:.3f} kHz")

            # Calculate required external input frequency for each port
            ext_clkin0 = pdf1 * r_clkin0 if r_clkin0 > 0 else 0
            ext_clkin1 = pdf1 * r_clkin1 if r_clkin1 > 0 else 0
            ext_clkin2 = pdf1 * r_clkin2 if r_clkin2 > 0 else 0

            print("\nRequired Clock for Lock on each port:")
            print(f"  * If using CLKin0  : {ext_clkin0:.3f} MHz")
            print(f"  * If using CLKin1  : {ext_clkin1:.3f} MHz")
            print(f"  * If using CLKin2  : {ext_clkin2:.3f} MHz")

            # Determine currently active port expectation
            active_r = [r_clkin0, r_clkin1, r_clkin2][clkin_mux if clkin_mux < 3 else 0]
            active_freq = pdf1 * active_r
            print(f"\n=> CURRENTLY SELECTED PORT ({mux_map.get(clkin_mux, 'CLKin0')}): EXPECTS {active_freq:.2f} MHz")
        else:
            print("PLL1 N-Divider is 0 (PLL1 Bypassed / Dual Loop disabled).")

        print("=" * 65)   

The next function dac_status reports the DAC status, useful in case there are problems and we want to get one of the AIs to help us.

dac_hard_stop can sometimes help with making sure everything is in a known state. This is important here because the DAC path uses scatter-gather, which can be tricky, which is why we have the DAC and ADC paths on separate DMA ports and DMA engines.

The next few are convenience functions for setting frequencies without having to call updates etc, followed by some convenience functions for processing the data and producing the FFT (fft_process), analyzing the spectrum to calculate the noise floor, SNR, and equivalent number of bits (ENOB) (analyze_fft_noise), and finding the width of the incoming peak that the DAC is generating (analyze_single_long_capture_fast).

Downloading and Initialization

In the next cell, we execute to set everything up and report the status:

"""
Initialize the overlay class and execute the download, etc
"""
gen = Gen()
ps_clock = Clocks.fclk0_mhz
version = gen.version
oled = oled.oled_display()
oled.write("Version \n"+hex(version))
print("Firmware version: ",hex(version))
print("PS Clock: ", ps_clock, " MHz")
print("DAC: sampling at "+str(rfdc_dac_sampling)+" MHz and interpolation "+str(rfdc_dac_interpolation))
print("ADC: sampling at "+str(rfdc_adc_sampling)+" MHz and decimation "+str(rfdc_adc_decimation))
print("all done")
gen.ip_dict
The output should look something like this:
quiesce_pl: matched 2 of 4 named DMA(s) in /home/xilinx/jupyter_notebooks/ADMX/tutorials/Loopback_v1.bit
  AXI_DMA_NOSG @ 0xa0000000
    AXI_DMA_NOSG.MM2S        was_running=False halted=True reset_cleared=True  ok  [was already stopped]
    AXI_DMA_NOSG.S2MM        was_running=False halted=True reset_cleared=True  ok  [was already stopped]
  AXI_DMA_SG @ 0xa0010000
    AXI_DMA_SG.MM2S          was_running=False halted=True reset_cleared=True  ok  [was already stopped]
    AXI_DMA_SG.S2MM          was_running=False halted=True reset_cleared=True  ok  [was already stopped]
hwh_check: Loopback_v1.hwh -- no dangling interfaces or floating inputs
Firmware version:  0xa5000001
PS Clock:  249.9975  MHz
DAC: sampling at 6881.28 MHz and interoplation 40
ADC: sampling at 4915.2 MHz and decimation 40
all done
You can see the quiesce_pl output found similar DMA names in the previous project, and reported that all is quiet with nothing to do. The rest of the output reports firmware versions, clocks, etc.

The command gen.ip_dict will type out the Python dictionary for all of the blocks defined in the project using Vivado.

  ▼ ip_dict:
  ▶ AXI_DMA_SG:AXI_DMA_NOSG:RX_FIFO_RESET:RX_FIFO_TLAST:VERSION:RFDC:ZYNQ:

Expand these and you will see all the attributes of each of these objects, including status and control registers.

Diagnostics for Consistency

The next cell is completely diagnostic. What it does is to make sure that the parameters defined for the radio part of the project in the first cell agrees with the parameters embedded into the hwh file. If they don't agree, we sure do want to know, and this code informs us accordingly.

"""
get relevant parameters from the actual values that the hardware rf converter drivers have.
these are based on the actual values of the reference clocks.
compare to the parameters at the beginning of the first cell above
"""
st = gen.RFDC.IPStatus

def _fmt(x, spec=".4f"):
    """format a possibly-None number without blowing up"""
    return "n/a" if x is None else format(x, spec)

def report(tiles, tstat, label, base, factor_attr, verbose=True):
    """Returns {tile_idx: {'phys':..., 'fs_msps':..., 'blocks':
                           {blk_idx: {'factor':..., 'fs_msps':...,
                                      'fabric_msps':..., 'nco_mhz':...}}}}"""
    out = {}
    for t, tile in enumerate(tiles):
        s = tstat[t]
        if not s['IsEnabled']:
            continue
        try:
            fs = tile.PLLConfig['SampleRate'] * 1000.0     # GSps -> MSps
        except RuntimeError as e:
            print(f"warning: {label} tile {t}: PLLConfig unreadable ({e})")
            fs = None
        rec = {'phys': base + t, 'fs_msps': fs, 'blocks': {}}
        for b in range(4):
            if not (s['BlockStatusMask'] >> b) & 1:
                continue
            blk = tile.blocks[b]
            factor = getattr(blk, factor_attr)
            bfs = blk.BlockStatus['SamplingFreq'] * 1000.0
            rec['blocks'][b] = {
                'factor':      factor,
                'fs_msps':     bfs,
                'fabric_msps': bfs / factor if factor else None,
                'nco_mhz':     blk.MixerSettings['Freq'],
            }
        out[t] = rec
        if verbose:
            print(f"{label} tile {t} (phys {rec['phys']}): Fs = {_fmt(fs)} MSps, "
                  f"blocks enabled = {list(rec['blocks'])}")
            for b, d in rec['blocks'].items():
                print(f"    block {b}: {factor_attr} = {d['factor']}, "
                      f"fabric = {_fmt(d['fabric_msps'])} MSps, "
                      f"NCO = {d['nco_mhz']} MHz")
    return out

def pick_tile(rec_map, t, label):
    if t not in rec_map:
        raise RuntimeError(
            f"{label}: tile {t} is not enabled; enabled tiles are {sorted(rec_map)}")
    if rec_map[t]['fs_msps'] is None:
        raise RuntimeError(f"{label}: tile {t} has no readable sample rate")
    return rec_map[t]

def only_block(rec, label):
    blocks = rec['blocks']
    if not blocks:
        raise RuntimeError(f"{label}: no enabled blocks")
    if len(blocks) != 1:
        print(f"note: {label} has {len(blocks)} enabled blocks {list(blocks)}; taking lowest")
    return blocks[min(blocks)]

dac = report(gen.RFDC.dac_tiles, st['DACTileStatus'], "DAC", 228, "InterpolationFactor")
adc = report(gen.RFDC.adc_tiles, st['ADCTileStatus'], "ADC", 224, "DecimationFactor")

dac_rec = pick_tile(dac, 0, "DAC")
adc_rec = pick_tile(adc, 2, "ADC")
d = only_block(dac_rec, "DAC tile 0")
a = only_block(adc_rec, "ADC tile 2")

dac_fs, dac_interp = dac_rec['fs_msps'], d['factor']
adc_fs, adc_decim  = adc_rec['fs_msps'], a['factor']
if not dac_interp or not adc_decim:
    raise RuntimeError(f"bad rate-change factor: interp={dac_interp}, decim={adc_decim}")
dac_fabric = dac_fs / dac_interp
adc_fabric = adc_fs / adc_decim

print(f"\nDAC: {dac_fs:.4f} MSps / {dac_interp}x  -> {dac_fabric:.4f} MSps fabric")
print(f"ADC: {adc_fs:.4f} MSps / {adc_decim}x  -> {adc_fabric:.4f} MSps fabric")

#
# compare to parameters in the first cell
#
def as_msps(x):
    """coerce a reference sample rate to a float in MSps.
       HWH values arrive as strings, sometimes in GSps.
       heuristic: anything below 100 is assumed to be GSps
       (real converter rates are 0.5-10 GSps = 500-10000 MSps)."""
    if x is None:
        return None
    v = float(x)
    return v * 1000.0 if v < 100.0 else v

def check(name, live, expected, rtol=1e-6):
    if expected is None:
        print(f"{name}: no reference value")
    elif live is None:
        print(f"{name}: no live value to compare")
    elif abs(live - expected) <= rtol * abs(expected):
        print(f"{name} is consistent ({live:.6g})")
    else:
        print(f"================> {name} is INCONSISTENT!!! "
              f"live={live:.6g}  expected={expected:.6g}")

check("DAC sampling",      dac_fs,     as_msps(rfdc_dac_sampling))
check("DAC interpolation", dac_interp, int(rfdc_dac_interpolation))
check("ADC sampling",      adc_fs,     as_msps(rfdc_adc_sampling))
check("ADC decimation",    adc_decim,  int(rfdc_adc_decimation))
If all goes well you should see this output:
DAC tile 0 (phys 228): Fs = 6881.2800 MSps, blocks enabled = [0]
    block 0: InterpolationFactor = 40, fabric = 172.0320 MSps, NCO = 99.99999999998721 MHz
ADC tile 2 (phys 228): Fs = 4915.2000 MSps, blocks enabled = [1]
    block 1: DecimationFactor = 40, fabric = 122.8800 MSps, NCO = 99.99999999999417 MHz

DAC: 6881.2800 MSps / 40x  -> 172.0320 MSps fabric
ADC: 4915.2000 MSps / 40x  -> 122.8800 MSps fabric
DAC sampling is consistent (6881.28)
DAC interpolation is consistent (40)
ADC sampling is consistent (4915.2)
ADC decimation is consistent (40)

DMA and FIFO functions

In the next cell, we set up for AXI GPIO and define helper functions:

and then calls them to put the system into a known state, ready to start the DAC path transfer. Code is below.

"""
set up the GPIOs for the RX_FIFO_RESET, RX_FIFO_TLAST over DMA, and vanilla DMA
"""
#
# setup for the RX_FIFO_RESET
#
rx_fifo_reset_ip = gen.ip_dict["RX_FIFO_RESET"]   # this setups up the pointer to the address in the dictionary
rx_fifo_reset = AxiGPIO(rx_fifo_reset_ip).channel1 # we are using channel 1 of the 2 channels
def RX_FIFO_RESET():
    rx_fifo_reset.write(0,0x1)
def RX_FIFO_RELEASE():
    rx_fifo_reset.write(1,0x1)
#
# now for TLAST
#
tlast_ip = gen.ip_dict["RX_FIFO_TLAST"]
tlast = AxiGPIO(tlast_ip).channel1
def SET_TLAST(n):
    tlast.write(n,0xFFFFFF)
#
# setup for DMA 
#
dma_tx = gen.AXI_DMA_SG
dma_rx = gen.AXI_DMA_NOSG

def DMA_SS2M_SOFT_RESET():
    dma_rx.register_map.S2MM_DMACR.Reset = 1
    t0 = time.time()
    print("DMA_SS2M_SOFT_RESET: S2MM status register: "+hex(dma_rx.register_map.S2MM_DMASR))
    while dma_rx.register_map.S2MM_DMACR.Reset:
        if time.time() - t0 > 0.1:
            raise RuntimeError("DMA soft reset did not complete!")
        time.sleep(0.001)
    print("S2MM control register: "+hex(dma_rx.register_map.S2MM_DMACR))
    print("S2MM status register: "+hex(dma_rx.register_map.S2MM_DMASR))

#
# set the RX_FIFO to be in the reset state
#
RX_FIFO_RESET()
#
# reset the S2MM DMA just to be sure we are in a known state
#
DMA_SS2M_SOFT_RESET()

Carrier Frequencies

Next, set the carrier frequencies, also known as the NCO (numerically controlled oscillator) values, in MHz. We will use 500MHz so that we can compare with the Xilinx documentation (see below).

"""
set the mixer frequencies for the ADC and DAC.  these should be the same for a loopback.  
these should be far enough away from the baluns limit of round 20 MHz
"""
gen.freq_adc = adc_carrier
gen.freq_dac = dac_carrier

Note that setting the frequencies like this is possible courtesy of the "@property" code above.

Contiguous Space

As a check, make sure you know how much contiguous free space you have:

!cat /proc/meminfo | grep -i cma

The exclamation in front of the command allows Python to submit it to the system. This should return something like:

CmaTotal:    524288 kB
CmaFree:     513320 kB
Note that CmaTotal is around half a GByte, which is what we set in the boot parameters for this board (see
here).

Allocate DAC buffers

Now we have to allocate buffers in the DDR4 memory, and set up for the DMA transfers. Note that the data into the DAC and back from the ADC will be I/Q with I in the lower 16 bits and Q in the upper. So first decide on how many words we want to send, making it some power of 2 for convenience (see above), and allocate the memory as 16-bit memory with twice the number of words. When the memory gets sent, it will start with the first 16 bits which is on an even address (0) so will contain the first I data point, then address 1 for the first Q, and so on. These data will be sent sequentially, and somewhere down the line the DMA will take 32 bits at a time and push them into the FIFO.

Put the following into the next cell:

"""
Allocate buffers for the DAC.

data_size are actually number of 16 bit words, for I and for Q. this number has to be smaller than the
number of free bytes from the command above.

The maximum DMA is 67 MB (2^26) but for DMA scatter-gather it can do a series of transfers.

Make the send and receive buffers. We make want some number of samples, with I and Q each as 16 bits. 
If we define 16 bit buffers, and put the I on the even addresses and Q on the odd addresses, 
then when the buffer is sent over DMA, since we've configured that for 32 bit "beats", 
it will put them together correctly.

set the number of data points we want to send to be an even power of 2. 
then data_size will be twice that since we are interleaving I and Q
"""
npoints = 2**21
data_size = 2*npoints 
print(f"Array will hold {npoints} = {npoints/1e6:.2f}M data points")
send_DAC_buffer = allocate(shape=(data_size,), dtype=np.int16)
DAC_channel = gen.AXI_DMA_SG.sendchannel

The output will be

Array will hold 2097152 = 2.10M data points

Halt the DAC

The next cell is not necessary, unless you want to halt the DAC. But it doesn't hurt to run it.

gen.dac_hard_stop()

Create the Waveform

Now we want to fill that memory with data for the DAC input. We will generate a 5 MHz sine wave, (f_mod below) and sample it (this is Python, not the FPGA!) at the same sampling frequency as in the hardware, only here the sampling will be the frequency at which data will be sent into the RF converter to be interpolated for the DAC sampling. We have set the DAC sampling to 6881.28 MHz with an interpolation of 40, so we want our sampling to be that same ratio. This is what sets the time scale on the data we are creating. This is the variable $f_{DAC}$ which multiplies the RFDC DAC sampling rate by $10^6$ since that is specified in MHz.

"""
now make an array that has the waveform, and take care that it fits nicely into "npoints" 
so there's no phase discontinuity
"""

f_DAC = 1.0E6*rfdc_dac_sampling
print(f"DAC sampling at {f_DAC/1E9:.6f} GSps")
fs = f_DAC/rfdc_dac_interpolation
print(f"DAC 'fabric' rate at {fs/1e6:.3f} MHz")
f_mod = 5.0e6   # in Hz
print(f"modulation tone at {f_mod:.8f} Hz = {f_mod/1e6:.1f} MHz")
#
# modify the modulation tone so there are an integer number of periods in the array to avoid phase discontinuties when we cycle it into the DAC
#
m = round(f_mod*npoints/fs)
print("Number of cycles is set to "+str(m))
f = m*fs/npoints
print(f"Modified modulation tone at {f:.8f} Hz = {f/1e6:.1f} MHz,  differs by {(f_mod-f):.3f} Hz")
#
# now make an i and q
#
duration = npoints/fs
print(f"Array duration is {duration:.7f} sec")
ampl = 0.9
n     = np.arange(npoints, dtype=np.float64)
start = time.perf_counter()
phase = (2*np.pi*m/npoints)*n              # exact, no float drift from t
i_a   = ampl*np.sin(phase)
q_a   = ampl*np.cos(phase)
#i_a = np.array([ampl] * npoints)
#q_a = np.array([ampl] * npoints)
stop = time.perf_counter()
print(f"Took {stop-start:.3f} seconds to fill both arrays")

Note that our DAC sampling is given in MHz, so we have to multiply by $10^6$ to get Hz, which is what Python is expecting for frequencies. Also, note the lines where we find the number of cycles in the array m, turn it into an integer, and then calculate the frequency f that will be used to sample to make the array values. Then the code makes the i_a and q_a I and Q arrays. I've also commented out 2 lines below that that you can uncomment so that you just send a constant into the DAC. The output should be:

DAC sampling at 6.881280 GSps
DAC 'fabric' rate at 172.032 MHz
modulation tone at 5000000.00000000 Hz = 5.0 MHz
Number of cycles is set to 60952
Modified modulation tone at 4999968.75000000 Hz = 5.0 MHz,  differs by 31.250 Hz
Array duration is 0.0121905 sec
Took 0.657 seconds to fill both arrays

As you can see from the output, the 5MHz modulation tone is modified by 31.250Hz so that there's no phase discontinuity between cycles.

Pack Waveform into Buffers

Now we pack the data from our i_a and q_a arrays into memory to send to the DAC:

#
# pack send_DAC_buffer[::2] to copy bytes to even addresses, and 1::2 to copy to odd, 
# since this is how the DCU expects I and Q data.
#
# and send the buffer into memory

send_DAC_buffer[::2]  = np.rint(i_a*(2**15-1)).astype(np.int16)
send_DAC_buffer[1::2] = np.rint(q_a*(2**15-1)).astype(np.int16)

print("done")

We use np.rint to round to the nearest integer (np.floor rounds downwards and would slightly distort symmetrical AC waveforms) and cast using astype(np.int16) instead of just np.int16 because the latter will make Numpy first copy the array into another array before moving into the buffer.

Start DAC DMA

Now, start the DMA. First issue a gen.dac_hard_stop() to make sure the hardware is in a known state. Then reset the RFDC internal FIFO's so they are in a known state, (stop() causes a reset and state purge), then the flush() makes sure everything is in the DDR and not in the system caches, the transfer is a purely configuration step that causes information to written to all the controllers (source addresses, transfer lengths, control flags, etc). Then check that the DAC status is ok and let us know.

"""
start the DAC
"""

tile = gen.RFDC.dac_tiles[dac_tile_n]
gen.dac_hard_stop()               # bounded stop, never hangs
#
# reset the internal FIFOs in the RF Converter DAC side, so it's in a known state
#
tile.SetupFIFO(False)             # safe ONLY because MM2S is halted
tile.SetupFIFO(True)              # bring the tile FIFO up clean
send_DAC_buffer.flush()
DAC_channel.transfer(send_DAC_buffer, cyclic=True)

status = gen.dac_status()
if status['ok']:
    print("DAC seems to be running...")
    oled.write("DAC DMA cyclic on")
else:
    print("Problems with the DAC status.  Here is a printout:")
    print(status)
    oled.write("DAC PROBLEM!!!")
In the above, note cyclic=True. This is how Python interacts with the scatter-gather (SG) part of the DMA engine. As stated above, true SG means you can have an array data scattered in different contiguous chunks of memory in DDR, and to do a DMA for the entire array the DMA engine would be told the starting address of the first chunk, and after that's done it finds the starting address of the 2nd chunk, and so on until all chunks are sent. That doesn't exactly work in PYNQ (maybe later?), but if you put cyclic=True in the transfer, it tells the DMA that when you get to the end of the 1st chunk, just go back to the beginning and cycle through.

At this point, if you take the DAC output and put it into a spectrum analyzer, you should see a nice peak like in the following photo, where the width of the peak is measured to be a few Hz, which might be at the limit of the instrument.

ADC path

Next, unplug the SMA connector from the spectrum analyzer and route it into the ADC_A input on the 4x2 board. In the next cell we set the number of ADC samples to capture, and specify a few variables for the analysis. Then set the TLAST target, and assert RX_FIFO reset so we know that the FIFO is empty. Then we will take 2 sets of data, one with analog amplitude dithering off and then with dithering on, so that we can compare. First, set dither to off, allocate a new buffer for the ADC data, and tell the 4x2 to start the transfer. However, the transfer will wait for the FIFO to have some data in it, and at this point it's empty because the reset line is still asserted (active low). So once all of that is ready, deassert the RX_FIFO reset via RX_FIFO_RELEASE(), print the block status, and then wait for a reasonable amount of time (as opposed to just issuing a Python wait(), which is blocking, and if the transfer fails you will have to restart the kernel). Once the data has arrived, put the RX_FIFO back into reset, and "invalidate" the buffer which is telling Linux that it doesn't have to keep that buffer in cache and can finish writing all of it to DDR in case it hadn't already. Then check if any data was actually sent and report it. If it reports 0, then you know something went wrong. That concludes one transfer. Then set dither on, and repeat.

Here's the code for that cell:

"""
Prepare to send data to ADC to compare dither on vs dither off in this cell
"""
#
# first you can maybe recover 3dB of gain by changing how the mixer attenuates, which I believe defaults to 3dB,
# but I'm not entirely sure.  so I've commented out the relevant few lines below but you can go ahead and play
# with it, maybe Gemini can figure it out for you!
#
adc_blk = gen.RFDC.adc_tiles[adc_tile_n].blocks[adc_block_n]
print("BlockStatus: ",adc_blk.BlockStatus)   # DataPathClocksStatus, FIFOFlagsAsserted
#ms = dict(adc_blk.MixerSettings)
#ms['FineMixerScale'] = 1
#adc_blk.MixerSettings = ms
#adc_blk.UpdateEvent(1)
print("Mixer settings:",adc_blk.MixerSettings)
print("DSA:",adc_blk.DSA)
#
# Grabs 2**16 I/Q pairs instead of what the DAC uses (e.g. 2**21 or so)
# At that size the whole spectrum is only 65,536 bins, so it plots directly 
# with no decimation and no envelope trickery.
#
# RBW = 122.88 MHz / N_CAP, capture duration = 1 / RBW
#
N_CAP = 2**16          # I/Q pairs to capture
SAMPLE_SUBSET = 200    # samples drawn in the time-domain panel
N_PEAKS = 6            # rows in the peak table
#
# make sure the TLAST target is set
#
SET_TLAST(N_CAP)
print("TLAST target set to " + hex(tlast.read()))
#
# reset the RX_FIFO to be sure it's in a known state and empty
#
RX_FIFO_RESET()
#
# dither off is first
#
adc_blk.Dither = 0
print("\n-----------------\nStarting run")
print("CalibrationMode:", adc_blk.CalibrationMode, "  Dither:", adc_blk.Dither)
# ---------------------------------------------------------------- capture --
# release any buffer from a previous run so repeated executions don't eat CMA
# use try/except because this buffer is defined below, so the first time you execute
# it, it will bomb unless you use try/except
#
try:
    recv_ADC_buffer_ditheroff.freebuffer()
except NameError:
    pass
#
# allocate the receive buffer and setup the channel for DMA, start the DMA so that
# the state machines are ready, and send the address using .transfer.  
#
# Note that at this point, no DMA will happen because the FIFO is reset, which means it's empty,
# and this will keep the AXIS stream bus in a wait mode until data can start flowing
#
recv_ADC_buffer_ditheroff = allocate(shape=(2 * N_CAP,), dtype=np.int16)
ADC_channel = gen.AXI_DMA_NOSG.recvchannel
ADC_channel.start()
ADC_channel.transfer(recv_ADC_buffer_ditheroff)
print("S2MM DMASR armed: " + hex(dma_rx.register_map.S2MM_DMASR))
#
# release the FIFO reset, and data will start flowing
#
RX_FIFO_RELEASE()
print("BlockStatus after RX_FIFO_RELEASE: ",adc_blk.BlockStatus)   # DataPathClocksStatus, FIFOFlagsAsserted
#
# wait for the DMA to finish.  instead of a wait, which is blocking, we set the maximum
# time we will need.   we know the clock frequency (ps_clock) and we know the number of
# words so this is easy to calculate:
#
total_time = max(1.0,N_CAP*1e-6/adc_fabric)
print("Will wait ",total_time," seconds for the data")
#
to = time.time()
ok = False
while True:
    if dma_rx.register_map.S2MM_DMASR.Idle:
        ok = True
        break
    if time.time() - to > total_time:
        print("=== S2MM TIMEOUT ===")
        print("DMASR:", dma_rx.register_map.S2MM_DMASR)
        print("BlockStatus after timeout:", adc_blk.BlockStatus)
        break
    time.sleep(0.01)
#
# Reset the FIFO so we are in a known state
#
RX_FIFO_RESET()
#
# this next command has to do with the fact that writing INTO DDR from the FPGA
# does not go through the ARM chip, but directly to DDR through the HP ports in
# the PS.  but the ARM chip might still hold state lines for some of those memory
# locations you allocated (and would hold zeros).  calling invalidate() for the
# receive buffer marks them dead so the next numpy read refetches and doesn't use
# ARM cache.   
#
recv_ADC_buffer_ditheroff.invalidate()
#
# check if we got anything.  if so, do the analysis
nonzero =  np.count_nonzero(recv_ADC_buffer_ditheroff)
print("nonzero words:", nonzero, "of", len(recv_ADC_buffer_ditheroff))
if nonzero == 0:
    print("Something happened, no data arrived.  Bail!")
else:
    print("ADC data arrived ok")
#
# now repeat with dither on
#
adc_blk.Dither = 1
print("\n-----------------\nStarting run")
print("CalibrationMode:", adc_blk.CalibrationMode, "  Dither:", adc_blk.Dither)
# ---------------------------------------------------------------- capture --
# release any buffer from a previous run so repeated executions don't eat CMA
# use try/except because this buffer is defined below, so the first time you execute
# it, it will bomb unless you use try/except
#
try:
    recv_ADC_buffer_ditheron.freebuffer()
except NameError:
    pass
#
# allocate the receive buffer and setup the channel for DMA, start the DMA so that
# the state machines are ready, and send the address using .transfer.  
#
# Note that at this point, no DMA will happen because the FIFO is reset, which means it's empty,
# and this will keep the AXIS stream bus in a wait mode until data can start flowing
#
recv_ADC_buffer_ditheron = allocate(shape=(2 * N_CAP,), dtype=np.int16)
ADC_channel = gen.AXI_DMA_NOSG.recvchannel
ADC_channel.start()
ADC_channel.transfer(recv_ADC_buffer_ditheron)
print("S2MM DMASR armed: " + hex(dma_rx.register_map.S2MM_DMASR))
#
# release the FIFO reset, and data will start flowing
#
RX_FIFO_RELEASE()
print("BlockStatus after RX_FIFO_RELEASE: ",adc_blk.BlockStatus)   # DataPathClocksStatus, FIFOFlagsAsserted
#
# wait for the DMA to finish.  instead of a wait, which is blocking, we set the maximum
# time we will need.   we know the clock frequency (ps_clock) and we know the number of
# words so this is easy to calculate:
#
total_time = max(1.0,N_CAP*1e-6/adc_fabric)
print("Will wait ",total_time," seconds for the data")
#
to = time.time()
ok = False
while True:
    if dma_rx.register_map.S2MM_DMASR.Idle:
        ok = True
        break
    if time.time() - to > total_time:
        print("=== S2MM TIMEOUT ===")
        print("DMASR:", dma_rx.register_map.S2MM_DMASR)
        print("BlockStatus after timeout:", adc_blk.BlockStatus)
        break
    time.sleep(0.01)
#
# Reset the FIFO so we are in a known state
#
RX_FIFO_RESET()
#
# this next command has to do with the fact that writing INTO DDR from the FPGA
# does not go through the ARM chip, but directly to DDR through the HP ports in
# the PS.  but the ARM chip might still hold state lines for some of those memory
# locations you allocated (and would hold zeros).  calling invalidate() for the
# receive buffer marks them dead so the next numpy read refetches and doesn't use
# ARM cache.   
#
recv_ADC_buffer_ditheron.invalidate()
#
# check if we got anything.  if so, do the analysis
nonzero =  np.count_nonzero(recv_ADC_buffer_ditheron)
print("nonzero words:", nonzero, "of", len(recv_ADC_buffer_ditheron))
if nonzero == 0:
    print("Something happened, no data arrived.  Bail!")
else:
    print("ADC data arrived ok")

Followed by the resulting output:

BlockStatus:  {'SamplingFreq': 4.9152, 'AnalogDataPathStatus': 1, 'DigitalDataPathStatus': 897, 'DataPathClocksStatus': 1, 'IsFIFOFlagsEnabled': 3, 'IsFIFOFlagsAsserted': 0}
Mixer settings: {'Freq': 499.99999999998835, 'PhaseOffset': 0.0, 'EventSource': 2, 'CoarseMixFreq': 0, 'MixerMode': 3, 'FineMixerScale': 0, 'MixerType': 2}
DSA: {'DisableRTS': 0, 'Attenuation': 0.0}
TLAST target set to 0x10000

-----------------
Starting run
CalibrationMode: 2   Dither: 0
S2MM DMASR armed: 0x0
BlockStatus after RX_FIFO_RELEASE:  {'SamplingFreq': 4.9152, 'AnalogDataPathStatus': 1, 'DigitalDataPathStatus': 897, 'DataPathClocksStatus': 1, 'IsFIFOFlagsEnabled': 3, 'IsFIFOFlagsAsserted': 0}
Will wait  1.0  seconds for the data
nonzero words: 131060 of 131072
ADC data arrived ok

-----------------
Starting run
CalibrationMode: 2   Dither: 1
S2MM DMASR armed: 0x1000
BlockStatus after RX_FIFO_RELEASE:  {'SamplingFreq': 4.9152, 'AnalogDataPathStatus': 1, 'DigitalDataPathStatus': 897, 'DataPathClocksStatus': 1, 'IsFIFOFlagsEnabled': 3, 'IsFIFOFlagsAsserted': 0}
Will wait  1.0  seconds for the data
nonzero words: 131065 of 131072
ADC data arrived ok
The number of nonzero words should be close to the number of data points, which is $2^{17}$. As you can see, "Dither: 0" at the top means dithering is disabled, and "Dither: 1" means enabled. "FineMixerScale" is set to 0, and both report "ADC data arrived ok".

Analyze ADC data

Next analyze the data, by converting from 16-bit packed I and Q into individual arrays, applying a window and Fourier analyzing, and calculating noise, NSD, SNR, and ENOB. Here is the code. Most of the work is done by the

fft_process
and analyze_fft_noise helper functions.

"""
    Analyze!

    this next command has to do with the fact that writing INTO DDR from the FPGA
    does not go through the ARM chip, but directly to DDR through the HP ports in
    the PS.  but the ARM chip might still hold state lines for some of those memory
    locations you allocated (and would hold zeros).  calling invalidate() for the
    receive buffer marks them dead so the next numpy read refetches and doesn't use
    ARM cache.

    Note: the spectrum x-axis is the baseband offset from the NCO frequency 
    (±61.44 MHz for our 122.88 MSps I/Q rate), not the absolute RF frequency. 
    To plot absolute RF, add the NCO frequency to the axis (and mirror the sign 
    if the input is in the second Nyquist zone).
"""
#
# check if we got anything by counting the number of non-zero elements.  if it's 0,
# of much less than the number of data points received, then bail.
nonzero_off =  np.count_nonzero(recv_ADC_buffer_ditheroff)
print("Dither off run:")
if nonzero_off == 0:
    print("Dither off:nonzero words:", nonzero_off, "of", len(recv_ADC_buffer_ditheroff))
    raise SystemExit("Something happened for dither off run, no data arrived.  Bail!")
else:
    print("Number of non-zero words ",nonzero_off," so data is probably ok")
print("Dither on run:")
nonzero_on =  np.count_nonzero(recv_ADC_buffer_ditheron)
if nonzero_on == 0:
    print("Dither on:nonzero words:", nonzero_on, "of", len(recv_ADC_buffer_ditheron))
    raise SystemExit("Something happened for dither on run, no data arrived.  Bail!")
else:
    print("Number of non-zero words ",nonzero_on," so data is probably ok")
#
# now dig the dither on and off data out of the arrays
#
print("\nDither off data:")
i_rx_off = recv_ADC_buffer_ditheroff[0::2].astype(np.float64)
q_rx_off = recv_ADC_buffer_ditheroff[1::2].astype(np.float64)
iq_data_off = i_rx_off - 1j * q_rx_off
iq_off_max = abs(iq_data_off).max()
iq_off_min = abs(iq_data_off).min()
print(f"  iq_off min {iq_off_min:.1f}  iq_off_max {iq_off_max:.1f} counts w/full scale 32767")
iq_off_scale = 0.5*(iq_off_max + iq_off_min)
#
# 16 bit data is 16 bits with the upper 14 bits being from the ADC, in 2's complement, so
# full scale is 2^16 or +- 2^15
#
iq_off_attenuation = iq_off_scale/32767.0
iq_off_attenuation_db = 20*math.log10(iq_off_attenuation)
print(f"  attenuation {iq_off_attenuation:.3f} = {iq_off_attenuation_db:.1f} dB")

print("\nDither on data:")
i_rx_on = recv_ADC_buffer_ditheron[0::2].astype(np.float64)
q_rx_on = recv_ADC_buffer_ditheron[1::2].astype(np.float64)
iq_data_on = i_rx_on - 1j * q_rx_on
iq_on_max = abs(iq_data_on).max()
iq_on_min = abs(iq_data_on).min()
print(f"  iq_on min {iq_on_min:.1f}  iq_on_max {iq_on_max:.1f} counts w/full scale 32767")
iq_on_scale = 0.5*(iq_on_max + iq_on_min)
iq_on_attenuation = iq_on_scale/32767.0
iq_on_attenuation_db = 20*math.log10(iq_on_attenuation)
print(f"  attenuation {iq_on_attenuation:.3f} = {iq_on_attenuation_db:.1f} dB")
#
# type out some useful information
#
f_fabric = rfdc_adc_sampling / rfdc_adc_decimation           # MHz
N_off = len(iq_data_off)
N_on = len(iq_data_on)
print("Array length:  off = ",N_off,"  on = ",N_on)
rbw_hz_off = f_fabric * 1e6 / N_off
rbw_hz_on = f_fabric * 1e6 / N_on
print("\nDither off:")
print(f"Captured {N_off} pairs at {f_fabric} MHz -> {N_off/f_fabric:.1f} us, "f"RBW = {rbw_hz_off:.1f} Hz")
#
# process both dither on and off data.  fft_process does the FFT and analyze_fft_noise digs out
# NSD and other useful quantities
#

do_window = True
fft_db_off = fft_process(iq_data_off,do_window)
freqs_off = np.fft.fftshift(np.fft.fftfreq(N_off, d=1.0 / f_fabric))   # MHz, offset from NCO
results_off = analyze_fft_noise(fft_db_off,iq_off_scale,1.0E6*rfdc_adc_sampling,rfdc_adc_decimation, 32767,
                               enbw=1.5 if do_window else 1.0)
results_off_noise_floor = results_off['noise_floor_per_bin_dbc']
print(adc_report(results_off, "Dither off"))
n = len(fft_db_off)
fs_out = 1.0E6*f_fabric
for k, db in results_off["spurs"][:10]:
    f_mhz = (k - n // 2) * fs_out / n / 1e6     # offset from NCO
    print(f"{f_mhz:+10.4f} MHz   {db:7.2f} dBc")
enob_off = results_off['enob_intrinsic']



print("\nDither on:")
print(f"Captured {N_on} pairs at {f_fabric} MHz -> {N_on/f_fabric:.1f} us, "f"RBW = {rbw_hz_on:.1f} Hz")

fft_db_on = fft_process(iq_data_on,do_window)
freqs_on = np.fft.fftshift(np.fft.fftfreq(N_on, d=1.0 / f_fabric))   # MHz, offset from NCO
results_on = analyze_fft_noise(fft_db_on,iq_on_scale,1.0E6*rfdc_adc_sampling,rfdc_adc_decimation, 32767,
                               enbw=1.5 if do_window else 1.0)
print(adc_report(results_on, "Dither on"))
results_on_noise_floor = results_on['noise_floor_per_bin_dbc']
n = len(fft_db_on)
fs_out = 1.0E6*f_fabric
for k, db in results_on["spurs"][:10]:
    f_mhz = (k - n // 2) * fs_out / n / 1e6     # offset from NCO
    print(f"{f_mhz:+10.4f} MHz   {db:7.2f} dBc")
enob_on = results_on['enob_intrinsic']

# 
# make the plots
#
noise_indices = np.where(results_off["noise_mask"])[0]
fig_off = go.Figure()

# 1. Full FFT Spectrum (Gray line)
fig_off.add_trace(
    go.Scatter(
        y=fft_db_off,
        mode="lines",
        name="FFT Spectrum",
        line=dict(color="gray", width=1),
        opacity=0.5
    )
)

# 2. Isolated Noise Bins (Blue dots)
fig_off.add_trace(
    go.Scatter(
        x=noise_indices,
        y=fft_db_off[results_off["noise_mask"]],
        mode="markers",
        name="Noise Bins Used",
        marker=dict(color="#1f77b4", size=3)
    )
)

# 3. Horizontal line for average noise floor
fig_off.add_hline(
    y=results_off["noise_floor_per_bin_dbc"],
    line_dash="dash",
    line_color="red",
    annotation_text=f"Avg Noise Floor ({results_off['noise_floor_per_bin_dbc']:.1f} dBc)",
    annotation_position="bottom right"
)

# 4. Layout styling
fig_off.update_layout(
    title="FFT Spectrum & Automated Noise Floor Masking",
    xaxis_title="FFT Bin Index",
    yaxis_title="Power (dBc)",
    template="plotly_white",
    hovermode="x unified",
    height=500
)

fig_off.show()
fig0 = go.Figure()
fig0.add_trace(go.Scatter(x=freqs_off, y=fft_db_off, mode="lines",name="Dither off"))
fig0.update_layout(title=f"Dither off f_mod {1.0e-6 * f:.1f}MHz, f_NCO {adc_carrier:.1f}MHz, ENOB {enob_off:.1f}")
fig0.update_xaxes(title_text="Offset from NCO (MHz)")
floor = np.floor((fft_db_off.min() - 5) / 10) * 10        # from the data, not -100
fig0.update_yaxes(title_text="Normalized Amplitude (dBc)",range=[floor, 5])
fig0.add_hline(y=results_off_noise_floor,line_dash="dash",line_color="firebrick",line_width=1.5,
    annotation_text=f"noise floor {results_off_noise_floor:.1f} dBc/bin",
    annotation_position="top left",annotation_yshift=10)
fig0.show()

fig1 = go.Figure()
fig1.add_trace(go.Scatter(x=freqs_on, y=fft_db_on, mode="lines",name="Dither on"))
fig1.update_layout(title=f"Dither on f_mod {1.0e-6 * f:.1f}MHz, f_NCO {adc_carrier:.1f}MHz, ENOB {enob_on:.1f}")
fig1.update_xaxes(title_text="Offset from NCO (MHz)")
floor = np.floor((fft_db_on.min() - 5) / 10) * 10        # from the data, not -100
fig1.update_yaxes(title_text="Normalized Amplitude (dBc)",range=[floor, 5])
fig1.add_hline(y=results_on_noise_floor,line_dash="dash",line_color="firebrick",line_width=1.5,
    annotation_text=f"noise floor {results_on_noise_floor:.1f} dBc/bin",
    annotation_position="top left",annotation_yshift=10)
fig1.show()

Here is the printed output, chopped into blocks since there is a lot of it since both dither off and dither on are being analyzed. The first part just gives you the non-zero words, and checks that it's close to the total number expected. If so then "data is probably ok". If the number of non-zero words is 0, then it will let you know and bail. After that it tells you the min and max, which should be pretty close, and using full scale of $\pm 32767$ it tells you the attenuation, which should be around -17.7 dB.

Dither off run:
Number of non-zero words  131060  so data is probably ok
Dither on run:
Number of non-zero words  131065  so data is probably ok

Dither off data:
  iq_off min 4357.5  iq_off_max 4358.0 counts w/full scale 32767
  attenuation 0.133 = -17.5 dB

Dither on data:
  iq_on min 4356.0  iq_on_max 4355.1 counts w/full scale 32767
  attenuation 0.133 = -17.5 dB
Array length:  off =  65536   on =  65536

Next, the data with dither off is analyzed. It reports the number of pairs (I and Q) captured, the frequency, and the resulting frequency bin width RBW. You can see here that $\Delta f=1875$Hz as expected. Then the RF-ADC noise analysis. The noise floor is calculated (see below) by averaging over all of the cells in the normalized Fourier spectrum, which was made with a Hann window. The average noise floor is $-107.98$ dBc relative to the peak bin, and $-109.74$ dBc relative to the carrier's true power. The 1.76 dB difference is the ENBW correction: the Hann window spreads the carrier's power across a main lobe about 1.5 bins wide, so the single peak bin under-represents the carrier by that factor. The noise needs no such correction, white noise is flat per bin with or without the window.

The noise spectral density is calculated, and this is the main result that tells you about the hardware. You can compare to the documentation DS926, the table in the "Integrated RF-ADC Block" in the "RF_ADC Performance Characteristics" section, labeled "RF-ADC Dual ADC Tile Performance Characteristics for ZU4xDR", which says that the NSD is measured to be 154 dB, ~5dB higher than we are getting here. But we don't exactly know how they made their measurement, and 5 dB is easy to get by not having the right full scale, or the windowing correction, etc.

Dither off:
Captured 65536 pairs at 122.88 MHz -> 533.3 us, RBW = 1875.0 Hz
====================================================================
  RF-ADC noise analysis  --  Dither off
====================================================================

  Capture
    Bin width           1875.0 Hz
    Bins used as noise  54653   (83.39% of spectrum)

  Signal
    Level               -18.16 dBFS      (18.2 dB of range unused)

  Noise floor per bin
    vs peak bin         -106.79 dBc      <- matches the fft_db plot scale
    vs carrier power    -108.55 dBc      <- ENBW folded in

  Noise spectral density
    NSD                 -159.44 dBFS/Hz
      The converter's own figure. Independent of drive level,
      FFT length, decimation and window. Compare against the
      DS926 table at your input frequency and DSA setting.

  Effective bits
    As measured           9.74 bits   (SNR 60.38 dB)
      your drive level, your decimated band
    At full scale        12.76 bits
      full scale, your decimated band
    Converter intrinsic  10.59 bits
      full scale, converter's full Nyquist zone

    +3.02 bits from driving to full scale  (18.16 dB)
    -2.16 bits from widening to full Nyquist  (13.01 dB of decimation gain given back)

    The intrinsic figure assumes the noise density measured in
    your decimated band holds across the full Nyquist zone. The
    DDC discarded the evidence, so this data cannot check it.

Next comes the dither on measurement. As you can see below, the NSD with dithering on changes by 0.79dB, which is probably a very small price to pay for getting rid of all those spurs, which you can see in the plots that follow.

Dither on:
Captured 65536 pairs at 122.88 MHz -> 533.3 us, RBW = 1875.0 Hz
====================================================================
  RF-ADC noise analysis  --  Dither on
====================================================================

  Capture
    Bin width           1875.0 Hz
    Bins used as noise  54812   (83.64% of spectrum)

  Signal
    Level               -18.16 dBFS      (18.2 dB of range unused)

  Noise floor per bin
    vs peak bin         -106.03 dBc      <- matches the fft_db plot scale
    vs carrier power    -107.79 dBc      <- ENBW folded in

  Noise spectral density
    NSD                 -158.68 dBFS/Hz
      The converter's own figure. Independent of drive level,
      FFT length, decimation and window. Compare against the
      DS926 table at your input frequency and DSA setting.

  Effective bits
    As measured           9.61 bits   (SNR 59.62 dB)
      your drive level, your decimated band
    At full scale        12.63 bits
      full scale, your decimated band
    Converter intrinsic  10.47 bits
      full scale, converter's full Nyquist zone

    +3.02 bits from driving to full scale  (18.16 dB)
    -2.16 bits from widening to full Nyquist  (13.01 dB of decimation gain given back)

    The intrinsic figure assumes the noise density measured in
    your decimated band holds across the full Nyquist zone. The
    DDC discarded the evidence, so this data cannot check it.
  

Here are the ensuing Fourier spectra for both dither on and off for 100, 250, 500, and 1000 MHz carriers. Data for 100MHz looks great with Dither on, less so at 250MHz, and by 500MHz and 1GHz we start to see very large spurs with or without dithering. (In the table of plots below, click on any plot to see an enlarged version for detail.)

 Dither OffDither On
100 MHz Thumbnail Thumbnail
250 MHz Thumbnail Thumbnail
500 MHz Thumbnail Thumbnail
1000 MHz Thumbnail Thumbnail
Enlarged view
  Dither Off Dither On On-Off
  Noise/bin
(dBc)
NSD
(dBFS/Hz)
ENOB Noise/bin
(dBc)
NSD
(dBFS/Hz)
ENOB Noise/bin
(dBc)
NSD
(dBFS/Hz)
ENOB
100 MHz -107.6 -161.3 10.9 -106.8 -158.7 10.5 0.8 2.6 0.4
250 MHz -108.6 -160.4 10.8 -107.9 -159.4 10.6 0.7 1.0 0.2
500 MHz -108.2 -160.0 10.7 -107.3 -159.0 10.5 0.9 1.0 0.2
1000 MHz -106.4 -159.8 10.7 -105.6 -158.3 10.4 0.8 1.5 0.3

To investigate these large signals, here is a blowup of the FFT spectrum for the 1000 MHz carrier:

You can see the peak at 5 MHz, at 0 dB by construction, and the peak to the left is at -38.92125 MHz and 20.3 dB down.

What we are probably seeing is the following: we are running the DAC at 6.88128 GSps, and at 1GHz carrier with a 5MHz modulation. That means that the DAC is sampling a 1005 MHz tone with a 6881.28 MHz clock, which means that we should see 1005 MHz, 6881.28±1.005 MHz, and so forth. So the main tone will be 1005 MHz but there should be an image at 5876.28 MHz due to the fact that we are sampling a smooth wave with a "staircase" at a higher frequency. The sinc filter on the DAC output is meant to even out frequencies in the first Nyquist zone, and so we would expect the sinc will reduce this image by $20\log_{10}[sinc(5876.28/6881.28)]=-15dB. That's the output of the DAC, and then there's the cable and other loses and that's consistent with the 20.3 dB measurement in the plot.

The ADC now samples at 4915.2 MHz, so it can only represent frequencies up to half that, or 2457.6 MHz unambiguously, and anything about that folds. The difference between these frequencies is 5876.28-4915.2=961.08 MHz, and that gets mixed with the DAC's 1000 MHz carrier in the mixer to give -38.92 MHz. It matches exactly.

I took the output of the DAC and then put it into a Keysight SA that has a higher bandwidth and I see the image tone at 5876.16 MHz with a resolution of 0.3 MHz, so it's definitely a real signal. Dithering does not clean it up, indicating it's a real signal. I did a test where I halved the amplitude of the waveform going into the DAC, but the image persisted because both the carrier and the copy shrank the same amount, telling me that it's not some kind of weird thing with the ADC.

Even if you run the DAC and ADC with the same rates, you will still see these images, although they will be vastly reduced since most of them will fall right on the signal. Bottom line: to really clean things up, the DAC output of the RFSoC board needs a low pass filter to get rid of some of these images, but it won't get rid of all of them because some of them are born inside the ADC system, downstream of the analog input. And we are seeing them because the noise floor of this system is so low, around -100 dBc per bin, so a spur at -80 dB will stick out. For inputs with a higher level of noise per bin, the spur will be buried if the noise is high enough, but averaging will bring them out. However for ADMX, where the real signal will sit in the 0-100kHz range above the cavity frequency, there is almost no chance one of these spurs will be a problem, and if it does, you will know because it will never change with the change in cavity resonance frequency.

Noise Spectral Density ($NSD$)

$NSD$ is noise power per unit bandwidth, and measures the power of a noise signal distributed across a 1-Hz bandwidth. To measure $NSD$, we start with the power spectrum from the Fourier transform. The coefficients $X_i$ are indexed by frequency bin, and we sum their squares over the $M$ cells that lie in the noise floor, excluding the carrier, any spurs, the DC bin, and the band edges where the DDC decimation filter rolls off. Dividing by $M$ gives the average noise power per bin: $$\bar{P}_{noise} = \frac{1}{M}\sum_{i=1}^M X_i^2\nonumber$$

For the signal reference we use the largest coefficient, $X_{peak}$, since that is what the FFT is normalized to. $$P_{signal} = X_{peak}^2\nonumber$$

The measured ratio is then $$ R = \frac{ \bar{P}_{noise} } { P_{signal} } = \frac{1}{M} \sum_{i=1}^M \Big( \frac{X_i}{X_{peak}} \Big)^2\nonumber$$

What we want instead is the noise referred to the ADC's full scale, per unit bandwidth, called the "noise spectral density", or $NSD$. We get there by inserting the carrier signal as an intermediate reference and letting it cancel: $$NSD_{linear} = \frac{1}{\Delta f}\, \frac{\bar{P}_{noise}}{P_{signal}}\, \frac{P_{signal}}{P_{fullscale}}\nonumber$$

where $\Delta f = f_s/(D\times N)$ is the frequency bin width, $f_s$ the ADC sampling rate, $D$ the decimation, and $N$ the number of points transformed.

The second factor comes from the waveform, not the spectrum. The data are bipolar 16-bit, so full scale in amplitude is $2^{15}$ and $$\frac{P_{signal}}{P_{fullscale}} = \frac{A_{peak}^2}{V_{fs}^2} = \frac{A_{peak}^2}{2^{30}}\nonumber$$ with $A_{peak} = (\max - \min)/2$ over the waveform array.

The first factor is where the window enters. A Hanning window spreads the carrier across roughly $s = 1.5$ bins, so the single peak bin holds only $1/s$ of the carrier's true power: $$P_{signal} = s\,X_{peak}^2\nonumber$$

Our measured ratio $R$ therefore divides by a signal power that is too small by $s$, making $R$ too large by the same factor. The correction is to divide by $s$: $$\frac{\bar{P}_{noise}}{P_{signal}} = \frac{R}{s}\nonumber$$

Collecting terms, $$NSD_{linear} = \frac{1}{\Delta f}\cdot\frac{R}{s} \cdot\frac{A_{peak}^2}{2^{30}}\nonumber$$

Defining $$\overline{dB}_{noise} \equiv 10\log R,\qquad dB_{FS} \equiv 10\log\frac{A_{peak}^2}{2^{30}},\qquad ENBW \equiv 10\log s = 1.76\ \mathrm{dB}\nonumber$$

gives $$NSD = \overline{dB}_{noise} + dB_{FS} - ENBW - 10\log\Delta f\nonumber$$

For this measurement: $-107.10 - 17.75 - 1.76 - 32.73 = -159.34$ dBFS/Hz.

Note that $ENBW$ is a ratio of powers, so it enters as $10\log s$, never $20\log s$. It appears only because we referenced the noise to the peak bin. Had we instead normalized to the integrated power of the whole carrier lobe, $\sum_{lobe}X_j^2$, the factor would cancel identically and the term would vanish. That alternative is more robust in practice: when the tone does not fall exactly on a bin center, scalloping reduces $X_{peak}$ by up to 1.42 dB while the lobe sum is unchanged, and no fixed $ENBW$ can correct for it.

Signal to noise (SNR) and Effective number of bits (ENOB)

The $NSD$ is the noise per full scale per frequency: $$NSD_{linear} = \frac{1}{\Delta f}\frac{ \bar{P}_{noise} }{P_{fullscale}}\nonumber$$

To get the total noise you have to integrate this over the full Nyquist band, which is $f_s/2$. $$\frac{P_{noise,total}}{P_{fullscale}} = NSD_{linear}\cdot\frac{f_s}{2}\nonumber$$ The $SNR$ is the signal to noise power ratio, and if we assume the signal is full scale, we get $$SNR = \frac{P_{signal}}{P_{noise,total}} = \frac{1}{NSD_{linear}\cdot\frac{f_s}{2}}\nonumber$$

Convert to decibels to get $$SNR = -10\log{NSD_{linear}} - 10\log{f_s/2} = -NSD - 10\log{f_s/2}\nonumber$$

Noise due to quantization is easy to calculate (see here) and is given by $SNR = 6.02N + 1.76$ for a full scale sine wave where $SNR$ means signal to noise due to quantization. We therefore turn that around and calculate $N_{eff} = (SNR-1.76)/6.02$ to get the effective number of bits (ENOB) for a measured SNR. For the data above at 500MHz, we have that the intrinsic ENOB goes from 10.69 to 10.56 bits as you turn dithering on, which is a small price to pay if dithering eliminates spurs. For the 100MHz data not shown, the intrinsic ENOB goes from 10.61 to 10.47, and it eliminates many spurs!

Peak Width

Next we should measure the width of the peak. To do this, we will need to take as much data as we can to get the FFT resolution down. That maximum is $2^{24}-1=16.8M$ data points. Given the ADC sampling of 4.9152GSps, a decimation of 40, and 16.8M samples, one would expect the FFT bin resolution to be $\Delta f=4.9152x10^9/(40\times [2^{24}-1])=7.3$Hz. When we take that much data with dithering off, we get a peak width of 15.71Hz, and with dithering on, 15.92Hz. So dithering costs 0.21Hz which is not measurable. Why the width is 2x $\Delta f$ is due to Hann windowing in the FFT. If we turn windowing off, then spectral leakage would increase the width as well. Here's the plot:

Back to top