EPFL Logo

École Polytechnique Fédérale de Lausanne
EE-426: Radio Frequency Circuits Design Techniques


Lab 1: BPSK Modulation and RLC Filterings

¶

Filippo Quadri
filippo.quadri@epfl.ch


Welcome to the first lab of the EE-426 course!¶

In this session, we'll explore the architecture of a basic digital communication system, focusing on two key components:

  1. A BPSK (Binary Phase Shift Keying) modulation/demodulation system.
  2. A simple RLC analog filter, modeled first in Python, then simulated using Cadence.

The goal of this lab is to build intuition about how digital information can be transmitted over analog channels and how basic filtering plays a role in signal conditioning.

Lab Outline¶

Part 1: BPSK Modulation System¶

  • Understand BPSK and its role in digital communication.
  • Implement a BPSK transmitter in Python.
  • Visualize the modulated signal and bitstream.
  • Implement a BPSK receiver in Python.

Part 2: RLC Low-Pass Filter¶

  • Pen&Paper analysis of the analog filter
  • Use Python to size components of a basic RLC filter.
  • Analyze its frequency response.
  • Simulate the same circuit in Cadence to verify its behavior (next session)

Let's get started!

Part 1: BPSK Modulation System¶

Before we dive into the implementation, let’s take a moment to understand what we’re going to build.

Digital Communication Basics¶

Even though we often prefer to treat information digitally (as bits), in the real world, communication usually happens over analog, continuous-time channels — such as air, cables, or optical fibers. To send digital data through these analog media, we need to convert it into a form that can be transmitted effectively.

This typically involves four major steps:

  1. Baseband Conversion:
    First, we map the digital information (a sequence of 0s and 1s) into a baseband analog signal — a signal that varies smoothly in time and carries our digital data.

  2. Modulation:
    We then modulate a carrier wave — typically a high-frequency sinusoidal signal — to carry the baseband signal. This process translates our signal to higher frequencies, suitable for efficient transmission.

  3. Transmission:
    Finally, the modulated signal is sent through a physical medium (like air using an antenna, or through a wired channel).

  4. Reception, Demodulation: The ultimate goal of a communication system is to transmit data accurately from one point to another. To achieve this, a receiver must be equipped with proper demodulation logic to recover the original signal. Additionally, the receiver may apply error correction techniques to improve the reliability and integrity of the received data.

Why Do We Use a Carrier?¶

The reasons for using a high-frequency carrier signal go beyond this lab's scope, but here's a quick overview of the main motivations:

  • Efficient Radiation:
    Antennas are more efficient at radiating energy at higher frequencies. For example, a 1 kHz baseband signal can't be transmitted effectively over the air, but a 1 GHz carrier wave can.

  • Multiplexing:
    Modulating signals onto different carrier frequencies allows multiple users or systems to share the same medium without interfering with each other. This is how FM radio stations or cellular networks work (each station uses a different frequency band).

  • Filtering and Noise Reduction:
    Moving the signal to a higher frequency band makes it easier to filter and isolate from low-frequency noise or interference (e.g. flicker noise, DC noise, ...)

  • Bandwidth Utilization:
    High-frequency bands often provide wider bandwidths, allowing for higher data rates.

What is BPSK?¶

BPSK (Binary Phase Shift Keying) is one of the simplest forms of digital modulation. In BPSK, each bit of a binary message (either 0 or 1) is represented by a phase shift of the carrier signal, defined as follow:

  • Bit 1 → carrier phase = 0°
  • Bit 0 → carrier phase = 180°

This means:

  • A 1 is transmitted as: $+A\cos(2\pi f_ct)$
  • A 0 is transmitted as: $-A\cos(2\pi f_ct)$

Where:

  • $A$ is the amplitude of the carrier signal.
  • $f_c$ is the carrier frequency.
  • $t$ is time.

Why Use BPSK?¶

  • Simplicity: Easy to implement in both hardware and software.
  • Efficiency: BPSK is robust against noise compared to some other modulation schemes.
  • Binary Logic: Perfectly matches digital (0/1) systems.

Let's start building the system!

