# Baseband signal upconversion and IQ Modulation and Demodulation¶

In this article, we will go through the basic steps of the up- and downconversion of a baseband signal to the passband signal. In most digital signal processing devices, any signal processing is performed in the baseband, i.e. where the signals are centered around the DC frequency. These baseband signals are mainly complex-valued. However, only real-valued signals can be sent with real-world physical devices. The process of upconversion thus has two purposes:

1. Convert the complex-valued baseband signal to a real-valued signal which can be transmitted over an antenna, cable or similar.
2. Adapt the transmit signal such that it uses a specific frequency band of the physical channel. This way, multiple signals can be transmitted independently on different frequency bands.

At the receiver, the received signal is then downconverted to baseband such that the subsequent processing can be done in complex-valued baseband domain.

A generic digital transceiver system with digital baseband processing, analog to digital conversion and up- and downconversion is presented in the figure below:




In the following steps, we will go through the components and numerically illustrate, what happens within these blocks. Let us first define some variables and constants:

Fs = int(6e4)    # the sampling frequency we use for the discrete simulation of analog signals

fc = int(3e3)    # 3kHz carrier frequency
Ts = 1e-3        # 1 ms symbol spacing, i.e. the baseband samples are Ts seconds apart.
BN = 1/(2*Ts )   # the Nyquist bandwidth of the baseband signal.

ups = int(Ts*Fs) # number of samples per symbol in the "analog" domain
N = 10           # number of transmitted baseband samples


Let us further define our transmit filter $g(t)$. In our case, we use a root-raised cosine (RRC) filter with rolloff 1. Feel free to change the filter or rolloff for your own experiments. Since the normal RRC filter is infinitely long in time domain, we need to truncate it to a length of $2t_0$ and make it causal by shifting it by $t_0$ to the right:

# the RRC filter should span 3 baseband samples to the left and to the right.
# Hence, it introduces a delay of 3Ts seconds.
t0 = 3*Ts

# Calculate the filter coefficients (N=number of samples in filter)
_, rrc = commpy.filters.rrcosfilter(N=int(2*t0*Fs), alpha=1,Ts=Ts, Fs=Fs)
t_rrc = np.arange(len(rrc)) / Fs  # the time points that correspond to the filter values
plt.plot(t_rrc/Ts, rrc)


## The Transmitter (Steps T1-T6)¶

In step T1) we generate some random baseband data. The baseband of the digital transmission system can be complex-valued, so we create complex baseband samples $d[n]$.

# Step T1)
constellation = np.array([1+1j, 1-1j, -1+1j, -1-1j])  # the possible values in the baseband
dk = np.random.choice(constellation, size=(N))        # randomly choose some samples
t_symbols = Ts * np.arange(N)                         # time instants of the baseband samples

# Plot the samples
plt.subplot(121)
plt.stem(t_symbols/Ts, dk.real);
plt.subplot(122)
plt.stem(t_symbols/Ts, dk.imag);


The next step T2) in the signal processing chain is the weighting of a Dirac comb function with the baseband samples, yielding the signal $x(t)$, given by $$x(t)=\sum_{k=-\infty}^{\infty}d[k]\delta(t-kT_s).$$ We emulate this by creating a sequence of zeros $x$, where at each time for a new baseband sample, this sample is written into the sequence:

# Step T2)
x = np.zeros(ups*N, dtype='complex')
x[::ups] = dk  # every ups samples, the value of dn is inserted into the sequence
t_x = np.arange(len(x))/Fs

plt.figure(figsize=(8,3))
plt.subplot(121)
plt.plot(t_x/Ts, x.real);
plt.subplot(122)
plt.plot(t_x/Ts, x.imag);


In step T3), the weighted Dirac comb function is filtered with the pulse shaping filter $g(t)$. The outcome is the baseband signal $u(t)$, given by $$u(t)=g(t)*x(t)=\sum_{k=-\infty}^{\infty}d[k]g(t-kT_s).$$

