Adjoint Optimization of Rectangular Patch Antennas#

This notebook demonstrates the use of inverse design for the optimization of antennas. A simple rectangular patch antenna, as explored in our Introduction to Antenna Simulation notebook, is primarily defined by two parameters: its width and height. If you aren’t familiar with it already, we strongly recommend going through Introduction to Antenna Simulation before this one as we will assume knowledge of the RF modeling workflow in tidy3d in this notebook. These are tuned to make the antenna resonate at a desired frequency and match the impedance of the feed line. Usually, these parameters can be found via intuitive design techniques and numerical formulas. However, in this example, we show a different strategy to tuning the antenna parameters. This notebook demonstrates gradient-based RF optimization in Tidy3D. This technique, enabled by the adjoint method and automatic differentiation (autograd), allows for the efficient and simultaneous optimization of all geometric parameters, also known as inverse design.

We illustrate this process through the straightforward optimization to find the ideal width and height of a rectangular patch antenna for a single target frequency. In future notebooks, we will build on this example and show more intricate patch antenna designs that take advantage of inverse design to simultaneously tune large numbers of geometric parameters to meet user-defined metrics with high performance. The optimization geometry is shown below. The antenna consists of a metallic patch on a substrate with a ground plane, is excited by an offset feed line, and radiates into free space.

e72ac6076a6f4fcab50ad7e600a04f22

[1]:
import os
import pickle

import autograd.numpy as anp
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import numpy as np
import optax
import tidy3d as td
import tidy3d.plugins.smatrix as smatrix
from autograd import value_and_grad
from rich.console import Console
from rich.text import Text
from tidy3d.plugins.microwave import rf_material_library
from tidy3d.web import run

# Setup console and printing parameters for rich printing during optimization loops
console = Console()
print_decimal_places = 2  # how many decimal places to use in printing
print_iteration_frequency = 5  # how often to print optimization progress
17:39:29 EST WARNING: Using canonical configuration directory at                
             '/Users/gregoryroberts/.config/tidy3d'. Found legacy directory at  
             '~/.tidy3d', which will be ignored. Remove it manually or run      
             'tidy3d config migrate --delete-legacy' to clean up.               

Configuration and Parameters#

In this first section, we set up a variety of parameters for the optimization, including the frequency bands (specified in Hz) and resonance target as well as useful geometric parameters. Similar to other RF examples, we introduce a scaling factor to convert the default unit in Tidy3D of micrometers to millimeters (mm), which is more commonly used in antenna simulations. Thus, the default unit when looking at constants in this notebook is millimeters.

[2]:
# length scales and geometric parameters for optimization
mm = 1e3  # mm scaling

# feedline parameters
feed_x = 2.46 * mm  # width of feed
feed_y = 20 * mm  # length of feed
feed_offset = 2.09 * mm  # feed offset from center of patch

# substrate geometric parameters
sub_width = 23.34 * mm
sub_height_extension = 25 * mm

sub_x = [-0.5 * sub_width, 0.5 * sub_width]
sub_y = [-feed_y, sub_height_extension]
sub_z = 0.68 * mm

# center and size for placing directivity and far field monitors to capture radiation from the
# antenna patch
directivity_center_x = np.mean(sub_x)
directivity_center_y = np.mean(sub_y)
directivity_size_x = 5 * mm + sub_x[1] - sub_x[0]
directivity_size_y = 5 * mm + sub_y[1] - sub_y[0]

# frequency range (Hz) for simulating
freq_start = 7e9
freq_stop = 11e9
# simulation frequencies to cover enough bandwidth for evaluating and optimizing antennas
opt_sim_freqs = [8e9, 10e9]

freq0 = 0.5 * (freq_start + freq_stop)  # central frequency
wavelength0 = td.C_0 / freq0  # wavelength of central frequency in vacuum

# frequencies for computing S-parameters of antennas
freqs_s_params = np.linspace(freq_start, freq_stop, 200)

# frequencies for optimizing different
opt_freqs = [8.25e9]  # single patch target frequency

# set ranges of theta and phi to record directivity for
theta_directivity = np.linspace(-np.pi, np.pi, 201)
phi_directivity = np.linspace(0, np.pi, 101)

