Effective Number of Bits (ENOB)
Contents
What is Effective Number of Bits (ENOB)?
All data converters have noise from the quantization of the signal: that’s the fact that every input is mapped to the closest single output level (no matter how fine), and the difference between that and the true value of the input is considered noise (this is the Error trace in the graph below).
This noise is (approximately) uniformly distributed over -1/2 to +1/2 LSB, and this noise is (approximately) uniformly distributed over the bandwidth of the ADC. The RMS value of this noise for an ideal ADC is $\frac{1}{\sqrt{12}}$ LSBs.
So, what is the Effective Number of Bits of an ADC? It is the bit width of a ideal ADC whose quantization noise alone has the same RMS value as the total noise of your real ADC.
For example, if you had a 12 bit ADC with an ENOB of 6 bits, that means that your ADC has the same noise level as an ideal 6 bit ADC.
Note, however, that this is only about noise. Having an ENOB of 6 bits does not mean your ADC is equivalent to an ideal 6 bit ADC - linearity errors in particular are not considered by the ENOB.
How do we measure it?
Measuring the ENOB is deceptively simple. We measure a perfect full-scale sine wave with our ADC, subtract the sine wave, and measure the RMS value of the resulting noise. Then we determine the bit width of an ideal ADC with the same RMS noise: this is our ENOB.
Note: This is the same as Total Harmonic Distortion and Noise (THD+N). However, THD+N is usually specified over a smaller frequency range than the full bandwidth of the ADC.
How perfect does perfect need to be?
We want the noise contribution of our sine wave to be a small fraction of our ADC’s quantization noise. We can calculate the ideal quantization noise of the Raspberry Pi Pico ADC as $\frac{1/ \sqrt{12}}{2^{12}} = 0.007\%$. And the sum of two (uncorrelated) noise sources is
\[\text{Noise}_\text{Total} = \sqrt{\text{Noise}_1^2 + \text{Noise}_2^2}\]which means we need to feed in a sine wave that has $1/3$ the noise to get less than a 5% contribution. However, this is a tall order, requiring a THD of better than -90dBc. But that can be relaxed - the specified ENOB of the Pico is only 9.5 Bits (9 Bits minimum), that means we only need to keep our source’s noise below $\left(\frac{1}{3}\right)\frac{1/ \sqrt{12}}{2^9} = 0.02\%$ (-75dBc THD+N) to accurately measure the Pico’s ADC.
Test Methodology
A 1kHz sine wave was generated with a SG505 function generator. It has a THD+N of -102 dB more than enough.
The following code was used to sample the ADC at it’s full rate then dump the samples out the USB serial interface:
// Code modified from https://github.com/raspberrypi/pico-examples/blob/master/adc/adc_console/adc_console.c
#include <stdio.h>
#include <inttypes.h>
#include "pico/stdlib.h"
#include "hardware/gpio.h"
#include "hardware/adc.h"
#include "hardware/dma.h"
#define N_SAMPLES 100000
uint16_t sample_buf[N_SAMPLES];
int main() {
stdio_init_all();
// DMA setup:
uint dma_chan = dma_claim_unused_channel(true);
dma_channel_config cfg = dma_channel_get_default_config(dma_chan);
channel_config_set_transfer_data_size(&cfg, DMA_SIZE_16);
channel_config_set_read_increment(&cfg, false);
channel_config_set_write_increment(&cfg, true);
channel_config_set_dreq(&cfg, DREQ_ADC);
adc_gpio_init(26);
adc_init();
adc_select_input(0);
adc_set_temp_sensor_enabled(false);
adc_fifo_setup(true, true, 1, false, false);
// Set SMPS_MODE Pin
gpio_init(23);
gpio_set_dir(23, GPIO_OUT);
gpio_put(23, 0); // 0 for PSM; 1 for PWM
// Capture samples then ship them out over USB
while (1) {
adc_fifo_drain();
dma_channel_configure(dma_chan, &cfg, sample_buf, &adc_hw->fifo, N_SAMPLES,true);
adc_run(true);
dma_channel_wait_for_finish_blocking(dma_chan);
adc_run(false);
printf("Done\n");
for (int i = 0; i < N_SAMPLES; i = i + 1) {
printf("%03x\n", sample_buf[i]);
}
}
return 0;
}
Note: This code uses DMA to sample the ADC at the full 500 kSps, then print out the sample buffer over the serial port; it’s based on the ADC Console and ADC DMA samples from the Pico SDK.
Analysis
The data was captured to a file, then a sine wave was fitted to the data, and subtracted from the data, and finally the RMS value of the difference was calculated.
Python Collection and Analysis code
import sys
import matplotlib.pyplot as plt
from tqdm import trange, tqdm
from scipy import optimize
import numpy as np
import serial
import time
N_SAMPLES = 100000
F_S = 500e3
# Required to avoid missing samples
# From: https://github.com/pyserial/pyserial/issues/216#issuecomment-369414522
class ReadLine:
def __init__(self, s):
self.buf = bytearray()
self.s = s
def readline(self):
i = self.buf.find(b"\n")
if i >= 0:
r = self.buf[: i + 1]
self.buf = self.buf[i + 1 :]
return r
while True:
i = max(1, min(2048, self.s.in_waiting))
data = self.s.read(i)
i = data.find(b"\n")
if i >= 0:
r = self.buf + data[: i + 1]
self.buf[0:] = data[i + 1 :]
return r
else:
self.buf.extend(data)
def get_pico_data(pico_com):
pbar = tqdm(desc="Waiting for start of collection", total=N_SAMPLES)
while True:
pbar.update(1)
if "Done" in str(pico_com.readline()):
break
pbar.close()
vals = list()
for _ in trange(N_SAMPLES, desc="Collecting waveform data"):
val = None
while val is None:
line = pico_com.readline().strip()
if len(line) > 1:
val = int(line, 16)
vals.append(val)
# Clip first vals as the Pico clears buffers and gets into steady state
return np.asarray(vals[10000:])
def sin(x, amplitude, freq, phase, offset):
return amplitude * np.sin(freq * 2 * np.pi * x + phase) + offset
def fit_sine_wave(vals, times, freq=1000):
fit_params, _ = optimize.curve_fit(
sin, times, vals, p0=[(np.max(vals) - np.min(vals)) / 2, freq, 0, np.mean(vals)]
)
return fit_params
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: adc-coll.py <COM Port>")
exit()
pico = ReadLine(serial.Serial(sys.argv[1]))
vals = get_pico_data(pico)
times = np.linspace(0, (1 / F_S) * len(vals), num=len(vals))
amplitude, freq, phase, offset = fit_sine_wave(vals, times)
print(
f"Fitted values: amp:{amplitude:.3f}LSBs, freq:{freq:.3f}Hz, {phase:.3f}sec,"
f" {offset:.3f}LSBs"
)
plt.title("Collected ADC Data")
plt.plot(
times, sin(times, amplitude, freq, phase, offset), label="Best fit sine wave"
)
plt.plot(times, vals, label="Recorded Data")
# Show only a few cycles
plt.xlim(1 / (250), 2 / (250))
plt.xlabel("Time (s)")
plt.ylabel("LSBs")
plt.legend(loc="upper right")
plt.show()
plt.title("Residual ADC Noise")
plt.plot(times, vals - sin(times, amplitude, freq, phase, offset))
plt.xlabel("Time (s)")
plt.ylabel("LSBs")
plt.show()
# RMS noise as RMS(collected - fitted)
rms_noise = np.sqrt(
np.mean((vals - sin(times, amplitude, freq, phase, offset)) ** 2)
)
ENOB = (1 / np.sqrt(12)) / (rms_noise / 2 ** 12)
print(f"RMS Noise: {rms_noise:.3f}LSBs, ENOB: {ENOB:.3f} Bits")
Collected Waveform
Typical Waveform: