oversample(): a running sum of OSR samples,
i.e. a boxcar, i.e. sinc. It is the cheapest low-pass there is. B bits
means 2B levels; the script's adc() counts
fractional bits and gives 2B+1+1, one bit more than it says.
Quantization noise power is set by the LSB alone: Δ2/12, whatever the sample rate. Spread that fixed power over a wider band and there is less of it in any given slice of spectrum. That is the whole idea of oversampling: run the converter faster than you need, then throw the part of the band you do not need away.
Doubling the sample rate halves the noise density, so filtering back down to the original band buys 3 dB, or half a bit. The SNR-vs-OSR panel plots that directly: measured in-band SNR against OSR, with the ideal 3 dB per octave line for comparison. Ten bits of ADC plus an OSR of 4 gets you about the same SNR as eleven bits at Nyquist.
The filter here is the crudest possible: oversample() adds up
OSR consecutive samples. That is a boxcar in time, so a sinc in frequency,
with zeros at every multiple of fs/OSR — count the notches
in the filtered spectrum. It is a bad low-pass filter, and it still works, which tells you how much slack oversampling gives you.
#- Oversample
OSR = 4
def oversample(x,OSR):
N = len(x)
y = np.zeros(N)
for n in range(0,N):
for k in range(0,OSR):
m = n+k
if(m < N):
y[n] += x[m]
return y
y_on = oversample(y_sn,OSR)
Source: ex/osr.py. The running sum is not decimated afterwards, so the plots stay on the same frequency axis and the filter shape is easy to see.