In the code below, we calculate the filtering by using np.convolve. For the plotting of the signal, we also plot the corresponding weighted Dirac comb in the figures. However, since the filtering introduces a delay of $t_0$ seconds, we also delay $x(t)$ by $t_0$ to match it to the transmitted signal:

# Step T3)
u = np.convolve(x, rrc)

t_u = np.arange(len(u))/Fs

plt.subplot(121)
plt.plot((t_x+t0)/Ts, x.real, label='$x(t)$') # artificial extra delay for the baseband samples
plt.plot(t_u/Ts, u.real, label='$u(t)$')
plt.subplot(122)
plt.plot((t_x+t0)/Ts, x.imag)
plt.plot(t_u/Ts, u.imag)


As we see, the baseband signal roughly matches the transmitted baseband samples. Since we use an RRC filter, the signal is not ISI-free and hence the baseband signal does not exactly go through the values of the baseband samples. We will use a matched filter at the receiver to get the correct samples out of this signal.

In the next step T4), the complex baseband signal $u(t)$ is split into real and imaginary part. The real and imaginary part are also named in-phase (I) and quadratur (Q) components.

# Step T4)
i = u.real
q = u.imag


The interesting part of up-conversion happens in step T5). Here, the real and imaginary part of the signal are multiplied by cosine and sine, respectively:

\begin{align} i_{up}(t)&=i(t)\cos(2\pi f_c t)\\ q_{up}(t)&=-q(t)\sin(2\pi f_c t) \end{align}.
# Step T5)
iup = i * np.cos(2*np.pi*t_u*fc)
qup = q * -np.sin(2*np.pi*t_u*fc)


Let us see how these signal look like, and also what we can learn from their spectrum, i.e. Fourier transform:

# define a function to calculate the spectrum of a signal
fftLen = 4*len(u)  # perform 4-times zeropadding to get smoother spectrum
spectrum = lambda x: np.fft.fftshift(np.fft.fft(x, fftLen)) / Fs * (len(u))

# Calculate the spectrum of the signals
f_u = np.linspace(-Fs/2, Fs/2, fftLen)
I = spectrum(i); Iup = spectrum(iup)
Q = spectrum(q); Qup = spectrum(qup)

# Plot the time-domain signals
plt.subplot(221)
plt.plot(t_u/Ts, iup, label='$i_{up}(t)$')
plt.plot(t_u/Ts, i, 'r', label='$i(t)$')

plt.subplot(222)
plt.plot(t_u/Ts, qup, label='$q_{up}(t)$')
plt.plot(t_u/Ts, q, 'r', label='$q(t)$')

plt.subplot(223)
plt.plot(f_u, abs(I), 'r')
plt.plot(f_u, abs(Iup), 'b')

plt.subplot(224)
plt.plot(f_u, abs(Q), 'r')
plt.plot(f_u, abs(Qup), 'b')


We can make several observations from these graphs: The blue curves are the upconverted version of the red curves. As shown, their envelope in time domain is given by the red curve. In the spectrum, we see that the blue curves are copies of the red curve, but shifted to the carrier frequency. In particular, note that all spectrums are symmetric. This is clear, since both $i(t)$ and $q(t)$ are purely real functions, which have a symmetric spectrum.

Eventually, in step T6) the I- and Q-path of the signal are summed together to get $s(t)=i_{up}(t)+q_{up}(t)$. Then, $s(t)$ is sent to the antenna.

# Step T6)
s = iup + qup


Let us have a look at the time domain signal $s(t)$ and its spectrum $S(f)$. In addition, let us compare $S(f)$ against the baseband spectrum $U(f)$:

S = spectrum(s)
U = spectrum(u)

plt.subplot(121)
plt.plot(t_u/Ts, s)

plt.subplot(122)
plt.plot(f_u, abs(U), 'r', label='$|U(f)|$')
plt.plot(f_u, abs(S), 'b', label='$|S(f)|$')


