Phase-locked loops

import numpy as np
import matplotlib.pyplot as plt

import sdr

%config InlineBackend.print_figure_kwargs = {"facecolor" : "w"}
# %matplotlib widget
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]

Design a proportional-plus-integrator (PPI) loop filter

A 2nd order, proportional-plus-integrator loop filter has the following configuration.

           +----+
       +-->| K1 |-------------------+
       |   +----+                   |
x[n] --+                            @--> y[n]
       |   +----+                   |
       +-->| K2 |--@-------------+--+
           +----+  ^             |
                   |  +------+   |
                   +--| z^-1 |<--+
                      +------+
x[n] = Input signal
y[n] = Output signal
K1 = Proportional gain
K2 = Integral gain
z^-1 = Unit delay
@ = Adder

The transfer function of the loop filter is

\[H(z) = K_1 + K_2 \frac{ 1 }{ 1 - z^{-1}} = \frac{(K_1 + K_2) - K_1 z^{-1}}{1 - z^{-1}} .\]

These loop filters are implemented in sdr.LoopFilter.

lf = sdr.LoopFilter(0.05, 1)
print(lf)
<sdr.LoopFilter object at 0x00000183D472A188>
plt.figure(figsize=(10, 5))
lf.iir.plot_frequency_response_log(decades=6)
plt.show()
../../_images/e6305fed49000dba9e995cf8cb6140cefa7288527d7790e426a420a6acd8930b.png

Implement a PLL in the phase domain

This section implements Example C.2.1 from Digital Communications: A Discrete-Time Approach.

N = 75  # samples
theta_0 = 2 * np.pi / 10  # radians/sample
x = theta_0 * np.arange(N) + np.pi  # Input phase signal, radians
y = np.zeros(x.size + 1)  # Output phase signal, radians
phase_error = np.zeros(x.size)  # Measured phase error, radians
freq_estimate = np.zeros(x.size)  # Estimated frequency, radians/sample

# Create a proportional-plus-integrator (PPI) loop filter with a normalized
# noise bandwidth of 0.05 and a damping factor of 1 (critically damped)
lf = sdr.LoopFilter(0.05, 1)

# Create a numerically controlled oscillator (NCO) with NCO gain of 1
# and a constant phase accumulation of theta_0 radians/sample
nco = sdr.NCO(1, theta_0)

for i in range(N):
    # Phase error detector (PED)
    phase_error[i] = x[i] - y[i]

    # Compute the frequency estimate
    freq_estimate[i] = lf.filter(phase_error[i])

    # Process the variable phase increment through the NCO
    y[i + 1] = nco.process(freq_estimate[i])
plt.figure(figsize=(10, 6))

plt.subplot(2, 1, 1)
plt.plot(x, label="Input phase")
plt.plot(y, label="Output phase")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Sample")
plt.ylabel("Phase (radians)")
plt.title("Input and output phase signals")

plt.subplot(2, 1, 2)
plt.plot(phase_error)
plt.grid(which="both", linestyle="--")
plt.xlabel("Sample")
plt.ylabel("Phase (radians)")
plt.title("Phase error between input and output phase signals")

plt.suptitle("Figure C.2.5 from Digital Communications: A Discrete-Time Approach")
plt.tight_layout()
plt.show()
../../_images/6bc4bb14a51c79193ccf60f838bf7c2b48482498a8fca2a649828be8d176ec36.png

Implement a PLL in the time domain

N = 75  # samples
theta_0 = 2 * np.pi / 10  # radians/sample
x = np.exp(1j * (theta_0 * np.arange(N) + np.pi))  # Input signal
y = np.ones(x.size + 1, dtype=np.complex64)  # Output signal
phase_error = np.zeros(x.size)
freq_estimate = np.zeros(x.size)

# Create a proportional-plus-integrator (PPI) loop filter with a normalized
# noise bandwidth of 0.05 and a damping factor of 1 (critically damped)
lf = sdr.LoopFilter(0.05, 1)

# Create a direct digital synthesizer (DDS) with NCO gain of 1
# and a constant phase accumulation of theta_0 radians/sample
dds = sdr.DDS(1, theta_0)

for i in range(N):
    # Phase error detector (PED)
    phase_error[i] = np.angle(x[i] * y[i].conj())

    # Compute the frequency estimate
    freq_estimate[i] = lf.filter(phase_error[i])

    # Process the variable phase increment through the DDS
    y[i + 1] = dds.process(freq_estimate[i])
plt.figure(figsize=(10, 8))

plt.subplot(3, 1, 1)
plt.plot(x.real, label="Input")
plt.plot(y.real, label="Output")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Sample")
plt.ylabel("Amplitude")
plt.title("Input and output signals (real part only)")

plt.subplot(3, 1, 2)
plt.plot(phase_error)
plt.grid(which="both", linestyle="--")
plt.xlabel("Sample")
plt.ylabel("Phase (radians)")
plt.title("Phase error between input and output phase signals")

plt.subplot(3, 1, 3)
plt.plot(np.unwrap(np.angle(x)), label="Input")
plt.plot(np.unwrap(np.angle(y)), label="Output")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Sample")
plt.ylabel("Phase (radians)")
plt.title("Input and output signals (phase)")