Note 1: a real system is much more complex compared to the one we're building (e.g. pilot signal, ...), but still it will give a general overview on the architecture of the system.

Note 2. Although BPSK (Binary Phase Shift Keying) belongs to the PSK family — which is fundamentally based on phase modulation — it can also be interpreted as a special case of amplitude modulation. This is because BPSK effectively transmits data by inverting the sign of the carrier wave (i.e., toggling between +A and −A), which can be seen as an amplitude change between two discrete levels, even though the amplitude magnitude remains constant.

Code¶

After covering some basic theory on digital communication, we're ready to start building our system. All the necessary setup — including library installation, imports, utility functions for converting strings to bitstreams (and back), as well as other tools (filters, visualizations, ...) — has been provided.

In [1]:
%pip install numpy matplotlib scikit-learn seaborn scipy schemdraw --quiet

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import schemdraw
import schemdraw.elements as elm
from typing import Tuple, List
Note: you may need to restart the kernel to use updated packages.

String/Bitstream Convertion Functions

In [2]:
def string2bitstream(s: str) -> np.ndarray:
    """
    Converts a string into a bitstream represented as a NumPy array of 0s and 1s.

    Each character in the input string is encoded using its 8-bit ASCII binary representation.
    The result is a flat array containing the entire bitstream.

    Parameters
    ----------
    s : str
        Input string to be converted.

    Returns
    -------
    np.ndarray
        1D NumPy array of integers (0s and 1s), representing the bitstream of the input string.
    """
    return np.array([int(bit) for char in s for bit in f"{ord(char):08b}"])

def bitstream2string(b: np.ndarray) -> str:
    """
    Converts a bitstream represented as a NumPy array of 0s and 1s back into a string.

    The input bitstream is expected to be a flat array where each group of 8 bits corresponds
    to the ASCII binary representation of a character.

    Parameters
    ----------
    b : np.ndarray
        1D NumPy array of integers (0s and 1s), representing the bitstream.

    Returns
    -------
    str
        The decoded string from the input bitstream.
    """
    chars = [chr(int("".join(map(str, b[i:i+8])), 2)) for i in range(0, len(b), 8)]
    return "".join(chars)

Visualization Functions

In [3]:
def plot_waveform(
    signal: np.ndarray,
    bit_rate: float,
    sampling_freq: float,
    title: str = "Baseband BPSK Modulated Signal",
    figsize=(15, 4),
):
    """
    Plots a baseband BPSK signal using seaborn/matplotlib.

    Parameters
    ----------
    signal : np.ndarray
        The modulated baseband signal (1D array).
    bit_rate : float
        Bit rate in bits per second.
    sampling_freq : float
        Sampling frequency in Hz.
    title : str
        Plot title.
    figsize : tuple
        Size of the figure.
    """
    sns.set_theme(style="whitegrid")
    
    bit_duration = 1 / bit_rate
    total_time = len(signal) / sampling_freq
    t = np.linspace(0, total_time, len(signal))

    fig, ax = plt.subplots(figsize=figsize)
    ax.plot(t, signal, color=sns.color_palette("muted")[0], lw=2, label="BPSK signal")

    ax.set_title(title, fontsize=14)
    ax.set_xlabel("Time [s]")
    ax.set_ylabel("Amplitude")
    ax.set_xlim(0, total_time)
    sns.despine()
    plt.tight_layout()
    plt.show()

