Amarisoft

IQ Plot- Python

The purpose of this tutorial is to show how to plot IQ data that are captured from sdr_spectrum or Remote API. There are multiple ways to capture IQ data that can be processed by the example in this tutorial. Refer to following tutorials regarding the way to capture IQ data.

NOTE : The example Python script in this tutorial would look a little bit complicated and tricky to understand but it is more mature with various options to process the data. If you want to get a very simple code just to understand the file format of the IQ data and plot it without any post processing, refer to this tutorial.

NOTE : The purpose of this tutorial is just to provide an example of Python script. We don't provide any technical support for Python itself.

Table of Contents

Introduction

In radio frequency (RF) and signal processing domains, IQ data (In-phase and Quadrature data) represents the complex-valued samples acquired from software-defined radios (SDRs) or similar digitizing hardware. This data is fundamental for analyzing, demodulating, and visualizing signals across a wide range of wireless communication and spectrum monitoring applications. Technologies such as sdr_spectrum and Remote API frameworks enable users to capture raw IQ samples directly from radio front-ends, storing them in standard formats for offline or real-time analysis. Plotting IQ data is a critical step in understanding signal characteristics such as bandwidth, modulation scheme, and interference patterns. This tutorial focuses on demonstrating how to efficiently plot IQ data that has been acquired via sdr_spectrum or Remote API, using Python as the primary data processing and visualization tool. The provided Python script illustrates advanced data handling, offering flexible options for users to process and visualize IQ files, whether for quick inspection or in-depth analysis. By leveraging Python’s mature scientific libraries, users can gain actionable insights into their captured RF environments, making this process an essential skill for engineers, researchers, and hobbyists working with SDR platforms and RF analytics.

Summary of the Tutorial

This tutorial provides detailed procedures for testing and visualizing IQ (In-phase and Quadrature) data using Python scripts, both from the command line and with a graphical user interface (GUI). The tests focus on reading, processing, and analyzing IQ data files, primarily plotting time-domain and frequency-domain characteristics. Below is a summary of the test procedures and methodologies described.

Additional Notes:

Test Setup

You can use any kind of test setup that allow you to get UE attached to callbox.

Python

Following is the version of the python and package that are used for this tutorial. (I tested this on Windows 11 Home Edition). For python, first try with whatever version you are using as long as it is ver 3.x and upgrade it to latest version if it does not work.

Python Packages

For the script in this tutorial, you need to install following packages.

pip install numpy

pip install matplotlib

pip install scipy

pip install tqdm

pip install mplcursors

pip install PyQt5

Python Script - Command Line

In this tutorial, I will show you an example of Python script to plot IQ data from a file. This is just an example script intended as POC(Proof of Concept) example. You can revise in any way as you like if this does not suit your purpose.

Source Codeand Data

Here goes the python script and IQ data that I used in this tutorial. Download the zip file that contains both the source and data, and try on your own.

Script - Barebone

Following is the source script without any comments. I didn't put any comments on purpose for simplicity.

import sys

import numpy as np

import matplotlib.pyplot as plt

from matplotlib.widgets import Slider

from scipy.signal import butter, filtfilt, convolve, ellip, cheby1

import struct

from tqdm import tqdm

import matplotlib

matplotlib.use('Qt5Agg')

import matplotlib.pyplot as plt

import mplcursors

import argparse

 

 

def read_iq_data(filename, start_pos, length=None):

    with open(filename, 'rb') as f:

        raw_data = f.read()

 

    num_samples = len(raw_data) // 8

    if length is not None:

        end_pos = min(start_pos + length, num_samples)

    else:

        end_pos = num_samples

 

    data = np.frombuffer(raw_data, dtype=np.float32).view(np.complex64)[start_pos:end_pos]

    #data = np.frombuffer(raw_data, dtype=np.float32).view(np.complex64)

 

    return data

 

 

updating_xlims = False

updating_ylims = False

 

