Skip to main content

Notebook example

I/Q Quadrature Sampling

Simulate mixing, filtering, and receiver imbalance.

Read the explanations, code, and available results below. Code does not run on this page.

Download notebook

This notebook preserves the code and 13 plots from the original I/Q webpage.

Run this notebook yourself

Use Python 3, Jupyter, NumPy, and Matplotlib.

Download the notebook and open it in Jupyter. Review its code and data requirements before running the cells.

Source and revision history

Sections in this example

Saved outputs are shown below.

IQ Mixing - Quadrature Signals

%matplotlib inline
import numpy as np
from matplotlib.pyplot import *

We'll simulate I/Q sampling at 200 MHz with a 1.4 GHz LO. Input signals are at 1.42 and 1.38 GHz. An Airspy is the same, only sampling at 5 MHz.

# Setup constants
Nx = 65536  #Large number of samples
Fs = 200e6  #Sampling frequency
Flo = 1.4e9 #Frequency 'tuned' to.
F1 = 1.42e9 # Input tone we're trying to listen to
F2 = 1.38e9 # other tone we're trying to listen to
fs_rf = 12e9 # Frequency running simulation at.
freq = np.fft.fftfreq(Nx,1.0/(fs_rf/1e6))
signal1 = np.cos(2.0*np.pi * F1/fs_rf * np.arange(Nx))# + 0.03)  # make the 1.42MHz tone
signal2 = np.cos(2.0*np.pi * F2/fs_rf * np.arange(Nx))# + 0.05 ) # make the 1.38MHz tone
signal_loI = np.cos(2.0*np.pi * Flo/fs_rf * np.arange(Nx) ) #make the signal from the receiver to 'tune'
signal_loQ = np.sin(2.0*np.pi * Flo/fs_rf * np.arange(Nx) ) #make the signal from the receiver to 'tune'
#Plot the 2 incoming tones.
figure()
plot(signal1[:50])
plot(signal2[:50])
First 50 samples of the 1.42 GHz (blue) and 1.38 GHz (green) input tones: two near-identical cosines of amplitude 1 that slowly drift apart in phase
[<matplotlib.lines.Line2D at 0x10cdf4828>]
# Put the input signals into the Receiver for Mixing, where multiply by a cosine and a sine
mixed1I = signal1 * signal_loI
mixed1Q = signal1 * signal_loQ
mixed2I = signal2 * signal_loI
mixed2Q = signal2 * signal_loQ

Trig product to sum rules:

$$ cos(a) cos(b) = cos(a+b) + cos(a-b) $$$$ cos(a) sin(b) = sin(a+b) - sin(a-b) $$
# Observer that we now have both the 'slow' 20 MHz (difference) and fast ~2.8GHz signals
figure()
plot(mixed1I[:500])
plot(mixed1Q[:500])
plot(mixed2I[:500])
plot(mixed2Q[:500])
First 500 samples of the four mixer outputs (I and Q for each tone): a dense 2.8 GHz oscillation riding inside a slow 20 MHz envelope
[<matplotlib.lines.Line2D at 0x105c62208>]
#Observe just the "I" output has 2 positive and 2 negative tones visible(low freq hard to see there are 2)
#after mixing the 'zero' is relative to the "LO" or receiver tuned frequency.
figure()
plot(freq, np.abs(np.fft.fft(mixed1I)))
Spectrum of the I mixer output, -6000 to 6000 MHz: sum tones near -2800 and +2800 MHz and the -20 and +20 MHz difference tones merged into one line at 0
[<matplotlib.lines.Line2D at 0x10fcda080>]
# Hard fourier cutoff lowpass filter.  This is the same as a 'rectangular' window in fourier space.
# This would usually be an analog filter of some kind in the receiver.
figure()
N_cutoff = int(Fs/fs_rf*Nx)
signals = [mixed1I, mixed1Q, mixed2I, mixed2Q]
filtered_signals = []

#Now can observe there are only the 'low frequency' positive and negative 20MHz tones

for signal in signals:
    fsignal = np.fft.fft(signal)
    fsignal[N_cutoff:-N_cutoff] = 0.0
    plot(freq, abs(fsignal))
    filtered_signals.append(np.fft.ifft(fsignal))
    
xlim(-200,200)
Spectra of the four mixer outputs after a hard low-pass cutoff, -200 to 200 MHz: only two sharp peaks remain, at -20 and +20 MHz; all four traces overlap
(-200, 200)
# Observer the timeseries again.  Now only have the 'slow' frequencies
# Notice however there are now 2 totally in phase, and 2 180 out of phase.  
figure()
for signal in filtered_signals:
    plot(signal[500:1000])
Samples 500 to 1000 of the four filtered signals: smooth 20 MHz sine waves of amplitude 0.5; the I traces coincide, the Q traces are mirror images
/Users/kbandura/anaconda/envs/py3/lib/python3.5/site-packages/numpy/core/numeric.py:482: ComplexWarning: Casting complex values to real discards the imaginary part
  return array(a, dtype, copy=False, order=order)
