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.

Quant-Noise Plot

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:

Residual Noise

ENOB Calculation

The RMS noise in the collected data is 2.4389LSBs. We can calculate the ENOB as:

\[\text{Noise}_\text{RMS} = \frac{2.4389}{2^{12}} = 0.0595 \%\] \[\text{ENOB} = \log_2 \left( \frac{1/ \sqrt{12}} {\text{Noise}_\text{RMS} } \right)\] \[\text{ENOB} = \log_2\left(\frac{1/\sqrt{12}}{0.0595 \%}\right) = \boxed{8.92 \text{ Bits}}\]

PWM Mode

I repeated the collection with the 3.3V regulator in PWM Mode, and this is the result:

\[\text{ENOB} = \boxed{8.84 \text{ Bits}}\]

References