def plot_data(data, downsample_rate=1, vbw=1, filter_type='butter', filter_order=5, filter_cutoff=None):

    def butter_lowpass(cutoff, fs, order=5):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = butter(order, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def ellip_lowpass(cutoff, fs, order=5, rp=0.5, rs=40):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = ellip(order, rp, rs, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def cheby_lowpass(cutoff, fs, order=5, rp=0.5):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = cheby1(order, rp, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def lowpass_filter(data, cutoff, fs, order=5, filter_type='butter'):

        if filter_type == 'butter':

            b, a = butter_lowpass(cutoff, fs, order=order)

        elif filter_type == 'elliptic':

            b, a = ellip_lowpass(cutoff, fs, order=order)

        elif filter_type == 'cheby':

            b, a = cheby_lowpass(cutoff, fs, order=order)

        else:

            raise ValueError("Invalid filter type. Choose 'butter', 'elliptic', or 'cheby'.")

        y = filtfilt(b, a, data)

        return y

 

    def moving_average(data, window_size):

        window = np.ones(window_size) / window_size

        return convolve(data, window, mode='same')

 

    if downsample_rate > 1:

        cutoff_frequency = filter_cutoff #0.45 * (0.5 / downsample_rate)

        filtered_data = lowpass_filter(data, cutoff_frequency, 1, filter_order, filter_type)

        downsampled_data = filtered_data[::downsample_rate]

    else:

        downsampled_data = data

 

    fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1)

 

    ax1.plot(downsampled_data.real)

    ax1.set_title('Real Part', fontsize=10)

    ax1.set_xticklabels([])

    ax1.set_yticklabels([])

 

    ax2.plot(downsampled_data.imag)

    ax2.set_title('Imaginary Part', fontsize=10)

    ax2.set_xticklabels([])

    ax2.set_yticklabels([])

 

    # Compute the magnitude of the downsampled data

    magnitude = np.absolute(downsampled_data)

 

    ax3.plot(magnitude)

    ax3.set_title('Magnitude', fontsize=10)

    ax3.set_xticklabels([])

    ax3.set_yticklabels([])

 

    spectrum = np.fft.fft(downsampled_data)

    centered_spectrum = np.fft.fftshift(spectrum)

 

    power_mw = np.abs(centered_spectrum)**2

    centered_spectrum_dbm = 10 * np.log10(power_mw)

 

    if vbw > 1:

        vbw_filtered_spectrum = moving_average(centered_spectrum_dbm, vbw)

    else:

        vbw_filtered_spectrum = centered_spectrum_dbm

 

    vbw_filtered_spectrum_normalized = vbw_filtered_spectrum - np.max(vbw_filtered_spectrum)

 

    ax4.plot(vbw_filtered_spectrum_normalized)

    ax4.set_title('Spectrum (FFT) with VBW', fontsize=10)

    ax4.set_xticklabels([])

    ax4.set_yticklabels([])

 

    plt.tight_layout()

    plt.subplots_adjust(top=0.95)

    plt.subplots_adjust(hspace = 0.3)

 

    def on_xlims_change(event_axes):

        global updating_xlims

        if not updating_xlims:

            updating_xlims = True

            if event_axes == ax1:

                ax2.set_xlim(ax1.get_xlim())

                ax3.set_xlim(ax1.get_xlim())

            elif event_axes == ax2:

                ax1.set_xlim(ax2.get_xlim())

                ax3.set_xlim(ax2.get_xlim())

            elif event_axes == ax3:

                ax1.set_xlim(ax3.get_xlim())

                ax2.set_xlim(ax3.get_xlim())

            updating_xlims = False

            update_spectrum_plot()

 

    def on_ylims_change(event_axes):

        global updating_ylims

        if not updating_ylims:

            updating_ylims = True

            if event_axes == ax1:

                ax2.set_ylim(ax1.get_ylim())

                ax3.set_ylim(ax1.get_ylim())

            elif event_axes == ax2:

                ax1.set_ylim(ax2.get_ylim())

                ax3.set_ylim(ax2.get_ylim())

            elif event_axes == ax3:

                ax1.set_ylim(ax3.get_ylim())

                ax2.set_ylim(ax3.get_ylim())

            updating_ylims = False

            update_spectrum_plot()

 

 

    def update_spectrum_plot():

        x1_min, x1_max = map(int, ax1.get_xlim())

        y1_min, y1_max = map(int, ax1.get_ylim())

        x2_min, x2_max = map(int, ax2.get_xlim())

        y2_min, y2_max = map(int, ax2.get_ylim())

        x3_min, x3_max = map(int, ax3.get_xlim())  # add this line for ax3 x limits

        y3_min, y3_max = map(int, ax3.get_ylim())  # add this line for ax3 y limits

 

        x1_min = max(x1_min, 0)

        x2_min = max(x2_min, 0)

        x3_min = max(x3_min, 0)  # make sure x3_min is non-negative

 

        if x1_max - x1_min < 1:

            x1_max = x1_min + 1

        if x2_max - x2_min < 1:

            x2_max = x2_min + 1

        if x3_max - x3_min < 1:  # ensure x3 range is at least 1

            x3_max = x3_min + 1

 

        visible_data_real = downsampled_data.real[x1_min:x1_max]

        visible_data_imag = downsampled_data.imag[x2_min:x2_max]

        visible_data_magnitude = np.abs(downsampled_data)[x3_min:x3_max]  # visible magnitude data from ax3

 

        visible_data = visible_data_real + 1j * visible_data_imag

 

        spectrum_data = np.fft.fft(visible_data)

        centered_spectrum_data = np.fft.fftshift(spectrum_data)

 

        power_mw = np.abs(centered_spectrum_data)**2

        centered_spectrum_dbm = 10 * np.log10(power_mw)

 

        if vbw > 1:

            vbw_filtered_spectrum = moving_average(centered_spectrum_dbm, vbw)

        else:

            vbw_filtered_spectrum = centered_spectrum_dbm

 

        vbw_filtered_spectrum_normalized = vbw_filtered_spectrum - np.max(vbw_filtered_spectrum)

 

        ax4.clear()

        ax4.plot(vbw_filtered_spectrum_normalized)

        ax4.set_title("Spectrum Data", fontsize=10)

        ax4.set_xlabel("Frequency", fontsize=10)

        ax4.set_ylabel("Amplitude", fontsize=10)

        ax4.set_xticklabels([])

        ax4.set_yticklabels([])

 

        fig.canvas.draw_idle()

 

    ax1.callbacks.connect('xlim_changed', on_xlims_change)

    #ax1.callbacks.connect('ylim_changed', on_ylims_change)

    ax2.callbacks.connect('xlim_changed', on_xlims_change)

    #ax2.callbacks.connect('ylim_changed', on_ylims_change)

    ax3.callbacks.connect('xlim_changed', on_xlims_change)

    #ax3.callbacks.connect('ylim_changed', on_ylims_change)

 

    plt.show()

 

 

def parse_arguments():

    parser = argparse.ArgumentParser(description='Plot IQ data from a file.')

    parser.add_argument('datafile', type=str, help='Path to the IQ data file')

    parser.add_argument('--data-pos-unit', type=str, choices=['sample', 'percent'], default='sample', help='Unit for start-pos and length: sample or percent')

    parser.add_argument('--start-pos', type=float, default=0, help='Start position of the I/Q data pair to read')

    parser.add_argument('--length', type=float, default=None, help='Length of the I/Q data pair to read')

    parser.add_argument('--downsample-rate', type=int, default=1, help='Factor to downsample the data by')

    parser.add_argument('--vbw', type=int, default=1000, help='Video Bandwidth (VBW)')

    parser.add_argument('--filter-type', type=str, default='cheby', choices=['butter', 'elliptic', 'cheby'], help='Type of low-pass filter to use')

    parser.add_argument('--filter-order', type=int, default=5, help='Order of the low-pass filter')

    parser.add_argument('--cutoff-frequency', type=float, default=None, help='Cutoff frequency of the low-pass filter')

 

    # Add a double dash to allow optional arguments to be in any order

    parser.add_argument('--', dest='double_dash', action='store_true', help=argparse.SUPPRESS)

 

    args = parser.parse_args()

 

    # Handle double dash by re-parsing the command line and re-assigning the arguments

    if args.double_dash:

        parser = argparse.ArgumentParser(description='Plot IQ data from a file.')

        parser.add_argument('datafile', type=str, help='Path to the IQ data file')

        parser.add_argument('--data-pos-unit', type=str, choices=['sample', 'percent'], default='sample', help='Unit for start-pos and length: sample or percent')

        parser.add_argument('--start-pos', type=float, default=0, help='Start position of the I/Q data pair to read')

        parser.add_argument('--length', type=float, default=None, help='Length of the I/Q data pair to read')

        parser.add_argument('--downsample-rate', type=int, default=1, help='Factor to downsample the data by')

        parser.add_argument('--vbw', type=int, default=1000, help='Video Bandwidth (VBW)')

        parser.add_argument('--filter-type', type=str, default='cheby', choices=['butter', 'elliptic', 'cheby'], help='Type of low-pass filter to use')

        parser.add_argument('--filter-order', type=int, default=5, help='Order of the low-pass filter')

        parser.add_argument('--cutoff-frequency', type=float, default=None, help='Cutoff frequency of the low-pass filter')

        args = parser.parse_args()

 

    return args

 

 

def main():

    args = parse_arguments()

 

    if args.data_pos_unit == 'percent':

        with open(args.datafile, 'rb') as f:

            total_samples = len(f.read()) // 8

        start_pos = int(args.start_pos / 100 * total_samples)

        if args.length is not None:

            length = int(args.length / 100 * total_samples)

        else:

            length = None

    else:

        start_pos = int(args.start_pos)

        length = int(args.length) if args.length is not None else None

 

    data = read_iq_data(args.datafile, start_pos, length)

 

    if args.cutoff_frequency is not None:

        filter_cutoff = args.cutoff_frequency

    else:

        filter_cutoff = 0.45 * (0.5 / args.downsample_rate)

 

    plot_data(data, downsample_rate=args.downsample_rate, vbw=args.vbw, filter_type=args.filter_type, filter_order=args.filter_order, filter_cutoff=filter_cutoff)

 

 

if __name__ == '__main__':

    main()

Script - Comments

For the readers who is not familiar with python script and packages that are used in the code. I put the comments for each lines.

import sys

import numpy as np

import matplotlib.pyplot as plt

from matplotlib.widgets import Slider

from scipy.signal import butter, filtfilt, convolve, ellip, cheby1

import struct

from tqdm import tqdm

import matplotlib

matplotlib.use('Qt5Agg')

import matplotlib.pyplot as plt

import mplcursors

import argparse

 

# This function reads IQ data from a binary file and returns it as a numpy array of complex numbers.

#

# Parameters:

#    filename (str): The name of the file to read the data from.

#    start_pos (int): The starting position in the data array from where to read the data.

#    length (int, optional): The number of samples to read. If not specified, the function reads data till the end of the file.

#

# Returns:

#    numpy.ndarray: A numpy array of complex numbers representing the IQ data read from the file.

#

# The function first reads the entire file into a byte array. It then calculates the total number of samples in the file  (each sample is a complex number consisting of 8 bytes - 4 for the real part and 4

# for the imaginary part).

#

# If a length is specified, it calculates the end position as the minimum of start_pos + length and num_samples.  If length is not specified, it sets the end position to num_samples, effectively reading

# till the end of the file.

#

# It then converts the byte array into a numpy array of float32, views it as a numpy array of complex64, and slices it  from start_pos to end_pos. The resulting sliced array is returned.

#

def read_iq_data(filename, start_pos, length=None):

    with open(filename, 'rb') as f:

        raw_data = f.read()

 

    num_samples = len(raw_data) // 8

    if length is not None:

        end_pos = min(start_pos + length, num_samples)

    else:

        end_pos = num_samples

 

    data = np.frombuffer(raw_data, dtype=np.float32).view(np.complex64)[start_pos:end_pos]

    #data = np.frombuffer(raw_data, dtype=np.float32).view(np.complex64)

 

    return data

 

 

# This function plots the real part, imaginary part, magnitude, and spectrum of the given data.

#

# The data is first low-pass filtered and downsampled if a downsample rate greater than 1 is specified.  The type of low-pass filter (Butterworth, elliptic, or Chebyshev) can be specified.

#

# The function then plots the real part, imaginary part, and magnitude of the downsampled data in separate subplots. It also computes the spectrum of the data, applies a video bandwidth (VBW)

# filter if specified, and plots the spectrum in a fourth subplot.

#

# The function also sets up callbacks to update the x-limits and y-limits of the plots when they are changed in any of the subplots. This allows the user to zoom in on a particular part of the data

# in onesubplot and see the corresponding part of the data in the other subplots.

#

# Parameters:

#    data (numpy.ndarray): The data to plot. This should be a 1D numpy array of complex numbers.

#    downsample_rate (int, optional): The rate at which to downsample the data. If not specified, the data is not downsampled.

#    vbw (int, optional): The video bandwidth (VBW) to apply when computing the spectrum. If not specified, no VBW is applied.

#    filter_type (str, optional): The type of low-pass filter to use when downsampling. Can be 'butter' (Butterworth), 'elliptic', or 'cheby' (Chebyshev). If not specified, a Butterworth filter is used.

#    filter_order (int, optional): The order of the low-pass filter. If not specified, a 5th order filter is used.

#    filter_cutoff (float, optional): The cutoff frequency of the low-pass filter. If not specified, the cutoff frequency is set to 0.45 times the Nyquist frequency.

#

# Returns:

#    None

#

updating_xlims = False

updating_ylims = False

 

def plot_data(data, downsample_rate=1, vbw=1, filter_type='butter', filter_order=5, filter_cutoff=None):

    def butter_lowpass(cutoff, fs, order=5):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = butter(order, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def ellip_lowpass(cutoff, fs, order=5, rp=0.5, rs=40):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = ellip(order, rp, rs, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def cheby_lowpass(cutoff, fs, order=5, rp=0.5):

        nyq = 0.5 * fs

        normal_cutoff = cutoff / nyq

        b, a = cheby1(order, rp, normal_cutoff, btype='low', analog=False)

        return b, a

 

    def lowpass_filter(data, cutoff, fs, order=5, filter_type='butter'):

        if filter_type == 'butter':

            b, a = butter_lowpass(cutoff, fs, order=order)

        elif filter_type == 'elliptic':

            b, a = ellip_lowpass(cutoff, fs, order=order)

        elif filter_type == 'cheby':

            b, a = cheby_lowpass(cutoff, fs, order=order)

        else:

            raise ValueError("Invalid filter type. Choose 'butter', 'elliptic', or 'cheby'.")

        y = filtfilt(b, a, data)

        return y

 

    def moving_average(data, window_size):

        window = np.ones(window_size) / window_size

        return convolve(data, window, mode='same')

 

    if downsample_rate > 1:

        cutoff_frequency = filter_cutoff #0.45 * (0.5 / downsample_rate)

        filtered_data = lowpass_filter(data, cutoff_frequency, 1, filter_order, filter_type)

        downsampled_data = filtered_data[::downsample_rate]

    else:

        downsampled_data = data

 

    fig, (ax1, ax2, ax3, ax4) = plt.subplots(4, 1)

 

    ax1.plot(downsampled_data.real)

    ax1.set_title('Real Part', fontsize=10)

    ax1.set_xticklabels([])

    ax1.set_yticklabels([])

 

    ax2.plot(downsampled_data.imag)

    ax2.set_title('Imaginary Part', fontsize=10)

    ax2.set_xticklabels([])

    ax2.set_yticklabels([])

 

    # Compute the magnitude of the downsampled data

    magnitude = np.absolute(downsampled_data)

 

    ax3.plot(magnitude)

    ax3.set_title('Magnitude', fontsize=10)

    ax3.set_xticklabels([])

    ax3.set_yticklabels([])

 

    spectrum = np.fft.fft(downsampled_data)

    centered_spectrum = np.fft.fftshift(spectrum)

 

    power_mw = np.abs(centered_spectrum)**2

    centered_spectrum_dbm = 10 * np.log10(power_mw)

 

    if vbw > 1:

        vbw_filtered_spectrum = moving_average(centered_spectrum_dbm, vbw)

    else:

        vbw_filtered_spectrum = centered_spectrum_dbm

 

    vbw_filtered_spectrum_normalized = vbw_filtered_spectrum - np.max(vbw_filtered_spectrum)

 

    ax4.plot(vbw_filtered_spectrum_normalized)

    ax4.set_title('Spectrum (FFT) with VBW', fontsize=10)

    ax4.set_xticklabels([])

    ax4.set_yticklabels([])

 

    plt.tight_layout()

    plt.subplots_adjust(top=0.95)

    plt.subplots_adjust(hspace = 0.3)

 

    def on_xlims_change(event_axes):

        global updating_xlims

        if not updating_xlims:

            updating_xlims = True

            if event_axes == ax1:

                ax2.set_xlim(ax1.get_xlim())

                ax3.set_xlim(ax1.get_xlim())

            elif event_axes == ax2:

                ax1.set_xlim(ax2.get_xlim())

                ax3.set_xlim(ax2.get_xlim())

            elif event_axes == ax3:

                ax1.set_xlim(ax3.get_xlim())

                ax2.set_xlim(ax3.get_xlim())

            updating_xlims = False

            update_spectrum_plot()

 

    def on_ylims_change(event_axes):

        global updating_ylims

        if not updating_ylims:

            updating_ylims = True

            if event_axes == ax1:

                ax2.set_ylim(ax1.get_ylim())

                ax3.set_ylim(ax1.get_ylim())

            elif event_axes == ax2:

                ax1.set_ylim(ax2.get_ylim())

                ax3.set_ylim(ax2.get_ylim())

            elif event_axes == ax3:

                ax1.set_ylim(ax3.get_ylim())

                ax2.set_ylim(ax3.get_ylim())

            updating_ylims = False

            update_spectrum_plot()

 

 

    def update_spectrum_plot():

        x1_min, x1_max = map(int, ax1.get_xlim())

        y1_min, y1_max = map(int, ax1.get_ylim())

        x2_min, x2_max = map(int, ax2.get_xlim())

        y2_min, y2_max = map(int, ax2.get_ylim())

        x3_min, x3_max = map(int, ax3.get_xlim())  # add this line for ax3 x limits

        y3_min, y3_max = map(int, ax3.get_ylim())  # add this line for ax3 y limits

 

        x1_min = max(x1_min, 0)

        x2_min = max(x2_min, 0)

        x3_min = max(x3_min, 0)  # make sure x3_min is non-negative

 

        if x1_max - x1_min < 1:

            x1_max = x1_min + 1

        if x2_max - x2_min < 1:

            x2_max = x2_min + 1

        if x3_max - x3_min < 1:  # ensure x3 range is at least 1

            x3_max = x3_min + 1

 

        visible_data_real = downsampled_data.real[x1_min:x1_max]

        visible_data_imag = downsampled_data.imag[x2_min:x2_max]

        visible_data_magnitude = np.abs(downsampled_data)[x3_min:x3_max]  # visible magnitude data from ax3

 

        visible_data = visible_data_real + 1j * visible_data_imag

 

        spectrum_data = np.fft.fft(visible_data)

        centered_spectrum_data = np.fft.fftshift(spectrum_data)

 

        power_mw = np.abs(centered_spectrum_data)**2

        centered_spectrum_dbm = 10 * np.log10(power_mw)

 

        if vbw > 1:

            vbw_filtered_spectrum = moving_average(centered_spectrum_dbm, vbw)

        else:

            vbw_filtered_spectrum = centered_spectrum_dbm

 

        vbw_filtered_spectrum_normalized = vbw_filtered_spectrum - np.max(vbw_filtered_spectrum)

 

        ax4.clear()

        ax4.plot(vbw_filtered_spectrum_normalized)

        ax4.set_title("Spectrum Data", fontsize=10)

        ax4.set_xlabel("Frequency", fontsize=10)

        ax4.set_ylabel("Amplitude", fontsize=10)

        ax4.set_xticklabels([])

        ax4.set_yticklabels([])

 

        fig.canvas.draw_idle()

 

    ax1.callbacks.connect('xlim_changed', on_xlims_change)

    #ax1.callbacks.connect('ylim_changed', on_ylims_change)

    ax2.callbacks.connect('xlim_changed', on_xlims_change)

    #ax2.callbacks.connect('ylim_changed', on_ylims_change)

    ax3.callbacks.connect('xlim_changed', on_xlims_change)

    #ax3.callbacks.connect('ylim_changed', on_ylims_change)

 

    plt.show()

 

 

# This function parses command-line arguments for plotting IQ data from a file.

#

# It uses the argparse module to define and parse the command-line arguments. It supports the following arguments:

#    datafile (str): The path to the IQ data file.

#    data-pos-unit (str, optional): The unit for start-pos and length. Can be 'sample' or 'percent'. Default is 'sample'.

#    start-pos (float, optional): The start position of the I/Q data pair to read. Default is 0.

#    length (float, optional): The length of the I/Q data pair to read. Default is None, which means read to the end of the file.

#    downsample-rate (int, optional): The factor to downsample the data by. Default is 1, which means no downsampling.

#    vbw (int, optional): The Video Bandwidth (VBW). Default is 1000.

#    filter-type (str, optional): The type of low-pass filter to use. Can be 'butter' (Butterworth), 'elliptic', or 'cheby' (Chebyshev). Default is 'cheby'.

#    filter-order (int, optional): The order of the low-pass filter. Default is 5.

#    cutoff-frequency (float, optional): The cutoff frequency of the low-pass filter. Default is None, which means the cutoff frequency is set to 0.45 times the Nyquist frequency.

#   

# The function also supports a special '--' argument that allows optional arguments to be in any order. If '--' is specified, the function re-parses the command line and re-assigns the arguments.

#   

# Returns:

#    argparse.Namespace: An object that contains the parsed arguments as attributes.

#

def parse_arguments():

    parser = argparse.ArgumentParser(description='Plot IQ data from a file.')

    parser.add_argument('datafile', type=str, help='Path to the IQ data file')

    parser.add_argument('--data-pos-unit', type=str, choices=['sample', 'percent'], default='sample', help='Unit for start-pos and length: sample or percent')

    parser.add_argument('--start-pos', type=float, default=0, help='Start position of the I/Q data pair to read')

    parser.add_argument('--length', type=float, default=None, help='Length of the I/Q data pair to read')

    parser.add_argument('--downsample-rate', type=int, default=1, help='Factor to downsample the data by')

    parser.add_argument('--vbw', type=int, default=1000, help='Video Bandwidth (VBW)')

    parser.add_argument('--filter-type', type=str, default='cheby', choices=['butter', 'elliptic', 'cheby'], help='Type of low-pass filter to use')

    parser.add_argument('--filter-order', type=int, default=5, help='Order of the low-pass filter')

    parser.add_argument('--cutoff-frequency', type=float, default=None, help='Cutoff frequency of the low-pass filter')

 

    # Add a double dash to allow optional arguments to be in any order

    parser.add_argument('--', dest='double_dash', action='store_true', help=argparse.SUPPRESS)

 

    args = parser.parse_args()

 

    # Handle double dash by re-parsing the command line and re-assigning the arguments

    if args.double_dash:

        parser = argparse.ArgumentParser(description='Plot IQ data from a file.')

        parser.add_argument('datafile', type=str, help='Path to the IQ data file')

        parser.add_argument('--data-pos-unit', type=str, choices=['sample', 'percent'], default='sample', help='Unit for start-pos and length: sample or percent')

        parser.add_argument('--start-pos', type=float, default=0, help='Start position of the I/Q data pair to read')

        parser.add_argument('--length', type=float, default=None, help='Length of the I/Q data pair to read')

        parser.add_argument('--downsample-rate', type=int, default=1, help='Factor to downsample the data by')

        parser.add_argument('--vbw', type=int, default=1000, help='Video Bandwidth (VBW)')

        parser.add_argument('--filter-type', type=str, default='cheby', choices=['butter', 'elliptic', 'cheby'], help='Type of low-pass filter to use')

        parser.add_argument('--filter-order', type=int, default=5, help='Order of the low-pass filter')

        parser.add_argument('--cutoff-frequency', type=float, default=None, help='Cutoff frequency of the low-pass filter')

        args = parser.parse_args()

 

    return args

 

 

# This function is the main entry point(main routine) of the program.

#

# It first parses the command-line arguments using the parse_arguments function.

# If the data-pos-unit argument is 'percent', it calculates the start position and length in samples based on the total number of samples in the file.

# If the data-pos-unit argument is not 'percent', it uses the start-pos and length arguments directly as the start position and length in samples.

# It then reads the specified portion of the IQ data from the file using the read_iq_data function.

# If a cutoff frequency is specified, it uses that as the cutoff frequency for the low-pass filter.

# If no cutoff frequency is specified, it calculates the cutoff frequency based on the downsample rate.

# Finally, it plots the data using the plot_data function, passing the appropriate arguments for downsampling, VBW, filter type, filter order, and filter cutoff.

#

def main():

    args = parse_arguments()

 

    if args.data_pos_unit == 'percent':

        with open(args.datafile, 'rb') as f:

            total_samples = len(f.read()) // 8

        start_pos = int(args.start_pos / 100 * total_samples)

        if args.length is not None:

            length = int(args.length / 100 * total_samples)

        else:

            length = None

    else:

        start_pos = int(args.start_pos)

        length = int(args.length) if args.length is not None else None

 

    data = read_iq_data(args.datafile, start_pos, length)

 

    if args.cutoff_frequency is not None:

        filter_cutoff = args.cutoff_frequency

    else:

        filter_cutoff = 0.45 * (0.5 / args.downsample_rate)

 

    plot_data(data, downsample_rate=args.downsample_rate, vbw=args.vbw, filter_type=args.filter_type, filter_order=args.filter_order, filter_cutoff=filter_cutoff)

 

 

if __name__ == '__main__':

    main()

Script - Test

This is how I tested the script. I tested the scrip on a windows 11 PC in command prompt

Example 1 : Plotting the whole data as it is

In this example, I will show how to run the script to plot the whole data without any post processing and how to change the range of the plot with matplotlib GUI dialogbox.

$ python plot_iq.py bin\spectrum_bin\sdr_spectrum_nr_sa_dur_100.bin

The plot window shows four views of the same IQ data: the real part, the imaginary part, the magnitude, and the spectrum calculated by FFT with VBW smoothing. Since the whole file is plotted at once, the time-domain traces look very dense. This is expected because many IQ samples are compressed into one screen. This view is useful as a first sanity check. It confirms that the file is loaded correctly, the I and Q samples are present, the signal power is stable over time, and the spectrum has the expected occupied bandwidth shape. After opening this plot, you can use the matplotlib toolbar to zoom, pan, and select a smaller region when you want to inspect a specific part of the IQ capture in more detail.

IQ Plot CommandLine Ex01 01

Use the magnification button in the matplotlib window when you want to inspect only a selected part of the IQ capture. First click the magnification icon on the toolbar. Then drag the mouse over the target range on the plot. In this example, the selected range is marked on the original full-data plot, and the zoomed result is shown in the new view. After zooming, the real part, imaginary part, and magnitude traces become much easier to read. The repeated burst-like pattern is now clearly visible in the time domain, while the spectrum view still shows the occupied bandwidth and the edge roll-off of the signal. This is useful when the full capture is too dense to analyze directly and you want to check the detailed signal behavior in a smaller time range.

IQ Plot CommandLine Ex01 02

IQ Plot CommandLine Ex01 03

IQ Plot CommandLine Ex01 04

Example 2: Plotting the data with video filter

In this example, I will show how to run the script to plot the whole data without any post processing and how to change the range of the plot with matplotlib GUI dialogbox. This time the option --vbw 10000 is added. This means the spectrum display is smoothed with a VBW setting of 10000. The IQ data itself is still plotted from the original file, but the spectrum trace becomes smoother and easier to read because short-term fluctuation is reduced. This is useful when the raw FFT spectrum looks too noisy or too dense and you want to check the overall occupied bandwidth, signal flatness, and band-edge shape more clearly.

$ python plot_iq.py bin\spectrum_bin\sdr_spectrum_nr_sa_dur_100.bin --vbw 10000

This plot is generated with the VBW option set to 10000. The real part and imaginary part still show the full IQ capture in the time domain, so they look dense because the whole file is displayed at once. The magnitude plot also shows the full signal level over time, with a repeated ripple pattern across the capture. The main difference is in the spectrum plot. With VBW filtering enabled, the FFT result becomes smoother than the raw spectrum view. This makes it easier to see the overall signal bandwidth, the flatness of the occupied region, and the roll-off at both band edges. This view is useful when the purpose is not to inspect every small FFT fluctuation, but to check the general spectral shape of the captured IQ data.

IQ Plot CommandLine Ex02 01

Example 3: Plotting the data with partial data

In this example, I will show how to plot only part of the data (not the whole file). This is good for speeding up plot timing from a huge data file. Following example, plot only the 5% of the data starting from the 25 % in terms of position. In this command, --data-pos-unit percent means the start position and length are interpreted as percentages of the whole file. The option --start-pos 25 means the plotting starts from the 25% position of the file, and --length 5 means only 5% of the file is plotted from that point. So the script plots the data range from 25% to 30% of the IQ capture. This allows you to quickly inspect a specific section of a large IQ file without loading and displaying the entire data range.

$ python plot_iq.py bin\spectrum_bin\sdr_spectrum_nr_sa_dur_100.bin --data-pos-unit percent --start-pos 25 --length 5

This plot is generated from only a partial range of the IQ file. In this example, the script starts from the 25% position of the capture and plots only 5% of the data, so the displayed range corresponds to 25% to 30% of the original file. Because the plotted section is much smaller than the whole file, the burst structure becomes much clearer in the real part, imaginary part, and magnitude views. The repeated on/off pattern can be seen directly in the time-domain waveform. The spectrum is still calculated from the selected data range, and it shows the occupied bandwidth and band-edge shape for this specific portion of the capture. This option is useful when the IQ file is very large, or when you already know the approximate position of the signal section you want to inspect.

IQ Plot CommandLine Ex03 01

Example 4: Plotting the data with undersampling and filtering

In this example, I will show how to apply undersampling (mainly for reduce the plot timing) and apply a low pass filter to compensate for the side effect of the undersampling. The final result is not as good as other method shown in previous examples. In this command, --downsample-rate 2 means the script keeps only one sample out of every two samples, so the plot can be generated faster. Since undersampling can create unwanted high-frequency artifacts or aliasing effects, a low pass filter is also applied. Here, --filter-type butter selects a Butterworth filter, --filter-order 5 sets the filter order to 5, and --cutoff-frequency 0.1 sets the normalized cutoff frequency. This method can be useful when the IQ file is large and you want a quicker overview, but the result may not be as accurate as plotting the original data or selecting a partial data range. So this option is mainly for fast inspection, not for detailed signal analysis.

$ python plot_iq.py bin\spectrum_bin\sdr_spectrum_nr_sa_dur_100.bin --downsample-rate 2 --filter-type butter --filter-order 5 --cutoff-frequency 0.1

This plot is generated after applying downsampling and low pass filtering. The command uses --downsample-rate 2, so only every second IQ sample is used for plotting. This reduces the number of samples and helps the plot open faster. The Butterworth low pass filter is also applied with filter order 5 and cutoff frequency 0.1 to reduce the side effect caused by undersampling. In the time-domain plots, the real part, imaginary part, and magnitude look smoother and more compressed compared to the original full-data plot. However, the spectrum shape is noticeably changed. The occupied bandwidth no longer has the sharper edge shape shown in the previous examples, and the spectrum becomes more rounded because of the filtering effect. This is why this method is useful mainly for quick visual inspection of a large IQ file, but it should not be used when you need an accurate spectrum or detailed signal analysis.

IQ Plot CommandLine Ex04 01

Python Script - GUI

In this tutorial, I will show you an example of Python script to plot IQ data from a file. This is just an example script intended as POC(Proof of Concept) example. You can revise in any way as you like if this does not suit your purpose.

Source Codeand Data

Here goes the python script and IQ data that I used in this tutorial. Download the zip file that contains both the source and data, and try on your own.

NOTE : I wouldn't put any explanation on the source code itself. If you have a general understandings on the code in previous section, you would not have much difficulties understanding this code. Major differences from the previous code is graphics and Python UI components.

NOTE : For those who want to analyze the attached I/Q file further (e.g, decoding the signal). refer to the information about the I/Q Capture

Description of UI

This GUI is organized into a few main areas. Area A is the file selection field. It shows the currently selected IQ file path, and the open button is used to browse and load another IQ file. Area B is the basic configuration area. It shows the channel bandwidth as 20 MHz, the sample rate as 30.72 Msps, the FFT bin size as 2048, and the plotting range from Start 0% to End 100%. It also shows the file size as 100.00 ms, which corresponds to 3,072,000 samples. Area C is the plot mode selection area. The Time tab is selected in this example, so the GUI displays time-domain plots. The Spectrum tab can be selected when you want to inspect the frequency-domain view. Area D is the plot option area. Value Format can be Raw or Normalize, Y Scale can be Linear or dB, and Measurements can be None, Peak, RMS, or Statistics. These options control how the waveform is displayed and what measurement information is added. Area E is the main plotting area. In this Time view, it displays the I component, Q component, and magnitude. The horizontal axis is time in microseconds, and the vertical axis is amplitude. In this example, repeated signal bursts are clearly visible across the 100 ms capture.

(A) File Open

Used to load the binary IQ sample file into the tool for analysis.

(B) Common Information

Displays metadata and parameters of the loaded IQ file, including bandwidth, sample rate, and analysis range.

(C) Time Plot Tab

Switches the visualization to time-domain representation of the IQ signal.

(D) Time Plot Options

Provides controls to configure time-domain display format and analysis behavior.

(E) Time Plots

Displays time-domain graphs for I/Q components and their magnitude.

In the view shown below, the Spectrum tab is selected, so the GUI displays the frequency-domain analysis instead of the time-domain waveform. Area F shows the Spectrum tab selection. Area G shows the spectrum plot options. Value Format is set to Normalize, so the spectrum is displayed relative to the normalized reference level. Y Scale is set to dB, so the power level is shown in logarithmic scale. Power Min is set to -80 and Power Max is set to 0, which defines the visible power range of the spectrogram and PSD plot. Area H is the main spectrum display area. The upper plot is the spectrogram. It shows how the signal energy changes over time and frequency. The horizontal axis is time, and the vertical axis is frequency from about -10 MHz to +10 MHz. The bright vertical regions indicate the time positions where signal bursts exist. The lower plot is the power spectral density. It shows the average frequency-domain power shape across the selected data range. This view is useful when you want to check where the signal is located in frequency, whether the occupied bandwidth fits within the expected 20 MHz channel, and whether the burst timing pattern is consistent over the capture.

(F) Spectrum Plot Tab

Switches the visualization to frequency-domain spectrum analysis.

(G) Spectrum Plot Options

Allows configuration of frequency-domain visualization and scaling.

(H) Spectrum Plots

Displays spectral characteristics of the signal over time and frequency.

Following view shows how to inspect a smaller region of the spectrogram by using the magnification function. Area I shows the magnification button in the matplotlib toolbar. After selecting this button, you can drag the mouse over the target region in the spectrogram. Area J shows the zoomed spectrogram result. In this example, the display is zoomed into a smaller time range around 39 ms to 43 ms, so the detailed frequency-time structure becomes much easier to see. The bright blocks show where stronger signal energy exists, and the dark vertical gaps show periods with little or no signal energy. This is useful when the full spectrogram is too compressed and you want to check the timing, frequency position, and burst structure of a specific part of the IQ capture.

(I) Zoom

Provides interactive tools to zoom, pan, and reset views for closer inspection of plots. (NOTE : In addition to Zoom, you can use various other toolboxes provided by Python matplotlab)

(J) Zoomed Plot

Shows a magnified view of the spectrogram and PSD, useful for analyzing fine details.

Example 01 : DL signal with SSB, Traffic PDSCH, CSI RS

This is another example carrying more diveserse channels (e.g, Traffic PDSCH, CSI RS etc). You can download the I/Q data here. (NOTE : The size of the file is around 200MB and it will take some time to download it)

This plot shows the spectrogram for the entire 1 second IQ capture. The file is sampled at 30.72 Msps, so the full file contains 30,720,000 samples. Because this is a large amount of IQ data, the GUI may take some time to read, process, and plot the result. In some environments, you may need to wait several tens of seconds before the spectrogram is displayed. In this example, the Spectrum tab shows the full 0 to 1 second range. The spectrogram shows many vertical activity patterns across time, and the PSD plot at the bottom shows the average frequency-domain power over the whole capture. If you want to reduce the plotting time or inspect only a specific part of the file, you can change Start(%) and End(%) before pressing Plot. For example, plotting only 10% of the file can make the display much faster and easier to inspect.

Following zoomed spectrogram shows a small time segment around the SSB area. Compared with the full 1 second view, this zoomed view makes it much easier to distinguish individual downlink signal components. Around this region, you can see SSB, CORESET 0, PDSCH for SIB, PDSCH for traffic, CSI-RS for TRS, and CSI-RS for CSI report. The strong and wide blocks around the center frequency region correspond to scheduled downlink transmission, while the narrow vertical structures indicate reference signal or control-related activity. The lower PSD plot still shows the average spectrum over the selected range, but the main benefit of this view is the upper spectrogram. It allows you to check when each signal appears, how wide it is in frequency, and how it is positioned relative to the 20 MHz channel bandwidth.