# set the optimization theta, phi grid for enhancing directivity around theta=0
num_theta_opt_points = 12
theta_opt = theta_directivity[
    (len(theta_directivity) // 2) - (num_theta_opt_points // 2) : (len(theta_directivity) // 2)
    + (num_theta_opt_points // 2)
    + 1
]
phi_opt = phi_directivity  # use the full phi sweep, but limit the theta points to be centered around 0 degrees

# materials for optimization
air = td.Medium()  # set up the antennas so they radiate into air
# choose common PCB material, ArlonAD255C, from the RF material library to use as substrate
sub_medium = rf_material_library["AD255C"]["design"]
PEC = td.PEC2D  # thickness-free PEC medium for antenna patches, feed lines, and the ground plane

Create Simulation Objects#

To prepare for the optimization, we need to make a base simulation that we can add the antenna geometry and excitation source to. This base simulation includes the ground plane and substrate, the two structures below the patch antenna. In this simulation, we also set up LayerRefinementSpec objects around our ground, substrate, and patch antenna layers. The fine mesh improves accuracy of both the simulation and PEC gradients. As in other RF examples, the source will be added later via a LumpedPort.

[3]:
def create_base_sim():
    """
    Creates the base simulation for evaluating and optimizing patch antennas. The base simulation includes the
    substrate geometry, and ground plane. It also overrides a vertical slice of the simulation where the feed and
    patch will be placed with a fine mesh override to ensure accurate simulation and gradients near the PEC boundaries.
    """
    substrate_box = td.Box.from_bounds(
        [sub_x[0], sub_y[0], -sub_z / 2.0],
        [sub_x[1], sub_y[1], sub_z / 2.0],
    )
    # Define substrate structure
    substrate = td.Structure(
        geometry=substrate_box,
        medium=sub_medium,
    )

    # Define ground plane structure and assign the material by PEC
    ground_plane = td.Structure(
        geometry=substrate_box.updated_copy(
            center=list(substrate_box.center[0:2]) + [-sub_z / 2.0],
            size=list(substrate_box.size[0:2]) + [0],
        ),
        medium=PEC,
    )

    # list of structures for the simulation arranged first by dielectric and then PEC to
    # ensure PEC takes precedence at interfaces.
    structures_list = [substrate, ground_plane]

    # PML wavelength at 10 GHz
    wl_pml = wavelength0

    # quarter wavelength (at 10 GHz) padding on each side
    sim_x_size = sub_x[1] - sub_x[0] + wl_pml / 2.0
    sim_y_size = sub_y[1] - sub_y[0] + wl_pml / 2.0
    sim_y_center = np.mean(sub_y)

    sim_z_max = sub_z + 1.5 * wavelength0
    sim_z_min = sub_z - 0.5 * wavelength0
    sim_z_center = 0.5 * (sim_z_max + sim_z_min)
    sim_z_size = sim_z_max - sim_z_min

    # set a fine mesh for the corner refinement based on the center wavelength
    dl = wavelength0 / 200.0
    mesh_override_vertical_padding = 1 * mm

    layer_refinement_ground = td.LayerRefinementSpec.from_structures(
        axis=2, min_steps_along_axis=3, structures=[ground_plane]
    )

    layer_refinement_patch = td.LayerRefinementSpec(
        center=(0, sim_y_center, sub_z / 2),
        size=(td.inf, td.inf, mesh_override_vertical_padding),
        axis=2,
        min_steps_along_axis=3,
        corner_refinement=td.GridRefinement(dl=dl, num_cells=3),
    )

    layer_refinement_substrate = td.LayerRefinementSpec.from_structures(
        axis=2, structures=[substrate], min_steps_along_axis=3
    )

    # Truncate the computational domain by PMLs
    boundary_spec = td.BoundarySpec(
        x=td.Boundary.pml(),
        y=td.Boundary.pml(),
        z=td.Boundary.pml(),
    )

    # Create the simulation object
    base_sim = td.Simulation(
        center=[0.0, sim_y_center, sim_z_center],
        size=[sim_x_size, sim_y_size, sim_z_size],
        grid_spec=td.GridSpec.auto(
            min_steps_per_wvl=20,  # largest cell size is set to 20 cells per smallest wavelength.
            wavelength=td.C_0 / freq_stop,  # smallest wavelength to resolve
            layer_refinement_specs=[
                layer_refinement_ground,
                layer_refinement_substrate,
                layer_refinement_patch,
            ],
        ),
        structures=structures_list,
        sources=[],  # sources will be added by TerminalComponentModeler
        monitors=[],
        run_time=70 * (substrate.geometry.size[1] / td.C_0),
        boundary_spec=boundary_spec,
        plot_length_units="mm",  # this option will make plots default to units of millimeters.
    )

    return base_sim

In addition to the base simulation creation, we set up a function to add a feed line and PEC patches to a simulation object. Later, this will allow us to create an antenna with an arbitrary list of patches and insert it into our base simulation.

[4]:
def create_simulation_with_patches(base_sim, patch_boxes, monitors):
    """
    Creates a new simulation from the base simulation that includes PEC patches for all of the Box
    objects in patch_boxes as well as adds a feed line.
    """
    patches = []
    for patch_box in patch_boxes:
        patches.append(td.Structure(geometry=patch_box, medium=PEC))

    feed_geometry = td.Box.from_bounds(
        [feed_offset - 0.5 * feed_x, -feed_y, sub_z / 2], [feed_offset + 0.5 * feed_x, 0, sub_z / 2]
    )

    feed = td.Structure(geometry=feed_geometry, medium=PEC)

    return base_sim.updated_copy(
        structures=list(base_sim.structures) + [feed] + patches,
        monitors=list(base_sim.monitors) + monitors,
    )

Finally, we set up a function to create a LumpedPort and TerminalComponentModeler that will generate the input excitation to the antenna. We can specify an impedance for the port as well as the desired frequencies for the resulting simulation. The TerminalComponentModeler will set up the simulations we need to evaluate and optimize the antenna.

[5]:
def create_modeler(simulation, frequencies, radiation_monitors=(), port_impedance=50):
    """
    Creates the LumpedPort and TerminalComponentModeler based on a `simulation`, the desired
    simulation `frequencies`, `radiation_monitors` for computing directivity, and a `port_impedance`.
    """
    # Setup a LumpedPort for the TerminalComponentModeler, which is needed
    # to end the port with a matched load as well as providing a source for the simulation.
    port = smatrix.LumpedPort(
        center=[feed_offset, -feed_y, 0],
        size=[feed_x, 0, sub_z],
        voltage_axis=2,
        name="lumped_port",
        impedance=port_impedance,
    )

    # We integrate the base simulation with the LumpedPort using the TerminalComponentModeler.
    # This allows us to compute scattering parameters and extract any additional data needed from the simulation.
    modeler = smatrix.TerminalComponentModeler(
        simulation=simulation,
        ports=[port],
        freqs=frequencies,
        remove_dc_component=False,  # include DC component for more accuracy at low frequencies
        radiation_monitors=radiation_monitors,
    )

    return modeler

Setup Plotting and Evaluation Functions#

Here, we set up some helper functions to aid in plotting antenna geometry and evaluating antenna characteristics before and after optimizations. First, we set up a function to plot the antenna structure and feed line along with the surrounding mesh. Near PEC edges, especially when computing gradients, we recommend using a fine mesh. In this function, we can also observe the location where the input source is fed into the antenna to confirm it is at the end of the feed line. Note: the plotting functions below are useful for visualizations in this notebook, but their internals are not crucial to the understanding of how to set up and run gradient based RF optimizations in tidy3d. Feel free to skim this section and skip to the next section (“Demonstrating Gradient Based Optimization of a Patch Antenna”) whenever you’re ready.

[6]:
def plot_structures_and_mesh(patch_boxes):
    """Plots the antenna structure and surrounding mesh to ensure it looks as expected before running simulations."""
    base_sim = create_base_sim()

    no_additional_monitors = []
    sim_with_patches = create_simulation_with_patches(base_sim, patch_boxes, no_additional_monitors)

    fig, ax = plt.subplots()

    # examine the structure and mesh in the x-y plane
    sim_with_patches.plot(
        z=sub_z / 2,
        ax=ax,
        monitor_alpha=0.2,
    )
    sim_with_patches.plot_grid(z=sub_z / 2, ax=ax)

    plt.show()

Second, we set up a function that can calculate S11 and realized antenna gain for each polarization. S11 is the voltage reflection coefficient and \(|S_{11}|^2\) is the power reflection coefficient or in other words, the reflected power divided by the input power. A good antenna will have a small S11 at its operating frequencies. S11 is plotted in dB and thus at resonance, we will see a large, negative value corresponding to low reflection. Before and after an optimization, this is one way to evaluate how well the optimization tuned the geometry for the correct frequencies. In the realized gain plot, we can see how efficiently energy is radiating from the antenna and in what direction when compared to an isotropic radiator. For simplicity and to keep plots less crowded, we collect the realized gain for the optimization frequencies while we compute S11 value over a broad spectrum.

[7]:
def evaluate_antenna(patch_params, eval_s_params_freqs):
    """
    Evaluate the S11 parameter and realized gain in both polarizations for the given antenna geometry.
    The S11 is calculated across a broad spectrum while the realized gain is only computed for the opt_freqs.
    """
    patch_boxes = params_to_boxes(patch_params)

    base_sim = create_base_sim()
    no_additional_monitors = []
    sim_with_patches = create_simulation_with_patches(base_sim, patch_boxes, no_additional_monitors)

    monitor_directivity = td.DirectivityMonitor(
        center=[directivity_center_x, directivity_center_y, 0],
        size=(
            directivity_size_x,
            directivity_size_y,
            4 * mm,
        ),
        freqs=opt_freqs,
        name="directivity",
        phi=list(phi_directivity),
        theta=list(theta_directivity),
        far_field_approx=True,
    )

    # we only need whatever frequencies are unique in these two lists to have all the data we need for computing
    # S11 and gain
    eval_freqs = np.unique(list(eval_s_params_freqs) + list(opt_freqs))

    modeler = create_modeler(sim_with_patches, eval_freqs, radiation_monitors=[monitor_directivity])
    smatrix_data = run(modeler, task_name="smatrix", verbose=False)

    antenna_parameters_freq = smatrix_data.get_antenna_metrics_data(monitor_name="directivity")
    partial_realized_gain = antenna_parameters_freq.partial_realized_gain(pol_basis="linear")

    return smatrix_data, partial_realized_gain

Given the optimization results, we can plot the comparison of two antenna simulations using the plot_antenna_comparison function below. We can easily compare the initial and final optimization states to see how well the resulting antenna is performing in our desired metrics. During the optimization, we will also periodically save the S11 and gain of the antenna. The function plot_antenna_evolution allows us to plot these metrics out so we can see how performance evolved over the course of the optimization.

[8]:
def plot_antenna_comparison(
    s_matrix_list,
    sim_data_list,
    partial_realized_gain_list,
    plot_phi,
    plot_title="Antenna Simulation Comparison",
    sim_names=None,
    single_color_gain_plots=False,
    savefig_fname=None,
):
    """
    Plots a comparison of two sets of simulation data.

    Args:
        s_matrix_list: List of S-matrix objects [s_matrix_sim1, s_matrix_sim2].
        sim_data_list: List of sim_data objects [sim_data_sim1, sim_data_sim2],
                       where each sim_data contains a "radiation" DirectivityMonitor output.
        partial_realized_gain_list: List of realized gain in linear polarization for each simulation.
        plot_phi: The phi value to select in the realized gain data for plotting.
        plot_title: Optional title to specify for plot (default is "Antenna Simulation Comparison").
        sim_names: Optional list of names for simulations for clearer legends.
        single_color_gain_plots: Optional choice to use a single color for all the frequencies
                                 in the gain plot (True) or have each frequency a different
                                 color (False). Default is False.
        savefig_fname: An optional filename to save the resulting figure
    """

    num_simulations = len(s_matrix_list)
    if num_simulations != 2 or len(sim_data_list) != 2:
        print("Warning: This function is designed to compare exactly two simulations.")

    alphas = [0.5, 1.0]  # Alpha for sim1, sim2
    if sim_names is None or len(sim_names) != num_simulations:
        sim_names = [f"Sim {i + 1}" for i in range(num_simulations)]

    grid_spec_cols = 3  # S11 and realized gain for each polarization side-by-side
    fig_width = grid_spec_cols * 4.5
    fig_height = 6.5

    num_rows = 1

    fig = plt.figure(figsize=(fig_width, fig_height), constrained_layout=True)
    gs = fig.add_gridspec(num_rows, grid_spec_cols)
    axs_list = []

    # plot the S11 parameter comparison for each simulation
    ax_s11 = fig.add_subplot(gs[0, 0])
    axs_list.append(ax_s11)
    ax_s11.set_title("$S_{11}$ Coefficient")
    ax_s11.set_xlabel("Frequency (GHz)")
    ax_s11.set_ylabel("$|S_{11}|^2$ (dB)")

    for sim_idx in range(num_simulations):
        s_matrix = s_matrix_list[sim_idx]
        current_alpha = alphas[sim_idx]
        sim_label_name = sim_names[sim_idx]

        s11_freqs_ghz = s_matrix.data.coords["f"] / 1e9
        s11_data_selection = s_matrix.data.isel(port_out=0, port_in=0)

        s11_values_flat = s11_data_selection.values.flatten()
        s11_values_db = 20 * np.log10(np.abs(s11_values_flat))
        ax_s11.plot(
            s11_freqs_ghz,
            s11_values_db,
            alpha=current_alpha,
            label=f"{sim_label_name})",
        )

    for opt_freq in opt_freqs:
        ax_s11.axvline(x=opt_freq / 1e9, color="k", linestyle="--")

    ax_s11.set_ylim(-25, 2)
    ax_s11.grid(True)
    ax_s11.legend()

    # for each linear polarization component, plot the realized gain for each linear polarization
    polarization_components = ["Gtheta", "Gphi"]

    for pol_idx, pol_component in enumerate(polarization_components):
        ax_polar = fig.add_subplot(gs[0, 1 + pol_idx], projection="polar")
        axs_list.append(ax_polar)
        ax_polar.set_title(f"Realized Gain for {pol_component}")

        ax_polar.set_theta_direction(-1)
        ax_polar.set_theta_offset(np.pi / 2.0)
        ax_polar.grid(True)
        ax_polar.set_rlabel_position(22.5)

        overall_max_gain = -np.inf
        color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]

        for freq_idx, eval_freq in enumerate(opt_freqs):
            base_color = color_cycle[freq_idx % len(color_cycle)]
            if single_color_gain_plots:
                base_color = color_cycle[0]
            freq_label_for_legend = f"{eval_freq / 1e9:.2f} GHz"

            # Plot data for each simulation for the current frequency
            for sim_idx in range(num_simulations):
                current_alpha = alphas[sim_idx]

                if single_color_gain_plots:
                    base_color = color_cycle[sim_idx]

                realized_gain_data = partial_realized_gain_list[sim_idx][pol_component].sel(
                    f=eval_freq, phi=plot_phi, method="nearest"
                )

                gain_values_for_plot = realized_gain_data.squeeze().values

                current_max_val = np.max(gain_values_for_plot)
                if current_max_val > overall_max_gain:
                    overall_max_gain = current_max_val

                # Label only the second simulation's line (alpha=1.0) for the legend entry of this frequency
                label_to_use = f"{freq_label_for_legend} ({sim_names[sim_idx]})"
                ax_polar.plot(
                    theta_directivity,
                    gain_values_for_plot,
                    color=base_color,
                    alpha=current_alpha,
                    label=label_to_use,
                )

        ax_polar.set_rlim(0, overall_max_gain * 1.1 if overall_max_gain > 0 else 1.0)
        ax_polar.legend(title="Frequency (GHz)", loc="best", fontsize="small")

    fig.suptitle(plot_title, fontsize=16)

    if savefig_fname:
        plt.savefig(savefig_fname)

    plt.show()


def plot_antenna_evolution(s11_sq_dB, s11_f, partial_realized_gain, gain_freqs):
    """Plots the evolution of the antenna S11 and and realized gain throughout an optimization.
    Args:
        s11_sq_dB: List of |S11|^2 (in dB) at each point in optimization. The length should match the
                   length of `partial_realized_gain`
        s11_f: Frequencies for each S11 array
        partial_realized_gain: List of realized gain objects broken into linear polarization. The length
                               of this list should match the length of `s11_sq_dB`
        gain_freqs: List of frequencies for each realized gain object.
    """
    num_lines = len(s11_sq_dB)

    alphas = np.linspace(0.25, 1.0, num_lines)

    fig_width = 13.5
    fig_height = 6.5

    fig = plt.figure(figsize=(fig_width, fig_height), constrained_layout=True)
    num_rows = 1
    grid_spec_cols = 3
    gs = fig.add_gridspec(num_rows, grid_spec_cols)
    axs_list = []

    ax_s11 = fig.add_subplot(gs[0, 0])
    axs_list.append(ax_s11)
    ax_s11.set_title("$S_{11}$ Coefficient")
    ax_s11.set_xlabel("Frequency (GHz)")
    ax_s11.set_ylabel("$|S_{11}|^2$ (dB)")

    color_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"]

    for idx in range(0, num_lines):
        ax_s11.plot(s11_f, s11_sq_dB[idx], color=color_cycle[0], linewidth=1.5, alpha=alphas[idx])

    ax_s11.set_ylim(-25, 2)
    ax_s11.grid(True)

    def add_polar_sequence(grid_col, pol_component, title):
        ax_polar = fig.add_subplot(gs[0, grid_col], projection="polar")
        axs_list.append(ax_polar)
        ax_polar.set_title(f"Realized Gain for {pol_component}")

        ax_polar.set_theta_direction(-1)
        ax_polar.set_theta_offset(np.pi / 2.0)
        ax_polar.grid(True)
        ax_polar.set_rlabel_position(22.5)

        for idx in range(0, num_lines):
            partial_realized_gain_batch = partial_realized_gain[idx][pol_component]
            partial_realized_gain_batch = np.reshape(
                partial_realized_gain_batch, (len(gain_freqs), len(theta_directivity))
            )

            for freq_idx in range(0, len(gain_freqs)):
                gain_values_for_plot = partial_realized_gain_batch[freq_idx]

                ax_polar.plot(
                    theta_directivity,
                    gain_values_for_plot,
                    color=color_cycle[freq_idx],
                    alpha=alphas[idx],
                )

            ax_polar.set_title(title)

    add_polar_sequence(1, "Gtheta", "Realized Gain\n(Gtheta), phi=0")
    add_polar_sequence(2, "Gphi", "Realized Gain\n(Gphi), phi=0")

    plt.show()

We set up a function to plot the figure of merit trajectory of the optimization and compare the initial and final antenna geometries. This is an indication of how well the optimization worked as well as a demonstration of the overall change that occurred over the course of the optimization.

[9]:
def plot_optimization_results(
    figure_of_merit, feed_rect, init_rectangles, final_rectangles, savefig_fname=None
):
    """
    Plots a main rectangle and two lists of other rectangles with specified styles.

    Args:
        figure_of_merit: Figure of merit for each iteration during the optimization.
        feed_rect: The rectangular corresponding to the feed line.
        init_rectangles: A list of rectangles corresponding to the initial state
                         of the optimization.
        final_rectangles: A list of rectangles corresponding to the final condition
                         of the optimization.
        savefig_fname: Optional filename to save the resulting figure.

    """
    fig, ax = plt.subplots(1, 2)
    ax[1].set_aspect("equal", adjustable="box")

    all_rects_params = []  # to store (x_bl, y_bl, width, height) for limit calculation

    # helper function to add a rectangle and collect its parameters
    def add_rectangle_to_plot(
        rect_obj, facecolor, alpha=1.0, edgecolor="black", linestyle="solid", legend=None
    ):
        """
        Adds a single rectangle to the plot and collects its parameters.

        Args:
            rect_obj: The rectangle object with 'center' and 'size'.
            facecolor: The face color of the rectangle.
            alpha: Optional transparency of the rectangle.
            edgecolor: Optional edge color of the rectangle.
            linestyle: Optional line style of the rectangle's border.
            legend: Optional legend entry to use for this rectangle.
        """
        center_x, center_y, _ = (coord / mm for coord in rect_obj.center)
        width, height, _ = (sz / mm for sz in rect_obj.size)

        # Calculate bottom-left corner coordinates
        bottom_left_x = center_x - width / 2
        bottom_left_y = center_y - height / 2

        all_rects_params.append((bottom_left_x, bottom_left_y, width, height))

        rect_patch = patches.Rectangle(
            (bottom_left_x, bottom_left_y),
            width,
            height,
            facecolor=facecolor,
            alpha=alpha,
            edgecolor=edgecolor,
            linestyle=linestyle,
            linewidth=1,  # Default linewidth for borders
            label=legend,
        )
        ax[1].add_patch(rect_patch)

    add_rectangle_to_plot(feed_rect, facecolor="gold", edgecolor="black")

    legends_init_rect = [
        "initial" if (idx == 0) else None for idx in range(0, len(init_rectangles))
    ]
    legends_final_rect = [
        "final" if (idx == 0) else None for idx in range(0, len(final_rectangles))
    ]

    # plot rectangles from the second list (gold)
    for idx, rect_obj in enumerate(final_rectangles):
        add_rectangle_to_plot(
            rect_obj, facecolor="gold", edgecolor="black", legend=legends_final_rect[idx]
        )  # Added black edge for consistency

    # plot rectangles from the first list (gray, 0.25 alpha, dotted black border)
    for idx, rect_obj in enumerate(init_rectangles):
        add_rectangle_to_plot(
            rect_obj,
            facecolor="gray",
            alpha=0.25,
            edgecolor="black",
            linestyle="dotted",
            legend=legends_init_rect[idx],
        )

    # calculate plot limits
    if all_rects_params:
        min_x = min(p[0] for p in all_rects_params)
        min_y = min(p[1] for p in all_rects_params)
        max_x = max(p[0] + p[2] for p in all_rects_params)  # max x is bottom_left_x + width
        max_y = max(p[1] + p[3] for p in all_rects_params)  # max y is bottom_left_y + height

        # add some padding to the limits
        padding_x = (max_x - min_x) * 0.1 if (max_x - min_x) > 0 else 1
        padding_y = (max_y - min_y) * 0.1 if (max_y - min_y) > 0 else 1

        ax[1].set_xlim(min_x - padding_x, max_x + padding_x)
        ax[1].set_ylim(min_y - padding_y, max_y + padding_y)
    else:
        # default limits if no rectangles are plotted
        ax[1].set_xlim(-5, 5)
        ax[1].set_ylim(-5, 5)

    ax[1].set_title("Antenna Geometry")
    ax[1].set_xlabel("X-coordinate (mm)")
    ax[1].set_ylabel("Y-coordinate (mm)")
    ax[1].grid(True, linestyle="--", alpha=0.7)

    ax[1].legend(loc="lower left", bbox_to_anchor=(1.0, 0.75))

    ax[0].plot(figure_of_merit, color="green", linewidth=2)
    ax[0].set_title("Optimization")
    ax[0].set_xlabel("Iteration")
    ax[0].set_ylabel("Figure of Merit")

    if savefig_fname:
        plt.savefig(savefig_fname)

    plt.show()

Demonstrating Gradient Based Optimization of a Patch Antenna#

In our optimization, we use the adjoint gradients to tune the width and height of a single patch antenna element. As a first step, we create an objective function that takes in parameters defining the antenna geometry which correspond to Box geometries that can be imported into the simulation. After adding the source via the TerminalComponentModeler, the objective runs a simulation and we compute the S11 spectrum and flux into a band of angles above the antenna. S11 is a measure of how much energy is reflected and the radiated flux over a set of angles gives us a good figure of merit for directivity. We optimize the antenna to direct the radiated power at 0 degrees, directly outward from the antenna plane. The two figures are combined together into one by computing reflection efficiency as \(1 - |S_{11}|^2\) and multiplying by the sum of the flux over a narrow band of angles around 0 degrees normalized by the initial flux in each of these angles, which is specified through poynting_flux_initial. The objective_fn uses the helper function compute_poynting_and_s11 to compute these metrics.

15764d90c7ab4d7996b2abcd7ac1beb5

Below, we show the functional flow for computing the objective function below and where automatic differentiation comes into the picture. By plugging into the autograd library with tidy3d, we can compute the gradient function for our objective function. In each iteration of the optimization, we use this function to calculate the gradient of the objective with respect to the antenna patch parameters. Using this gradient, the parameters are updated to improve the objective function value. This process continues in our case for a fixed number of iterations but can also be done until convergence or some other criteria are met.

81ed7976f794489e9ff394e79764cd0f

[10]:
def compute_poynting_and_s11(
    patch_params,
    log_simulation_cost=False,
):
    """
    Computes the Poynting flux for an antenna. The antenna parameters are specified in and
    `patch_params` are converted these parameters to simulation objects. These objects are inserted
    into a base simulation. After setup, the antenna simulation is run to compute the `S11` and the
    Poynting flux considering just the angular components in the `FieldProjectionAngleMonitor`.
    """
    base_sim = create_base_sim()

    # set up monitor that will be used as a data source for far field projection
    monitor_radiation = td.FieldMonitor(
        center=[directivity_center_x, directivity_center_y, 0.5 * sub_z + 5 * mm],
        size=(directivity_size_x, directivity_size_y, 0.0),
        freqs=opt_freqs,
        name="radiation",
        colocate=False,
    )
    # set up monitor that will be used to project radiation to the far field so we can
    # optimize for directivity
    monitor_far_field = td.FieldProjectionAngleMonitor(
        center=[directivity_center_x, directivity_center_y, 0.5 * sub_z + 5 * mm],
        size=(directivity_size_x, directivity_size_y, 0.0),
        freqs=opt_freqs,
        name="far_field",
        phi=phi_opt,
        theta=theta_opt,
        far_field_approx=True,
        proj_distance=50 * wavelength0,  # project far away form antenna
    )

    antenna_monitors = [monitor_radiation, monitor_far_field]

    # Add antenna patches to simulation
    sim_with_patches = create_simulation_with_patches(
        base_sim, params_to_boxes(patch_params), antenna_monitors
    )

    # Create the `TerminalComponentModeler` to add the source and get the simulation we can run
    # to evaluate the antenna performance
    modeler_freqs = sorted(set(list(opt_sim_freqs) + list(opt_freqs)))

    modeler = create_modeler(
        sim_with_patches,
        modeler_freqs,
    )

    # Run the simulations for the antenna
    if log_simulation_cost:
        job = td.web.Job(simulation=modeler, task_name="smatrix", verbose=False)
        smatrix_data = job.run()
        cost = td.web.real_cost(job.task_id)
        console.print(
            f"The antenna cost [bold yellow]{cost:.3f}[/bold yellow] FlexCredits to run. "
            f"Each iteration of the optimization will cost around "
            f"[bold cyan]{2 * cost:.3f}[/bold cyan] FlexCredits to complete."
        )
    else:
        smatrix_data = run(modeler, task_name="smatrix", verbose=False, local_gradient=False)

    # Set up a `FieldProjector` based on the 'radiation' monitor near the patch
    projector = td.FieldProjector.from_near_field_monitors(
        sim_data=smatrix_data.data["lumped_port"],
        near_monitors=[monitor_radiation],
        normal_dirs=["+"],  # we are projecting along the +z direction
    )
    # Project this near field into the subset of far field components
    # specified by the 'far_field' monitor
    radiation_data = projector.project_fields(monitor_far_field)
    poynting_flux = np.abs(
        np.real(
            0.5
            * (
                radiation_data.Etheta * np.conj(radiation_data.Hphi)
                - radiation_data.Ephi * np.conj(radiation_data.Htheta)
            )
        )
    )

    return poynting_flux, smatrix_data.smatrix()


def objective_fn(patch_params, poynting_flux_initial):
    """
    Objective function for an antenna optimization that computes a product of the reflection
    efficiency and the increased flux in a set of optimization angles compared to the initial design.
    """

    def weights_from_merit(merit):
        """
        Computes performance based weights that sum to a total weight of 1.
        For a given frequency, the weights are inversely tied to the performance
        so that figures of merit that are lagging get favored more than those that
        are leading.
        """
        weights = (2.0 / len(merit)) - (merit**2 / anp.sum(merit**2))
        clip_weights = anp.maximum(weights, 0.0)

        inv_weights = 1.0 / anp.sum(clip_weights)
        renorm_weights = clip_weights * inv_weights

        return renorm_weights

    poynting_flux, smatrix = compute_poynting_and_s11(patch_params)

    fom_by_freq = []
    for freq in opt_freqs:
        directivity_metric = np.sum(poynting_flux.sel(f=freq).data) / np.sum(
            poynting_flux_initial.sel(f=freq).data
        )

        s11 = np.abs(smatrix.data.isel(port_out=0, port_in=0).sel(f=freq).data)

        fom = (1 - np.abs(s11) ** 2) * directivity_metric
        fom_by_freq.append(fom)

    fom_by_freq = anp.array(fom_by_freq)

    if len(fom_by_freq) > 1:
        weights = weights_from_merit(fom_by_freq)  # dynamic optimization weights
        return anp.sum(weights * fom_by_freq)
    else:
        return anp.sum(fom_by_freq)

We create a helper function to convert the patch width and height parameters to the antenna geometry for a single patch. To check our setup, we plot what the resulting antenna will look like when inserted into a simulation.

[11]:
def params_to_boxes(patch_params):
    """Convert single patch parameters into patch geometry."""
    patch_wh = patch_params[0:2]
    main_patch = td.Box.from_bounds(
        [-0.5 * patch_wh[0], 0.0, sub_z / 2.0], [0.5 * patch_wh[0], patch_wh[1], sub_z / 2.0]
    )
    return [main_patch]


# set an initial width and height for the patch
patch_init_width = 12.45 * mm
patch_init_height = 16 * mm
patch_init_wh = anp.array([patch_init_width, patch_init_height])

# set the min/max for the patch width and height to use to bound these parameters in the optimization
patch_min_width = 8 * mm
patch_min_height = 8 * mm

patch_max_width = 18 * mm
patch_max_height = 24 * mm

# plot the patch and mesh of the initial structure to visually inspect geometry before starting optimization
plot_structures_and_mesh(params_to_boxes(patch_init_wh))
../_images/notebooks_RFAutograd1RectangularPatchAntenna_21_0.png

Now, we set up the optimization. We create the monitors to capture the far field radiation and solve for the initial flux into the target radiation angular components. Then, we configure the optimizer which we can also load from a saved checkpoint by toggling the restart_optimization flag if we get interrupted in the middle. When computing the initial values of S11 and the radiated flux, we log the simulation cost to give an idea of how many FlexCredits it costs run. During the optimization loop, each iteration we compute the gradient, which requires two simulations. The cost per iteration will thus be double that of a single simulation except for the occasional iteration where we save additional simulation data for evaluation after the optimization. On those iterations, the cost will be 3x the single simulation cost. To reduce the number of the full evaluations, you can increase full_eval_period.

[12]:
# Turn the logging level to only show errors to not crowd the optimization output with repeated warnings
td.config.logging_level = "ERROR"

# whether or not to restart the optimization from the current saved checkpoint
restart_optimization = False
restart_fname = "misc/rf_autograd_1_patch_opt_state.pkl"

# set up the single patch radiation monitor sizes
obj_fn_monitor_dim = 1.25 * np.mean(patch_init_wh)
obj_fn_monitor_center_y = 0.5 * patch_init_wh[1]

# compute initial poynting flux to be used in the objective function
initial_poynting_flux, initial_s11 = compute_poynting_and_s11(
    patch_init_wh, log_simulation_cost=True
)
17:40:35 EST Billed flex credit cost: 0.025.
The antenna cost 0.025 FlexCredits to run. Each iteration of the optimization will cost around 0.050 FlexCredits to
complete.

Finally, we run the optimization and capture the figure of merit, S11, and gain metrics along the way.

[13]:
# Set up the objective function for the single patch that is a function
# of the patch width and height. Using autograd, we can find the gradient
# of the objective function with respect to the first argument, which contains
# the (width, height) parameters describing the rectangular patch.
obj_val_and_grad = value_and_grad(objective_fn, argnum=[0])
patch_wh = patch_init_wh.copy()

# Set up optimization parameters and optimizer
learning_rate = 30
optimizer = optax.adam(learning_rate=learning_rate, b1=0.8)
opt_state = optimizer.init(patch_wh)

num_steps = 60

full_eval_period = 10
s11_sq_dB_evolution = []
s11_f_ghz = None
realized_gain_evolution = []

# Set bounds on patch width and height in the optimization
clip_min_single = anp.array([patch_min_width, patch_min_height])
clip_max_single = anp.array([patch_max_width, patch_max_height])

figure_of_merit = np.zeros(num_steps + 1)


def pack_optimization_state(iter_idx):
    optimization_state["iteration"] = iter_idx
    optimization_state["figure of merit"] = figure_of_merit
    optimization_state["realized gain evolution"] = realized_gain_evolution
    optimization_state["opt state"] = opt_state
    optimization_state["patch parameters"] = patch_wh


optimization_state = {}
if not os.path.exists(restart_fname):
    pack_optimization_state(iter_idx=0)

if restart_optimization:
    with open(restart_fname, "rb") as file:
        optimization_state = pickle.load(file)

start_idx = 0
if restart_optimization:
    start_idx = optimization_state["iteration"]
    figure_of_merit = optimization_state["figure of merit"]
    realized_gain_evolution = optimization_state["realized gain evolution"]
    opt_state = optimization_state["opt state"]
    patch_wh = optimization_state["patch parameters"]

# run optimization loop, updating the patch parameters with the `Adam` optimizer
# at each step and clipping the design on the bounds
for step_idx in range(start_idx, num_steps):
    pack_optimization_state(iter_idx=step_idx)
    with open(restart_fname, "wb") as file:
        pickle.dump(optimization_state, file)

    f, g = obj_val_and_grad(patch_wh, initial_poynting_flux)

    figure_of_merit[step_idx] = f

    if (step_idx % full_eval_period) == 0:
        smatrix_data_eval, realized_gain_eval = evaluate_antenna(patch_wh, freqs_s_params)
        smatrix_eval = smatrix_data_eval.smatrix()
        smatrix_f_ghz = smatrix_eval.data.coords["f"] / 1e9
        s11_f_ghz = smatrix_f_ghz.copy()
        s11_data_selection = smatrix_eval.data.isel(port_out=0, port_in=0)
        s11_sq_dB_evolution.append(20 * np.log10(np.abs(s11_data_selection.values.flatten())))

        realized_gain_evolution.append(
            {
                "Gtheta": realized_gain_eval["Gtheta"]
                .sel(phi=0, method="nearest")
                .squeeze()
                .values,
                "Gphi": realized_gain_eval["Gphi"].sel(phi=0, method="nearest").squeeze().values,
            }
        )

    updates, opt_state = optimizer.update(-g[0], opt_state, patch_wh)

    patch_wh = optax.apply_updates(patch_wh, updates)
    patch_wh = anp.array(patch_wh)
    patch_wh = anp.clip(patch_wh, a_min=clip_min_single, a_max=clip_max_single)

    if (step_idx % print_iteration_frequency) == 0:
        output_text = Text.assemble(
            (f"Iteration {step_idx + 1}", "bold cyan"),
            "\n",
            ("\tParams (width, height) (mm): ", ""),
            (
                f"{(patch_wh[0] / mm):.{print_decimal_places}f}, {(patch_wh[1] / mm):.{print_decimal_places}f}",
                "blue",
            ),
            "\n",
            ("\tFigure of merit:          ", "bold"),
            (f"{f:.{print_decimal_places}f}", "bold green"),
        )
        console.print(output_text)

figure_of_merit[num_steps] = objective_fn(patch_wh, initial_poynting_flux)
Iteration 1
        Params (width, height) (mm): 12.42, 15.97
        Figure of merit:          0.11
Iteration 6
        Params (width, height) (mm): 12.27, 15.82
        Figure of merit:          0.13
Iteration 11
        Params (width, height) (mm): 12.10, 15.66
        Figure of merit:          0.17
Iteration 16
        Params (width, height) (mm): 11.92, 15.48
        Figure of merit:          0.23
Iteration 21
        Params (width, height) (mm): 11.71, 15.28
        Figure of merit:          0.36
Iteration 26
        Params (width, height) (mm): 11.48, 15.06
        Figure of merit:          0.67
Iteration 31
        Params (width, height) (mm): 11.21, 14.80
        Figure of merit:          1.65
Iteration 36
        Params (width, height) (mm): 10.92, 14.51
        Figure of merit:          5.78
Iteration 41
        Params (width, height) (mm): 10.62, 14.20
        Figure of merit:          18.95
Iteration 46
        Params (width, height) (mm): 10.50, 14.07
        Figure of merit:          19.34
Iteration 51
        Params (width, height) (mm): 10.60, 14.12
        Figure of merit:          20.82
Iteration 56
        Params (width, height) (mm): 10.61, 14.10
        Figure of merit:          20.60

Below, we plot the optimization output, including the figure of merit as a function of iteration and a comparison of the initial and final patch geometry. During the optimization, we also tracked how the S11 coefficient and realized gain were evolving as the optimizer tuned the patch width and height. This evolution is visualized as well, showing the resonance moving towards the desired frequency and the antenna gain increasing accordingly. In the visualization the lines get darker moving from the beginning of the optimization to the end.

[14]:
# Plot the results of the optimization including the optimization trajectory and the resulting patch geometry
# compared to the initial state
plot_optimization_results(
    figure_of_merit,
    td.Box(center=(feed_offset, -0.5 * feed_y, 0.0), size=(feed_x, feed_y, 0.0)),
    params_to_boxes(patch_init_wh),
    params_to_boxes(patch_wh),
)

# Plot the evolution of the antenna S11 and realized gain during the course of the optimization
plot_antenna_evolution(
    s11_sq_dB_evolution,
    s11_f_ghz,
    realized_gain_evolution,
    opt_freqs,
)
../_images/notebooks_RFAutograd1RectangularPatchAntenna_27_0.png
../_images/notebooks_RFAutograd1RectangularPatchAntenna_27_1.png

Now, we plot a performance comparison between our initial final antenna states including the realized gain for both polarization.

[15]:
# Compute S11 and realized gain for the initial patch geometry
smatrix_data_init, realized_gain_init = evaluate_antenna(patch_init_wh, freqs_s_params)
# Compute S11 and realized gain for the optimized patch geometry
smatrix_data_final, realized_gain_final = evaluate_antenna(patch_wh, freqs_s_params)

# Plot the comparison in S11 and gain for the initial and optimized antennas for phi=0
plot_phi = 0.0
plot_antenna_comparison(
    [smatrix_data_init.smatrix(), smatrix_data_final.smatrix()],
    [smatrix_data_init.data, smatrix_data_final.data],
    [realized_gain_init, realized_gain_final],
    plot_phi,
    sim_names=["before optimization", "after optimization"],
    plot_title="Antenna Comparison (phi=0)",
)

# Plot the comparison in S11 and gain for the initial and optimized antennas for phi=pi / 2
plot_phi = np.pi / 2.0
plot_antenna_comparison(
    [smatrix_data_init.smatrix(), smatrix_data_final.smatrix()],
    [smatrix_data_init.data, smatrix_data_final.data],
    [realized_gain_init, realized_gain_final],
    plot_phi,
    sim_names=["before optimization", "after optimization"],
    plot_title="Antenna Comparison (phi=\u03a0 / 2)",
)
# Save results of optimization
np.save("misc/rf_autograd_1_patch_antenna_optimization.npy", patch_wh)
../_images/notebooks_RFAutograd1RectangularPatchAntenna_29_0.png
../_images/notebooks_RFAutograd1RectangularPatchAntenna_29_1.png

Inverse design and the adjoint method will become increasingly useful when the degrees of freedom in the optimization get larger and the target objective functions are more challenging. The goal of this notebook was to introduce the concept and show how we can use gradient-based optimization methods for RF in Tidy3D. In future notebooks, we will demonstrate how to take advantage of this process to find designs for dual-band patch antennas and other more challenging RF problems.