From the time-domain signal we cannot really see anything. But, looking at the spectrum we can see the following:

• The blue spectrum $S(f)$ is symmetric (to $f=0$). Hence, it corresponds to a real signal. This is clear, since the signal which is sent to the antenna must be real-valued.
• The red spectrum $U(f)$ is not symmetric, since it corresponds to a complex-valued baseband signal. However, note that the right half of $S(f)$ equals $U(f)$ (up to a scaling factor). The left half of $S(f)$ equals $U(-f)$, i.e. the mirrored baseband spectrum. By this trick, the complex-valued baseband signal $u(t)$ is converted to a real-valued bandpass signal $s(t)$ which carries the same information.

At the receiver, in step R1) the signal $s(t)$ is first multiplied by a sine and a cosine to get a down-converted I and Q component, given by \begin{align} i_{down}(t) &= s(t)\cos(2\pi f_c t)\\ q_{down}(t) &= -s(t)\sin(2\pi f_c t) \end{align}.

# Step R1)
idown = s * np.cos(2*np.pi*-fc*t_u)
qdown = s * -np.sin(2*np.pi*fc*t_u)


Let us again look at the spectrum of both downconverted signals:

Idown = spectrum(idown)
Qdown = spectrum(qdown)

plt.subplot(121)
plt.plot(f_u, Idown.real, label=r'$\Re\{I_(f)\}$', color='r')
plt.plot(f_u, S.real, label='$\Re\{S(f)\}$', color='b')

plt.subplot(122)
plt.plot(f_u, Qdown.real, label=r'$\Re\{Q_(f)\}$', color='r')
plt.plot(f_u, S.imag, label=r'$\Im\{S(f)\}$', color='b')


First of all, we see that the downconverted signal has componenents around $f=0$ and some images around $f=2f_c$. Without going deeply into the maths, we can intuitively explain this: The blue signal $s(t)$ is multiplied by a cosine in time domain. This operation equals a convolution in the frequency domain, and the frequency domain expression of a cosine is given by

$$\mathcal{F}\{\cos(2\pi f_c t)\}=\frac{1}{2}(\delta(f-f_c)+\delta(f+f_c)).$$

Hence, we can find the following relation: \begin{align} i_{down}(t)&=s(t)\cos(2\pi f_c t)\\ I_{down}(f)&=S(f)*\frac{1}{2}(\delta(f-f_c)+\delta(f+f_c))=\frac{1}{2}(S(f-f_c)+S(f+f_c)). \end{align}

This means, the red spectrum is the sum of shifting the blue spectrum by $f_c$ to the right to the left. Since $S(f)$ is concentrated around $f=\pm f_c$, we first get a component around $f=0$, but also images at $f=2f_c$.

Since we are only interested in the central part of the signal, the images at $2f_c$ need to be eliminated. To this end, we apply a low-pass filter, which is called the image rejection filter by obvious reasons.

Let us design such a filter. Normally, the cutoff should be closely chosen to the bandwidth of the following AD converter in order to eliminate as much noise as possible. However, here we take a more pragmatic approach and design a filter that just rejects the images at $2f_c$. Also note that the filter, to be causal, necessarily introduces some extra delay $\tau_{LP}$ to the signal.