def plot_fft(
    signals: list,
    sampling_freq: float,
    labels: list = None,
    normalize: bool = True,
    xlim: tuple = None,
    title: str = "FFT Spectrum",
    figsize: tuple = (16, 8)
):
    """
    Plot the FFT magnitude spectrum of one or more signals.

    Parameters
    ----------
    signals : list of np.ndarray
        List of 1D signals (same length preferred).
    sampling_freq : float
        Sampling frequency in Hz.
    labels : list of str, optional
        Labels for each signal in the legend.
    window : str, optional
        Window function name (e.g., 'hamming', 'hann').
    normalize : bool
        If True, normalize magnitude to [0, 1].
    xlim : tuple, optional
        Limits for x-axis (e.g., (0, 5000)).
    title : str
        Title of the plot.
    figsize : tuple
        Size of the figure.
    """
    sns.set_theme(style="whitegrid")

    plt.figure(figsize=figsize)

    for i, sig in enumerate(signals):
        N = len(sig)

        fft_vals = np.fft.fft(sig)
        fft_freqs = np.fft.fftfreq(N, d=1/sampling_freq)

        # Keep only positive frequencies
        mask = fft_freqs >= 0
        fft_freqs = fft_freqs[mask]
        fft_mags = np.abs(fft_vals[mask])

        if normalize:
            fft_mags /= np.max(fft_mags)

        label = labels[i] if labels and i < len(labels) else f"Signal {i+1}"
        plt.plot(fft_freqs, fft_mags, label=label)

    plt.title(title, fontsize=14)
    plt.xlabel("Frequency [Hz]")
    plt.ylabel("Normalized Magnitude" if normalize else "Magnitude")
    if xlim:
        plt.xlim(xlim)
    plt.legend()
    plt.grid(True, linestyle='--', alpha=0.5)
    plt.tight_layout()
    plt.show()

I also provide you with the general structure of the BPSK modulator. In the following steps, you’ll complete each function to build a fully working modulation system.

For your reference, all key details are explained throughout the lab. However, each function also includes a docstring — a detailed comment at the beginning of the function — which gives you a quick intuition about how it works. When you use the function in your code, the docstring will automatically appear (e.g., when you hover or use autocomplete), providing you with everything you need to understand and implement it correctly.

In [4]:
from scipy.signal import convolve