# "I" part is exactly the same for both the 1.38 and 1.42GHz signals
figure()
plot(filtered_signals[0][500:1000])
plot(filtered_signals[2][500:1000])
Samples 500 to 1000 of the filtered I outputs for the 1.38 and 1.42 GHz tones: the two traces lie exactly on top of each other as one sine wave
/Users/kbandura/anaconda/envs/py3/lib/python3.5/site-packages/numpy/core/numeric.py:482: ComplexWarning: Casting complex values to real discards the imaginary part
  return array(a, dtype, copy=False, order=order)
[<matplotlib.lines.Line2D at 0x110b023c8>]
# "Q" part is exactly 180 out of phase for the 1.38 and 1.42GHz signals
figure()
plot(filtered_signals[1][500:1000])
plot(filtered_signals[3][500:1000])
Samples 500 to 1000 of the filtered Q outputs for the 1.38 and 1.42 GHz tones: two sine waves that are exact mirror images, 180 degrees out of phase
/Users/kbandura/anaconda/envs/py3/lib/python3.5/site-packages/numpy/core/numeric.py:482: ComplexWarning: Casting complex values to real discards the imaginary part
  return array(a, dtype, copy=False, order=order)
[<matplotlib.lines.Line2D at 0x110b4b438>]
# Make the 'complex' signal I - j*Q.  
#
s1 = filtered_signals[0] - 1.0j*filtered_signals[1]
s2 = filtered_signals[2] - 1.0j*filtered_signals[3]
# Now can just take the fourier transform of the IQ quadrature signal we've just created and recover the 'original' 
# spectrum shifted down by 1.4GHz.
# 0 Frequency corresponds to 1.4GHz.  -20MHz is 1.38GHz, 20MHz is 1.42GHz.
figure()
freq = np.fft.fftfreq(Nx,1.0/(fs_rf/1e6))
slices = np.concatenate((np.arange(1092), np.arange(Nx-1092,Nx,1)))
print(slices)
plot(freq[slices],20*np.log10(abs(np.fft.fft(s1))[slices]))
#Can recover just the +20MHz signal
Spectrum in dB of the complex I minus jQ signal for the 1.42 GHz tone, -200 to 200 MHz: a single sharp peak at +20 MHz near 90 dB and nothing at -20 MHz
[    0     1     2 ..., 65533 65534 65535]
[<matplotlib.lines.Line2D at 0x111629048>]
#Can recover just the -20MHz signal.
figure()
plot(freq[slices],20*np.log10(abs(np.fft.fft(s2))[slices]))
Spectrum in dB of the complex I minus jQ signal for the 1.38 GHz tone, -200 to 200 MHz: a single sharp peak at -20 MHz near 90 dB and nothing at +20 MHz
[<matplotlib.lines.Line2D at 0x111db3dd8>]

I Q imbalance

#Of course the I and Q pass through different analog filters/amplifiers/mixers and ADC
# The I and Q signal will be off by a bit.  

#Let's simulate that.
imbalanced = [0.99*signal1 * signal_loI, signal1 * signal_loQ, 0.99*signal2 * signal_loI, signal2 * signal_loQ]
#Filter and plot the unbalanced signals.  Not obviously different...
figure()
filtered_signals_imbalanced = []
for signal in imbalanced:
    fsignal = np.fft.fft(signal)
    fsignal[N_cutoff:-N_cutoff] = 0.0
    plot(freq, abs(fsignal))
    filtered_signals_imbalanced.append(np.fft.ifft(fsignal))
xlim(-200,200)
Spectra of the four imbalanced mixer outputs after low-pass filtering, -200 to 200 MHz: peaks at -20 and +20 MHz, no different from the balanced case
(-200, 200)
# Also un-noticeable in the time domain.
figure()
for signal in filtered_signals_imbalanced:
    plot(signal[500:1000])
Samples 500 to 1000 of the four imbalanced filtered signals: 20 MHz sine waves of amplitude about 0.5 that look identical to the balanced case
/Users/kbandura/anaconda/envs/py3/lib/python3.5/site-packages/numpy/core/numeric.py:482: ComplexWarning: Casting complex values to real discards the imaginary part
  return array(a, dtype, copy=False, order=order)
# Create 'complex' IQ sampling output again.  Rember this is what an SDR dongle
#gives as an output for processing by GNU Radio.
s1im = filtered_signals_imbalanced[0] - 1.0j*filtered_signals_imbalanced[1]
s2im = filtered_signals_imbalanced[2] - 1.0j*filtered_signals_imbalanced[3]
# Now what should be a pure tone at 20MHz also has a small one at -20MHz.  This is non-ideal when trying to measure a pure signal.  
figure()
plot(freq[slices],20*np.log10(abs(np.fft.fft(s1im))[slices]))
Spectrum in dB of the imbalanced complex signal for the 1.42 GHz tone: the main peak at +20 MHz near 90 dB plus a small image spur at -20 MHz near 44 dB
[<matplotlib.lines.Line2D at 0x112e60ba8>]
#Same for the -20MHz.  
figure()
plot(freq[slices],20*np.log10(abs(np.fft.fft(s2im))[slices]))
Spectrum in dB of the imbalanced complex signal for the 1.38 GHz tone: the main peak at -20 MHz near 90 dB plus a small image spur at +20 MHz near 44 dB
[<matplotlib.lines.Line2D at 0x112ebf2b0>]