plt.suptitle("Figure C.2.5 from Digital Communications: A Discrete-Time Approach")
plt.tight_layout()
plt.show()
../../_images/b5be9a9e51f269d8a6faf75034b77594b6c97d5ef9383c998e95d49beb4abbf7.png

Analyze PLL closed-loop performance

A closed-loop PLL has the following configuration.

             bb[n]
        +---+    +-----+    +----+
x[n] -->| X |--->| PED |--->| LF |---+
        +---+    +-----+    +----+   |
          ^                          |
          |  +---------+   +-----+   |
   lo[n]  +--| e^(-j.) |<--| NCO |<--+
             +---------+   +-----+

x[n] = Input signal
lo[n] = Local oscillator signal
bb[n] = Baseband signal
PED = Phase error detector
LF = Loop filter
NCO = Numerically-controlled oscillator

The closed-loop transfer function of the PLL is

\[ H_{PLL}(z) = \frac{K_p K_0 (K_1 + K_2) z^{-1} - K_p K_0 K_1 z^{-2}} {1 - 2 (1 - \frac{1}{2} K_p K_0 (K_1 + K_2) z^{-1} + (1 - K_p K_0 K_1) z^{-2} } . \]

The analysis of the performance of this closed-loop system is available in sdr.ClosedLoopPLL.

Compare step and frequency response across \(\zeta\)

plt.figure(figsize=(10, 5))
for zeta in [1 / 2, 1 / np.sqrt(2), 1, 2]:
    pll = sdr.ClosedLoopPLL(0.01, zeta)
    h = pll.iir.step_response(500)
    plt.plot(h, label=rf"$\zeta = {zeta}$")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Sample")
plt.ylabel("Amplitude")
plt.title("Step response of the closed-loop PLL across damping factor")
plt.show()
C:\Users\matth\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0\LocalCache\local-packages\Python37\site-packages\scipy\signal\filter_design.py:1632: BadCoefficients: Badly conditioned filter coefficients (numerator): the results may be meaningless
  "results may be meaningless", BadCoefficients)
../../_images/f1d4a1601d7d4b1b10a3457f7b928cdca8096421fe008db7fb2b9b3ed926a7e0.png
plt.figure(figsize=(10, 5))
for zeta in [1 / 2, 1 / np.sqrt(2), 1, 2]:
    pll = sdr.ClosedLoopPLL(0.01, zeta)
    omega, H = pll.iir.frequency_response_log(sample_rate=2 * np.pi)
    omega /= pll.omega_n
    plt.semilogx(omega, 20 * np.log10(np.abs(H)), label=rf"$\zeta = {zeta}$")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlim([10**-2, 10**2])
plt.ylim([-25, 5])
plt.xlabel(r"Normalized Frequency ($\omega / \omega_n$)")
plt.ylabel("Power (dB)")
plt.title("Frequency response of the closed-loop PLL across damping factor")
plt.show()
../../_images/aab60544e35daa4edf58e89381210e9deacb8293fe2e82106b17d3899b759367.png

Compare step and frequency response across \(B_n T\)

plt.figure(figsize=(10, 5))
for BnT in [0.001, 0.005, 0.01, 0.05, 0.1]:
    pll = sdr.ClosedLoopPLL(BnT, 1)
    h = pll.iir.step_response(500)
    plt.plot(h, label=f"$B_nT = {BnT}$")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Sample")
plt.ylabel("Amplitude")
plt.title("Step response of the closed-loop PLL across normalized noise bandwidth")
plt.show()
../../_images/8cf5ebdad449e29ab0d597c596e20bf6c3330b20650fce5f17aa32a72d7c9b89.png
plt.figure(figsize=(10, 5))
for BnT in [0.001, 0.005, 0.01, 0.05, 0.1]:
    pll = sdr.ClosedLoopPLL(BnT, 1)
    f, H = pll.iir.frequency_response_log(sample_rate=1)
    plt.semilogx(f, 20 * np.log10(np.abs(H)), label=f"$B_nT = {BnT}$")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.ylim([-25, 5])
plt.xlabel("Normalized Frequency ($f / f_s$)")
plt.ylabel("Power (dB)")
plt.title("Frequency response of the closed-loop PLL across normalized noise bandwidth")
plt.show()
../../_images/bbfb7e46114337998326f404a5c3c6e7ccfa56733e39ed6a5b108caa47735e0a.png

Compare lock time across \(B_n T\)

freq = np.arange(0, 0.0005, 0.00001)

plt.figure(figsize=(10, 5))
for BnT in [0.01, 0.0125, 0.015, 0.0175, 0.02]:
    pll = sdr.ClosedLoopPLL(BnT, 1)
    t_lock = pll.lock_time(freq)
    plt.plot(freq, t_lock, label=f"$B_nT = {BnT}$")
plt.grid(which="both", linestyle="--")
plt.legend()
plt.xlabel("Normalized frequency offset ($f / f_s$)")
plt.ylabel("Lock time (samples)")
plt.title("Lock time of the closed-loop PLL across input signal frequency offset")
plt.show()
../../_images/160c6c713ea1d50b54e5ecc94e19ee429786c9d3c408dce3b095618e5e3b70bd.png

Last update: Jul 09, 2023