class BPSKModulator:
    def __init__(self, bit_rate: float, carrier_freq: float, sample_rate: float):
        """
        Initializes the BPSKModulator with specified parameters.
        
        Parameters
        ----------
        bit_rate : float
            Bit rate of the input signal in bits per second.
        carrier_freq : float
            Frequency of the carrier signal in Hz.
        sample_rate : float
            Sampling rate for the modulation process in Hz.
        """
        self.bit_rate = bit_rate
        self.carrier_freq = carrier_freq
        self.sample_rate = sample_rate

    def _generate_baseband_waveform(self, bitstream: np.ndarray) -> np.ndarray:
        """
        Generates the baseband waveform for BPSK modulation.

        Each bit in the input bitstream is represented as a rectangular pulse:
        - A '1' bit is represented by a pulse of +1.
        - A '0' bit is represented by a pulse of -1.

        Parameters
        ----------
        bitstream : np.ndarray
            1D NumPy array of integers (0s and 1s), representing the bitstream.

        Returns
        -------
        np.ndarray
            Array containing the generated baseband waveform samples.
        """
        samples_per_bit = int(self.sample_rate / self.bit_rate)             # Hidden from students
        baseband_waveform = np.repeat(2 * bitstream - 1, samples_per_bit)   # Hidden from students
        return baseband_waveform

    def _generate_carrier(self, nb_samples: int) -> np.ndarray:
        """
        Generates a carrier wave for BPSK modulation.

        The carrier wave is a cosine wave with a frequency defined by carrier frequency (class variable).

        Parameters
        ----------
        nb_samples : int
            Number of samples to generate for the carrier wave.
        Returns
        -------
        np.ndarray
            Array containing the generated carrier wave samples.
        """
        t = np.arange(nb_samples) / self.sample_rate                        # Hidden from students
        carrier = np.cos(2 * np.pi * self.carrier_freq * t)                 # Hidden from students
        return carrier

    def _rrc_filter(
        self,
        signal: np.ndarray,
        alpha: float = 0.35,
        span: int = 6,
        sps: int = None
    ) -> np.ndarray:
        """
        Applies a Root Raised Cosine (RRC) filter to the input signal.
        
        Parameters
        ----------
        signal : np.ndarray
            The input signal to filter.
        alpha : float, optional
            Roll-off factor (0 < alpha ≤ 1). Default is 0.35.
        span : int, optional
            The number of symbols the filter spans. Default is 6.
        sps : int, optional
            Samples per symbol (default inferred from sample_rate and bit_rate).

        Returns
        -------
        np.ndarray
            The filtered signal.
        """
        if sps is None:
            sps = int(self.sample_rate / self.bit_rate)

        # Time vector for the filter taps
        t = np.arange(-span / 2, span / 2 + 1/sps, 1/sps)

        # Avoid division by zero
        t = np.where(t == 0, 1e-8, t)

        pi_t = np.pi * t
        four_alpha_t = 4 * alpha * t

        numerator = np.sin(pi_t * (1 - alpha)) + 4 * alpha * t * np.cos(pi_t * (1 + alpha))
        denominator = pi_t * (1 - (four_alpha_t) ** 2)
        h = numerator / denominator

        # Handle NaNs or division artifacts
        h = np.nan_to_num(h)

        # Normalize filter energy
        h /= np.sqrt(np.sum(h ** 2))

        # Filter the signal via convolution
        filtered_signal = convolve(signal, h, mode='same')

        return filtered_signal


    def modulate(self, bitstream: np.ndarray) -> np.ndarray:
        """
        Modulates the input bitstream using BPSK modulation.

        The modulation process involves generating a baseband waveform from the bitstream,
        creating a carrier wave, and then multiplying the baseband waveform with the carrier wave.
        A low-pass filter is applied to the modulated signal to smooth it.

        Parameters
        ----------
        bitstream : np.ndarray
            1D NumPy array of integers (0s and 1s), representing the bitstream to be modulated.

        Returns
        -------
        np.ndarray
            1D NumPy array representing the modulated BPSK signal.
        """        
        baseband_waveform = self._generate_baseband_waveform(bitstream)                         # Hidden from students
        filtered_baseband = self._rrc_filter(baseband_waveform)                                 # Hidden from students

        carrier = self._generate_carrier(len(filtered_baseband))                                # Hidden from students

        modulated_signal = filtered_baseband * carrier                                          # Hidden from students

        return modulated_signal
    
    def demodulate(self, received_signal: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
        """
        Demodulates the received BPSK signal to recover the original bitstream.

        The demodulation process involves multiplying the received signal with the carrier wave,
        applying a low-pass filter to extract the baseband signal, and then sampling the baseband
        signal to determine the transmitted bits.

        Parameters
        ----------
        received_signal : np.ndarray
            1D NumPy array representing the received BPSK signal.

        Returns
        -------
        Tuple[np.ndarray, np.ndarray]
            A tuple containing:
            - 1D NumPy array representing the recovered bitstream (0s and 1s).
            - 1D NumPy array representing the mixed signal before filtering.
        """
        
        carrier = self._generate_carrier(len(received_signal))              # Hidden from students

        mixed_signal = received_signal * carrier                            # Hidden from students
        filtered_signal = self._rrc_filter(mixed_signal)                    # Hidden from students

        samples_per_bit = int(self.sample_rate / self.bit_rate)             # Hidden from students
        nb_samples = len(received_signal)                                   # Hidden from students
        recovered_bitstream = np.zeros(nb_samples // samples_per_bit)       # Hidden from students
        
        # Hidden from students
        for i in range(nb_samples // samples_per_bit):
            bit_chunk = filtered_signal[i * samples_per_bit : (i + 1) * samples_per_bit]
            recovered_bitstream[i] = 1 if np.sum(bit_chunk) > 0 else 0
        
        return recovered_bitstream.astype(int), mixed_signal

Step 1: Generate the Baseband Waveform¶

The first step is to convert the bitstream into a baseband signal. A baseband signal is one whose frequency content is centered around DC (zero frequency). It typically contains low-frequency components and is symmetric around zero if we consider negative frequencies — but that's a detail more relevant to signal processing experts.

Our objective here is to map the binary sequence of 0s and 1s into a sequence of -1s and +1s.

Question: Write the mathematical function to convert the bitstream (bs[t]) to the BPSK Baseband Waveform (bb[t])

Your Answer: $$ bb[t] = 2 \cdot bs[t] - 1 $$

Use this function to complete the _generate_baseband_waveform method. If your implementation is correct, the next cell should execute without errors.


💡 Tip 1:
In the BPSKModulator class, several variables are initialized in the __init__ method (the class constructor). You can access these from any method within the class using self.variable_name.
💡 Tip 2:
Python is beginner-friendly but relatively slow since it's interpreted. To improve performance, it's recommended to use optimized libraries like NumPy, which are built on C. Make use of NumPy functions whenever possible for this and upcoming tasks.
In [5]:
bpsk_mod = BPSKModulator(bit_rate=1000, carrier_freq=10000, sample_rate=100000)

# Generate the bitstream
bitstream = string2bitstream("This Lab is amazing!")

# Create the BPSK Baseband Waveform
baseband_waveform = bpsk_mod._generate_baseband_waveform(bitstream)

# Visualize the Baseband Waveform
plot_waveform(
    signal=baseband_waveform,
    bit_rate=bpsk_mod.bit_rate,
    sampling_freq=bpsk_mod.sample_rate,
    title="Baseband BPSK Signal",
)
No description has been provided for this image

Let's also have a look at the FFT of the signal

In [6]:
plot_fft(
    signals=[baseband_waveform],
    sampling_freq=bpsk_mod.sample_rate,
    labels=["Baseband BPSK"],
    xlim=(0, 15000),
    title="FFT of Baseband BPSK Signal",
    normalize=False
)
No description has been provided for this image

As you can see, the frequency response of this signal is not confined to a limited bandwidth.

Question: Why is the spectrum of the baseband signal not bandwidth-limited?

Your Answer: A pulse waveform (such as a rectangular pulse) contains sharp transitions in time, which translate to high-frequency components in the frequency domain. As a result, its Fourier Transform extends infinitely, meaning the signal has no strictly limited bandwidth.

To reduce the bandwidth of the signal, we can apply a low-pass filter. As shown, the majority of the signal’s energy is concentrated within a bandwidth of approximately 1kHz. This is intuitive: for baseband digital modulation schemes, the effective (or "occupied") bandwidth of the signal can be approximated as

$$ BW \approx \frac{1}{T_B} = BR $$

where $T_B$ is the bit duration and $BR$ is the bit rate.

A widely used filter in digital communications for bandwidth shaping is the Root-Raised-Cosine (RRC) filter, which is already implemented for you.

In [7]:
# Filter the baseband signal to limit its bandwidth
filtered_baseband = bpsk_mod._rrc_filter(baseband_waveform)

# Visualize the FFT comparison between original and filtered baseband signals
plot_fft(
    signals=[baseband_waveform, filtered_baseband],
    sampling_freq=bpsk_mod.sample_rate,
    labels=["Original Baseband BPSK", "Filtered Baseband BPSK"],
    xlim=(0, 15000),
    title="FFT of Filtered and Original Baseband BPSK Signal"
)

# Visualize the Filtered Baseband Waveform
plot_waveform(
    signal=filtered_baseband,
    bit_rate=bpsk_mod.bit_rate,
    sampling_freq=bpsk_mod.sample_rate,
    title="Filtered Baseband BPSK Signal",
)
No description has been provided for this image
No description has been provided for this image

Step 2: Generate the Carrier Wave¶

As discussed in the theoretical section, BPSK modulation is achieved by modulating the phase (or equivalently, the sign) of a carrier wave. The carrier signal is defined by the following equation:

$$ c(t) = \cos(2\pi f_c t) $$

where $f_c$ is the carrier frequency.

Using this expression, you can now complete the _generate_carrier method.

Step 3: Generate the modulated signal¶

Now that you created all the components you need to modulate the signal, you can put everything together inside the modulate method. If you succed, you will see a part of the modulated signal and it's fft in the cell below.

In [8]:
modulated_signal = bpsk_mod.modulate(bitstream)

plot_waveform(
    signal=modulated_signal[:2000],
    bit_rate=bpsk_mod.bit_rate,
    sampling_freq=bpsk_mod.sample_rate,
    title="BPSK Modulated Signal",
)

plot_fft(
    signals=[baseband_waveform, modulated_signal],
    sampling_freq=bpsk_mod.sample_rate,
    labels=["Original Baseband BPSK", "Modulated BPSK"],
    xlim=(0, 15000),
    title="FFT of Modulated and Original Baseband BPSK Signal",
)
No description has been provided for this image
No description has been provided for this image

Step 4: Restore the Signal¶

Let's do some math... At the receiver, what we have is the modulated signal (without noise for simplicity). $$ bpsk\_mod(t) = m(t) \cdot \cos (2\pi f_c t) $$

where $m(t)$ is the original message (in it's Baseband form). It can be shown that $m(t)$ can be recovered simply by doing

$$ \hat{m}(t) = LP\left[ 2 \cdot bpsk\_mod(t) \cdot \cos (2\pi f_c t)\right] $$

where $LP$ is the application of some lowpass filter. From now on $ bpsk\_mod(t) \cdot \cos (2\pi f_c t) $ will be called the mixed signal $mix(t)$, as it is the output of the analog mixer in the analog implementation.

Question: Show that this is the case :)

Your Answer: $$ \begin{align} mix(t) &= bpsk\_mod(t) \cdot \cos (2\pi f_c t) \\ mix(t) \cdot \cos (2\pi f_c t) &= bpsk\_mod(t) \cdot \cos^2 (2\pi f_c t) \\ &= \frac{1}{2}bpsk\_mod(t) \cdot \left( \cos (4\pi f_c t) + 1 \right) \\ &\Rightarrow \times 2 \text{ and } LP \\ \hat{m}(t) &= LP\left[ 2 \cdot bpsk\_mod(t) \cdot \cos (2\pi f_c t)\right] \end{align} $$

Implement the demodulate method. If you implement everything correctly you should be able to recover the original message :)

In [9]:
restored_bitstream, mixed_signal = bpsk_mod.demodulate(modulated_signal)

restored_message = bitstream2string(restored_bitstream)
print("Restored Message:", restored_message)

plot_fft(
    signals=[mixed_signal],
    sampling_freq=bpsk_mod.sample_rate,
    labels=["Mixed Signal"],
    xlim=(0, 25000),
    title="FFT of Mixed Signal Before Filtering",
)
Restored Message: This Lab is amazing!
No description has been provided for this image

Part 2: RLC Filtering¶

In this section, we will analyze a second-order low-pass filter configuration based on an RLC resonator. The circuit we will study is shown in the figure in the next cell.

In [10]:
with schemdraw.Drawing() as d:
    d.config(unit=3)
    d.push()
    elm.Inductor2().left().flip().label('L')
    elm.SourceSin().down()
    elm.Ground().right()
    d.pop()
    C = elm.Capacitor2().down().label('C')
    elm.Line().left()
    d.pop()
    elm.Line().right(1.5).at(C.start)
    R = elm.Resistor().down().label('R')
    elm.Line().left(1.5)
    d.pop()
    elm.Line().right(1).at(R.start)
    elm.Line().right(1).at(R.end)
    elm.Gap().toy(R.start).label(['-', '$V_{out}$', '+'])
No description has been provided for this image

Theoretical Analysis¶

When we talk about filters, the first concept that should come to mind is the Transfer Function. This function characterizes how the circuit responds to different frequencies and is defined as

$$ H(j\omega) = \frac{V_{out}(j\omega)}{V_{in}(j\omega)} $$

Question: Compute the transfer function H of the studied circuit

Hint: to simplify the next steps, write the transfer function in the following form $$ H(j\omega) = \frac{1}{1+j\omega\cdot A + (j\omega)^2 \cdot B} $$

Your Answer: $$ \begin{align} Y_{RC} &= \frac{1}{R} + j\omega C \\ \Rightarrow Z_{RC} &= \frac{R}{1 + j\omega RC} \\ \\ Z_{tot} &= j\omega L + \frac{R}{1 + j\omega RC} \\ &= \frac{j\omega L + (j\omega)^2LRC + R}{1 + j\omega RC} \\ \\ \frac{V_{out}}{V_{in}} &= \frac{R}{R + j\omega L + (j\omega)^2LRC}\\ &= \frac{1}{1+j\omega \frac{L}{R} + (j\omega)^2LC} \end{align} $$ $$ \boxed{\frac{V_{out}}{V_{in}} = \frac{1}{1+j\omega \frac{L}{R} + (j\omega)^2LC}} $$

Great! Now that we have the transfer function, we can express it in an alternative form:

$$ \frac{V_{out}}{V_{in}} = \frac{1}{1 + j 2 \zeta \frac{\omega}{\omega_0} + \left(j \frac{\omega}{\omega_0}\right)^2} $$

where $\omega_0$ represents the resonance frequency of the RLC circuit, and $\zeta$ denotes the damping ratio.

Question: Rewrite the transfer function by using the RLC resonance frequency and the damping ratio. Furthermore, specify their values

Your Answer: $$ \begin{align} H(j\omega) &= \frac{1}{1+j\frac{\omega}{\omega_0}2 \zeta + (j\frac{\omega}{\omega_0})^2}\\ \omega_0 &= \sqrt{\frac{1}{LC}}\\ \zeta &= \frac{1}{2R}\sqrt{\frac{L}{C}} \end{align} $$

Let's have a closer look to this transfer function. When $\omega_0 << \omega$ (high frequencies) the transfer function can be approximated by $$ H(j\omega) \approx -\frac{1}{(\frac{\omega}{\omega_0})^2} $$

That is, basically, a slope of $-40dB/dec$

When $\omega << \omega_0$ (low frequencies), the transfer function can be approximated by $$ H(j\omega) \approx 1 $$

so a unity gain (slope 0)

The interesting part is around $\omega_0$ where the system may or may not have a peak in the transfer function (due to the resonance) depending on the value of $\zeta$. Let's dive into this analysis.

To find the peak, we can check for the maximum of the magnitude of the transfer function.

Question: Find the peak frequency in terms of the resonance frequency and the damping ratio

Hint 1: there will be two solutions, one will have a peak and the other one not

Your Answer: Let's define the magnitude of the transfer function as $$ M(\omega) = \Big| G(j\omega) \Big| = \frac{1}{\sqrt{\left( 1 - \left( \frac{\omega}{\omega_0}\right)^2 \right)^2 + 4\zeta^2 \left( \frac{\omega}{\omega_0}\right)^2}} $$ The peak occurs when $\frac{\partial}{\partial \omega}M(\omega)=0$, which is when $\frac{\partial}{\partial \omega}\left[\left( 1 - \left( \frac{\omega}{\omega_0}\right)^2 \right)^2 + 4\zeta^2 \left( \frac{\omega}{\omega_0}\right)^2\right] = 0$

After the derivative calculation and simplification, this leads to $$ 4\left( \frac{\omega}{\omega_0}\right)\left( \left( \frac{\omega}{\omega_0}\right)^2 + 2\zeta^2-1 \right) = 0 $$

Two cases are now possible

  1. If $2\zeta^2-1 > 0$, i.e. $\zeta \geq \frac{1}{\sqrt{2}}$ :
    The only solution is $\omega = 0$, so no resonant peak

  2. If $2\zeta^2-1 < 0$, i.e. $0\leq \zeta \leq \frac{1}{\sqrt{2}}$ :
    There is a peak at $\left(\frac{\omega}{\omega_0}\right) = \sqrt{1- 2\zeta^2}$

We define the peak frequency $\omega_P$ as $$ \boxed{\omega_P = \omega_0\sqrt{1- 2\zeta^2}} $$

Question: Now that you have computed the peak frequency value, compute the peak height (in dB) as function of the damping ratio

Your Answer: $$ M(\omega_P) = \frac{1}{2\zeta\sqrt{1-\zeta^2}} $$

Alright, everything should be starting to make sense now. For the final part, I'll provide you with most of the code, and your task is simply to complete a function that calculates the values of $R$, $L$, and $C$ based on the damping factor $\zeta$ and cutoff frequency.

Once done, you can use the plot_bode function to visualize the frequency response of the filter.

Please plot the frequency response for the following damping factors:

  • $\zeta = 0.01$
  • $\zeta = 0.1$
  • $\zeta = \sqrt{2}$
  • $\zeta = 1$

The cutoff frequency is set to 100 kHz, and the inductance $L$ is fixed at $10 \, \mu H$.

Remember, the cutoff frequency corresponds to the resonance frequency $\omega_0$ of the RLC oscillator.

In [11]:
def size_lp_filter(zeta: float, f_c: float, L: float = 10e-6) -> Tuple[float, float, float]:
    """
    Computes the values of R and C for a second-order low-pass RLC filter given the damping factor (zeta),
    cutoff frequency (f_c), and inductance (L).

    Parameters
    ----------
    zeta : float
        Damping factor of the filter.
    f_c : float
        Cutoff frequency in Hz.
    L : float
        Inductance in Henry.

    Returns
    -------
    Tuple[float, float, float]
        A tuple containing:
        - R : Resistance in Ohms.
        - C : Capacitance in Farads.
        - L : Inductance in Henry (same as input).
    """
    omega_c = 2 * np.pi * f_c                   # Hidden from students
    C = 1 / (L * (omega_c ** 2))                # Hidden from students
    R = 1 / (2 * zeta) * np.sqrt(L / C)         # Hidden from students
    print(f"Zeta = {zeta:.2f} => R: {R:.2f} Ohms, C: {C*1e9:.2f} nF, L: {L*1e6:.2f} uH")
    return R, C, L

def plot_bode(
    RLC: List[Tuple[float, float, float]],
    dB=True,
    deg=True,
    freq_range=None,
    title='Bode Diagram',
    figsize=(10, 6)
) -> None:
    """
    Plots the Bode diagram (magnitude and phase) for one or more RLC low-pass filters.

    Parameters
    ----------
    RLC : List[Tuple[float, float, float]]
        List of tuples, each containing (R, C, L) values for the filters.
    dB : bool
        If True, plot magnitude in dB. Default is True.
    deg : bool
        If True, plot phase in degrees. Default is True.
    freq_range : tuple, optional
        Frequency range for the x-axis (min_freq, max_freq). Default is None (auto).
    title : str
        Title of the plot.
    figsize : tuple
        Size of the figure.
    """
    sns.set_theme(style="whitegrid")
    
    plt.figure(figsize=figsize)

    for rlc in RLC:
        r, c, l = rlc
        zeta = 1/(2*r) * np.sqrt(l/c)       # Hidden from students
        omega_0 = 1/np.sqrt(l*c)            # Hidden from students
        num = [1]
        den = [1/(omega_0**2), 2*zeta/omega_0, 1]

        # Frequency response
        w = np.logspace(1, 8, 1000) if freq_range is None else np.logspace(np.log10(freq_range[0]), np.log10(freq_range[1]), 1000)
        s = 1j * w
        H = np.polyval(num, s) / np.polyval(den, s)

        magnitude = 20 * np.log10(np.abs(H)) if dB else np.abs(H)
        phase = np.angle(H, deg=deg)

        label = f'$\\zeta$={zeta:.2f}'
        plt.subplot(2, 1, 1)
        plt.semilogx(w/(2*np.pi), magnitude, label=label)
        plt.title(title)
        plt.ylabel('Magnitude (dB)' if dB else 'Magnitude')
        plt.grid(True, which='both', linestyle='--', alpha=0.5)

        plt.subplot(2, 1, 2)
        plt.semilogx(w/(2*np.pi), phase, label=label)
        plt.xlabel('Frequency (Hz)')
        plt.ylabel('Phase (degrees)' if deg else 'Phase (radians)')
        plt.grid(True, which='both', linestyle='--', alpha=0.5)

    plt.subplot(2, 1, 1)
    plt.legend()
    plt.tight_layout()
    plt.show()
In [12]:
zeta_values = [0.01, 0.1, np.sqrt(2), 1]
RLC = [size_lp_filter(zeta=zeta, f_c=100e3) for zeta in zeta_values]

plot_bode(
    RLC=RLC,
    freq_range=(1e3, 10e8),
    title='Bode Diagram of RLC Low-Pass Filters'
)
Zeta = 0.01 => R: 314.16 Ohms, C: 253.30 nF, L: 10.00 uH
Zeta = 0.10 => R: 31.42 Ohms, C: 253.30 nF, L: 10.00 uH
Zeta = 1.41 => R: 2.22 Ohms, C: 253.30 nF, L: 10.00 uH
Zeta = 1.00 => R: 3.14 Ohms, C: 253.30 nF, L: 10.00 uH
No description has been provided for this image