cutoff = 5*BN        # arbitrary design parameters
lowpass_order = 51
lowpass_delay = (lowpass_order // 2)/Fs  # a lowpass of order N delays the signal by N/2 samples (see plot)
# design the filter
lowpass = scipy.signal.firwin(lowpass_order, cutoff/(Fs/2))

# calculate frequency response of filter
t_lp = np.arange(len(lowpass))/Fs
f_lp = np.linspace(-Fs/2, Fs/2, 2048, endpoint=False)
H = np.fft.fftshift(np.fft.fft(lowpass, 2048))

plt.subplot(121)
plt.plot(t_lp/Ts, lowpass)
plt.gca().annotate(r'$\tau_{LP}$', xy=(lowpass_delay/Ts,0.08), xytext=(lowpass_delay/Ts+0.3, 0.08), arrowprops=dict(arrowstyle='->'))

plt.subplot(122)
plt.plot(f_lp, 20*np.log10(abs(H)))


As we see, the filter will introduce a delay of $\tau_{LP}$ due to its maximum at this time. Furthermore, according to the frequency response, it should reliably remove the images at $2f_c$.

So, in step R2) the images that stem from the downconversion are filtered out by means of the lowpass image rejection filter: \begin{align} i_{down,lp}(t)&=i_{down}(t)*LP(t)\\ q_{down,lp}(t)&=q_{down}(t)*LP(t)\\ \end{align}.

# Step R2)
idown_lp = scipy.signal.lfilter(lowpass, 1, idown)
qdown_lp = scipy.signal.lfilter(lowpass, 1, qdown)


Let us again have a look at the spectrum of these filtered signals:

Idown_lp = spectrum(idown_lp)
Qdown_lp = spectrum(qdown_lp)

plt.subplot(121)
plt.plot(f_u, abs(Idown), 'r', lw=2, label=r'$|I_{down}(f)|$')
plt.plot(f_u, abs(Idown_lp), 'g-', label=r'$|I_{down,lp}(f)|$')

plt.subplot(122)
plt.plot(f_u, abs(Qdown), 'r', lw=2, label=r'$|Q_{down}(f)|$')
plt.plot(f_u, abs(Qdown_lp), 'g', label=r'$|Q_{down,lp}(f)|$')


As we can see, the image rejection filter has successfully removed the images at $2f_c$ from the signal, since the green curves only have components around $f=0$. Now, we can go ahead and combine I- and Q components to a complex baseband signal in step R3): $$v(t)=i_{down,lp}(t)+jq_{down,lp}(t).$$

# Step R3)
v = idown_lp + 1j*qdown_lp


In step R4), we perform matched filtering to the transmitter filter $g(t)$. This way the RRC at the transmitter and receiver combine to a RC filter, fulfills the 1st Nyquist criterion and hence yields ISI-free baseband samples, when sampled at the correct sampling position. $$y(t)=v(t)*g(t)$$

# Step R4)
y = np.convolve(v, rrc) / (sum(rrc**2)) * 2


Here, we introduce some energy normalization to get the correct amplitude of the signal at the receiver. Eventually, in step R5), we sample the analog baseband signal to get the actual baseband samples that were transmitted over the channel. Here, we need to take care of the overall transmission delay of the chain, which consists of

• $t_0$ delay at the transmitter due to filtering with $g(t)$ in the DA conversion,
• $\tau_{LP}$ delay at the receiver due to the image rejection filter,
• $t_0$ delay at the receiver due to matched filtering with $g(t)$ in the AD conversion.

Hence, the overall delay is given by $2t_0+\tau_{LP}$. So, to get the baseband samples, we sample the received baseband signal at the following positions:

$$\hat{d}[n] = v(nT_s+2t_0+\tau_{LP}).$$
# Step R5)
delay = int((2*t0 + lowpass_delay)*Fs)


Let us finally have a look at the resulting baseband signal and the sampled version of it. We can also compare the received values with the transmitted to make sure, our transmission did not introduce any errors.

t_y = np.arange(len(y))/Fs
t_samples = t_y[delay::ups]
y_samples = y[delay::ups]

plt.subplot(221)
plt.plot(t_y/Ts, y.real)
plt.stem(t_samples/Ts, y_samples.real)

plt.subplot(222)
plt.plot(t_y/Ts, y.imag)
plt.stem(t_samples/Ts, y_samples.imag)

plt.subplot(223)
plt.stem(t_symbols/Ts, dk.real);
plt.subplot(224)
plt.stem(t_symbols/Ts, dk.imag);


As shown, the received samples match the transmitted samples, however a significant delay due to the intermediate filters was introduced.