Inverse design optimization of a compact grating coupler#
Note: Tidy3D now supports automatic differentiation natively through
autograd
. Thejax
-basedadjoint
plugin will be deprecated from 2.7 onwards. To see this notebook implemented in the new feature, see this notebook.
To install the
jax
module required for this feature, we recommend runningpip install "tidy3d[jax]"
.
This notebook contains a long optimization. Running the entire notebook will cost about 10 FlexCredits and take a few hours.
The ability to couple light in and out of photonic integrated circuits (PICs) is crucial for developing wafer-scale systems and tests. This need makes designing efficient and compact grating couplers an important task in the PIC development cycle. In this notebook, we will demonstrate how to use Tidy3Dβs adjoint plugin to perform the inverse design of a compact 3D grating coupler. We will show how to improve design fabricability by enhancing permittivity binarization and controlling the deviceβs minimum feature size.
In addition, if you are interested in more conventional designs, we modeled an uniform grating coupler and a Focusing apodized grating coupler in previous case studies. For more integrated photonic examples, please visit our examples page. If you are new to the finite-difference time-domain (FDTD) method, we highly recommend going through our FDTD101 tutorials. FDTD simulations can diverge due to various reasons. If you run into any simulation divergence issues, please follow the steps outlined in our troubleshooting guide to resolve it.
We start by importing our typical python packages, jax
, tidy3d
and its adjoint
plugin.
[1]:
# Standard python imports.
from typing import List
import numpy as np
import scipy as sp
import matplotlib.pylab as plt
import os
import json
import pydantic as pd
from typing import Callable
# Import jax to be able to use automatic differentiation.
import jax.numpy as jnp
import jax.scipy as jsp
from jax import value_and_grad
# Import regular tidy3d.
import tidy3d as td
import tidy3d.web as web
# Import the components we need from the adjoint plugin.
from tidy3d.plugins.adjoint import (
JaxSimulation,
JaxBox,
JaxCustomMedium,
JaxStructure,
JaxSimulationData,
JaxDataArray,
JaxPermittivityDataset,
)
from tidy3d.plugins.adjoint.web import run
from tidy3d.plugins.adjoint.utils.penalty import ErosionDilationPenalty
Grating Coupler Inverse Design Configuration#
The grating coupler inverse design begins with a rectangular design region connected to a \(Si\) waveguide. Throughout the optimization process, this initial structure evolves to convert a vertically incident Gaussian-like mode from an optical fiber into a guided mode and then funnel it into the \(Si\) waveguide.
We are considering a full-etched grating structure, so a \(SiO_{2}\) BOX layer is included. To reduce backreflection, we adjusted the fiber tilt angle to \(10^{\circ}\) [1, 2].
In the following block of code, you can find the parameters that can be modified to configure the grating coupler structure, optimization, and simulation setup. Special care should be devoted to the it_per_step
and opt_steps
variables bellow.
[2]:
# Geometric parameters.
w_thick = 0.22 # Waveguide thickness (um).
w_width = 0.5 # Waveguide width (um).
w_length = 1.0 # Waveguide length (um).
box_thick = 1.6 # SiO2 BOX thickness (um).
spot_size = 2.5 # Spot size of the input Gaussian field regarding a lensed fiber (um).
fiber_tilt = 10.0 # Fiber tilt angle (degrees).
src_offset = 0.05 # Distance between the source focus and device (um).
# Material.
nSi = 3.48 # Silicon refractive index.
nSiO2 = 1.44 # Silica refractive index.
# Design region parameters.
gc_width = 4.0 # Grating coupler width (um).
gc_length = 4.0 # Grating coupler length (um).
dr_grid_size = 0.02 # Grid size within the design region (um).
# Inverse design set up parameters.
#################################################################
# Total number of iterations = opt_steps x it_per_step.
it_per_step = 1 # Number of iterations per optimization step.
opt_steps = 75 # Number of optimization steps.
#################################################################
eta = 0.50 # Threshold value for the projection filter.
fom_name = "fom_field" # Name of the monitor used to compute the objective function.
# Simulation wavelength.
wl = 1.55 # Central simulation wavelength (um).
bw = 0.06 # Simulation bandwidth (um).
n_wl = 61 # Number of wavelength points within the bandwidth.
# feature size
min_feature_size = 0.080
filter_radius = min_feature_size
# Buffer layer thickness
border_buffer = 0.16
# projection
beta_min = 1.0
beta_max = 30.0
[3]:
total_iter = opt_steps * it_per_step
print(f"Total iterations = {total_iter}")
Total iterations = 75
Inverse Design Optimization Set Up#
We will calculate the values of some parameters used throughout the inverse design set up.
[4]:
# Minimum and maximum values for the permittivities.
eps_max = nSi**2
eps_min = 1.0
# Material definitions.
mat_si = td.Medium(permittivity=eps_max) # Waveguide material.
mat_sio2 = td.Medium(permittivity=nSiO2**2) # Substrate material.
# Wavelengths and frequencies.
wl_max = wl + bw / 2
wl_min = wl - bw / 2
wl_range = np.linspace(wl_min, wl_max, n_wl)
freq = td.C_0 / wl
freqs = td.C_0 / wl_range
freqw = 0.5 * (freqs[0] - freqs[-1])
run_time = 5e-12
# Computational domain size.
pml_spacing = 0.6 * wl
size_x = pml_spacing + w_length + gc_length + 2 * border_buffer
size_y = gc_width + 2 * pml_spacing + 2 * border_buffer
size_z = w_thick + box_thick + 2 * pml_spacing
center_z = size_z / 2 - pml_spacing - w_thick / 2
eff_inf = 1000
# Inverse design variables.
src_pos_z = w_thick / 2 + src_offset
mon_pos_x = -size_x / 2 + 0.25 * wl
mon_w = int(3 * w_width / dr_grid_size) * dr_grid_size
mon_h = int(5 * w_thick / dr_grid_size) * dr_grid_size
nx = int((gc_length + 2 * border_buffer) / dr_grid_size)
ny = int((gc_width + 2 * border_buffer) / dr_grid_size / 2.0)
npar = int(nx * ny)
dr_size_x = nx * dr_grid_size
dr_size_y = 2 * ny * dr_grid_size
dr_center_x = -size_x / 2 + w_length + dr_size_x / 2
n_border = int(border_buffer / dr_grid_size)
First, we will introduce the simulation components that do not change during optimization, such as the \(Si\) waveguide and \(SiO_{2}\) BOX layer. Additionally, we will include a Gaussian source to drive the simulations, and a mode monitor to compute the objective function.
[5]:
# Input/output waveguide.
waveguide = td.Structure(
geometry=td.Box.from_bounds(
rmin=(-eff_inf, -w_width / 2, -w_thick / 2),
rmax=(-size_x / 2 + w_length, w_width / 2, w_thick / 2),
),
medium=mat_si,
)
# SiO2 BOX layer.
sio2_substrate = td.Structure(
geometry=td.Box.from_bounds(
rmin=(-eff_inf, -eff_inf, -w_thick / 2 - box_thick),
rmax=(eff_inf, eff_inf, -w_thick / 2),
),
medium=mat_sio2,
)
# Si substrate.
si_substrate = td.Structure(
geometry=td.Box.from_bounds(
rmin=(-eff_inf, -eff_inf, -eff_inf),
rmax=(eff_inf, eff_inf, -w_thick / 2 - box_thick),
),
medium=mat_si,
)
# Gaussian source focused above the grating coupler.
gauss_source = td.GaussianBeam(
center=(dr_center_x, 0, src_pos_z),
size=(dr_size_x - 2 * border_buffer, dr_size_y - 2 * border_buffer, 0),
source_time=td.GaussianPulse(freq0=freq, fwidth=freqw),
pol_angle=np.pi / 2,
angle_theta=fiber_tilt * np.pi / 180.0,
direction="-",
num_freqs=7,
waist_radius=spot_size / 2,
)
# Monitor where we will compute the objective function from.
mode_spec = td.ModeSpec(num_modes=1, target_neff=nSi)
fom_monitor = td.ModeMonitor(
center=[mon_pos_x, 0, 0],
size=[0, mon_w, mon_h],
freqs=[freq],
mode_spec=mode_spec,
name=fom_name,
)
Now, we will define a random vector of initial design parameters or load a previously designed structure.
Note: if a previous optimization file is found, the optimizer will pick up where that left off instead.
[6]:
init_par = np.random.uniform(0, 1, int(npar))
init_par = sp.ndimage.gaussian_filter(init_par, 1)
init_par = init_par.reshape((nx, ny))
Fabrication Constraints#
We will use jax
to build functions that improve device fabricability. A classical conic density filter, which is popular in topology optimization problems, is used to enforce a minimum feature size specified by the filter_radius
variable. Next, a hyperbolic tangent projection function is applied to eliminate grayscale and obtain a binarized permittivity pattern. The beta
parameter controls the sharpness of the transition in the projection function, and for better results, this
parameter should be gradually increased throughout the optimization process. Finally, the design parameters are transformed into permittivity values. For a detailed review of these methods, refer to [3].
We will also introduce a buffer layer around the design region to enhance fabricability at the interfaces. The permittivity is enforced to lower values within the buffer layer, except at the output waveguide connection where we want a smooth transition.
[7]:
from tidy3d.plugins.adjoint.utils.filter import ConicFilter, BinaryProjector
conic_filter = ConicFilter(radius=filter_radius, design_region_dl=dr_grid_size)
def tanh_projection(x, beta, eta=0.5):
tanhbn = jnp.tanh(beta * eta)
num = tanhbn + jnp.tanh(beta * (x - eta))
den = tanhbn + jnp.tanh(beta * (1 - eta))
return num / den
def filter_project(x, beta, eta=0.5):
x = conic_filter.evaluate(x)
return tanh_projection(x, beta=beta, eta=eta)
def interface_buffer(params):
"""Introduce a buffer around design to enhance fabricability at the interfaces."""
par = jnp.asarray(params)
par = par.at[0:n_border, :].set(0)
par = par.at[nx - n_border :, :].set(0)
par = par.at[:, ny - n_border :].set(0)
par = par.at[0:n_border, 0 : int((w_width / 2) / dr_grid_size) + 1].set(1)
return par
def pre_process(params, beta):
"""Get the permittivity values (1, eps_wg) array as a funciton of the parameters (0,1)"""
params1 = interface_buffer(params)
params2 = filter_project(params1, beta=beta)
params3 = filter_project(params2, beta=beta)
return params3
def get_eps_values(params, beta):
params = pre_process(params, beta=beta)
eps_values = eps_min + (eps_max - eps_min) * params
return eps_values
[8]:
def get_eps(design_param, beta: float = 1.00, binarize: bool = False) -> np.ndarray:
"""Returns the permittivities after applying a conic density filter on design parameters
to enforce fabrication constraints, followed by a binarization projection function
which reduces grayscale.
Parameters:
design_param: np.ndarray
Vector of design parameters.
beta: float = 1.0
Sharpness parameter for the projection filter.
binarize: bool = False
Enforce binarization.
Returns:
eps: np.ndarray
Permittivity vector.
"""
# Calculates the permittivities from the transformed design parameters.
eps = get_eps_values(design_param, beta=beta)
if binarize:
eps = jnp.where(eps < (eps_min + eps_max) / 2, eps_min, eps_max)
else:
eps = jnp.where(eps < eps_min, eps_min, eps)
eps = jnp.where(eps > eps_max, eps_max, eps)
return eps
The permittivity values obtained from the design parameters are then used to build a JaxCustomMedium. As we will consider symmetry about the x-axis in the simulations, only the upper-half part of the design region needs to be populated. A JaxStructure
built using the JaxCustomMedium
will be returned by the following function:
[9]:
def update_design(eps, unfold: bool = False) -> List[JaxStructure]:
# Reflects the structure about the x-axis.
nyii = ny
y_min = 0
dr_s_y = dr_size_y / 2
dr_c_y = dr_s_y / 2
eps_val = jnp.array(eps).reshape((nx, ny, 1, 1))
if unfold:
nyii = 2 * ny
y_min = -dr_size_y / 2
dr_s_y = dr_size_y
dr_c_y = 0
eps_val = np.concatenate((np.fliplr(np.copy(eps_val)), eps_val), axis=1)
# Definition of the coordinates x,y along the design region.
coords_x = [(dr_center_x - dr_size_x / 2) + ix * dr_grid_size for ix in range(nx)]
coords_y = [y_min + iy * dr_grid_size for iy in range(nyii)]
coords = dict(x=coords_x, y=coords_y, z=[0], f=[freq])
# Creation of a custom medium using the values of the design parameters.
eps_components = {
f"eps_{dim}{dim}": JaxDataArray(values=eps_val, coords=coords) for dim in "xyz"
}
eps_dataset = JaxPermittivityDataset(**eps_components)
eps_medium = JaxCustomMedium(eps_dataset=eps_dataset)
box = JaxBox(center=(dr_center_x, dr_c_y, 0), size=(dr_size_x, dr_s_y, w_thick))
design_structure = JaxStructure(geometry=box, medium=eps_medium)
return [design_structure]
Next, we will write a function to return the JaxSimulation
object. Note that we are using a MeshOverrideStructure
to obtain a uniform mesh over the design region.
[10]:
def make_adjoint_sim(
design_param, beta: float = 1.00, unfold: bool = False, binarize: bool = False
) -> JaxSimulation:
# Builds the design region from the design parameters.
eps = get_eps(design_param, beta, binarize)
design_structure = update_design(eps, unfold=unfold)
# Creates a uniform mesh for the design region.
adjoint_dr_mesh = td.MeshOverrideStructure(
geometry=td.Box(
center=(dr_center_x, 0, 0), size=(dr_size_x, dr_size_y, w_thick)
),
dl=[dr_grid_size, dr_grid_size, dr_grid_size],
enforce=True,
)
return JaxSimulation(
size=[size_x, size_y, size_z],
center=[0, 0, -center_z],
grid_spec=td.GridSpec.auto(
wavelength=wl_max,
min_steps_per_wvl=15,
override_structures=[adjoint_dr_mesh],
),
symmetry=(0, -1, 0),
structures=[waveguide, sio2_substrate, si_substrate],
input_structures=design_structure,
sources=[gauss_source],
monitors=[],
output_monitors=[fom_monitor],
run_time=run_time,
subpixel=True,
)
Letβs visualize the simulation set up and verify if all the elements are in their correct places.
[11]:
init_design = make_adjoint_sim(init_par, beta=beta_min)
fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(10, 10))
init_design.plot_eps(z=0, ax=ax1)
init_design.plot_eps(y=0, ax=ax2)
plt.show()
Optimization#
We need to provide an objective function and its gradients with respect to the design parameters of the optimization algorithm.
Our figure-of-merit (FOM) is the coupling efficiency of the incident power into the fundamental transverse electric mode of the \(Si\) waveguide. The optimization algorithm will call the objective function at each iteration step. Therefore, the objective function will create the adjoint simulation, run it, and return the FOM value.
[12]:
# Figure of Merit (FOM) calculation.
def fom(sim_data: JaxSimulationData) -> float:
"""Return the power at the mode index of interest."""
output_amps = sim_data.output_data[0].amps
amp = output_amps.sel(direction="-", f=freq, mode_index=0)
return jnp.sum(jnp.abs(amp) ** 2)
def penalty(params, beta) -> float:
"""Penalty function based on amount of change in parameters after erosion and dilation."""
params_processed = pre_process(params, beta=beta)
ed_penalty = ErosionDilationPenalty(length_scale=filter_radius, pixel_size=dr_grid_size)
return ed_penalty.evaluate(params_processed)
# Objective function to be passed to the optimization algorithm.
def obj(
design_param, beta: float = 1.0, step_num: int = None, verbose: bool = False
) -> float:
sim = make_adjoint_sim(design_param, beta)
task_name = "inv_des"
if step_num:
task_name += f"_step_{step_num}"
sim_data = run(sim, task_name=task_name, verbose=verbose)
fom_val = fom(sim_data)
feature_size_penalty = penalty(design_param, beta=beta)
J = fom_val - feature_size_penalty
return J, sim_data
# Function to calculate the objective function value and its
# gradient with respect to the design parameters.
obj_grad = value_and_grad(obj, has_aux=True)
Next we will define the optimizer using optax
. We will save the optimization progress in a pickle
file. If that file is found, it will pick up the optimization from the last state. Otherwise, we will create a blank history.
[13]:
import optax
import pickle
# hyperparameters
learning_rate = 0.3
optimizer = optax.adam(learning_rate=learning_rate)
# where to store history
history_fname = "misc/grating_coupler_history.pkl"
def save_history(history_dict: dict) -> None:
"""Convenience function to save the history to file."""
with open(history_fname, "wb") as file:
pickle.dump(history_dict, file)
def load_history() -> dict:
"""Convenience method to load the history from file."""
with open(history_fname, "rb") as file:
history_dict = pickle.load(file)
return history_dict
Checking For a Previous Optimization#
If history_fname
is a valid file, the results of a previous optimization are loaded, then the optimization will continue from the last iteration step. If the optimization was completed, only the final structure will be simulated. The pickle file used in this notebook can be downloaded from our documentation repo.
[14]:
try:
history_dict = load_history()
opt_state = history_dict["opt_states"][-1]
params = history_dict["params"][-1]
num_iters_completed = len(history_dict["params"])
print("Loaded optimization checkpoint from file.")
print(
f"Found {num_iters_completed} iterations previously completed out of {total_iter} total."
)
if num_iters_completed < total_iter:
print(f"Will resume optimization.")
else:
print("Optimization completed, will return results.")
except FileNotFoundError:
params = np.array(init_par)
opt_state = optimizer.init(params)
history_dict = dict(
values=[],
params=[],
gradients=[],
opt_states=[opt_state],
data=[],
beta=[],
)
Loaded optimization checkpoint from file.
Found 75 iterations previously completed out of 75 total.
Optimization completed, will return results.
[15]:
iter_done = len(history_dict["values"])
for i in range(iter_done, total_iter):
print(f"iteration = ({i + 1} / {total_iter})")
# compute gradient and current objective funciton value
perc_done = i / (total_iter - 1)
beta_i = beta_min * (1 - perc_done) + beta_max * perc_done
(value, sim_data_i), gradient = obj_grad(params, beta=beta_i)
# outputs
print(f"\tbeta = {beta_i}")
print(f"\tJ = {value:.4e}")
print(f"\tgrad_norm = {np.linalg.norm(gradient):.4e}")
# compute and apply updates to the optimizer based on gradient (-1 sign to maximize obj_fn)
updates, opt_state = optimizer.update(-gradient, opt_state, params)
params = optax.apply_updates(params, updates)
# cap parameters between 0 and 1
params = jnp.minimum(params, 1.0)
params = jnp.maximum(params, 0.0)
# save history
history_dict["values"].append(value)
history_dict["params"].append(params)
history_dict["beta"].append(beta_i)
history_dict["gradients"].append(gradient)
history_dict["opt_states"].append(opt_state)
# history_dict["data"].append(sim_data_i) # uncomment to store data, can create large files
save_history(history_dict)
Optimization Results#
After 150 iterations, a coupling efficiency value of 0.71 (-1.48 dB) was achieved at the central wavelength.
[16]:
obj_vals = np.array(history_dict["values"])
final_par = history_dict["params"][-1]
final_beta = history_dict["beta"][-1]
[17]:
fig, ax = plt.subplots(1, 1, figsize=(6, 4))
ax.plot(obj_vals, "ro")
ax.set_xlabel("iterations")
ax.set_ylabel("objective function")
ax.set_ylim(-1, 1)
ax.set_title(f"Final Objective Function Value: {obj_vals[-1]:.2f}")
plt.show()
The final grating coupler structure is well binarized, with mostly black (eps_max
) and white (eps_min
) regions.
[18]:
fig, ax = plt.subplots(1, figsize=(4, 4))
sim_final = make_adjoint_sim(final_par, beta=final_beta, unfold=True)
sim_final = sim_final.to_simulation()[0]
sim_final.plot_eps(z=0, source_alpha=0, monitor_alpha=0, ax=ax)
plt.show()
Once the inverse design is complete, we can visualize the field distributions and the wavelength dependent coupling efficiency.
[19]:
# Field monitors to visualize the final fields.
field_xy = td.FieldMonitor(
size=(td.inf, td.inf, 0),
freqs=[freq],
name="field_xy",
)
field_xz = td.FieldMonitor(
size=(td.inf, 0, td.inf),
freqs=[freq],
name="field_xz",
)
# Monitor to compute the grating coupler efficiency.
gc_efficiency = td.ModeMonitor(
center=[mon_pos_x, 0, 0],
size=[0, mon_w, mon_h],
freqs=freqs,
mode_spec=mode_spec,
name="gc_efficiency",
)
sim_final = sim_final.copy(update=dict(monitors=(field_xy, field_xz, gc_efficiency)))
sim_data_final = web.run(sim_final, task_name="inv_des_final")
14:03:22 EDT Created task 'inv_des_final' with task_id 'fdve-ef15ddf7-f3f8-4925-8752-d687f37fba09' and task_type 'FDTD'.
View task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=fdve-ef15ddf7-f3f 8-4925-8752-d687f37fba09'.
/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/ rich/live.py:231: UserWarning: install "ipywidgets" for Jupyter support warnings.warn('install "ipywidgets" for Jupyter support')
14:03:25 EDT status = queued
To cancel the simulation, use 'web.abort(task_id)' or 'web.delete(task_id)' or abort/delete the task in the web UI. Terminating the Python script will not stop the job running on the cloud.
14:03:29 EDT status = preprocess
14:03:32 EDT Maximum FlexCredit cost: 0.213. Use 'web.real_cost(task_id)' to get the billed FlexCredit cost after a simulation run.
starting up solver
running solver
14:03:58 EDT early shutoff detected at 16%, exiting.
status = postprocess
14:04:00 EDT status = success
View simulation result at 'https://tidy3d.simulation.cloud/workbench?taskId=fdve-ef15ddf7-f3f 8-4925-8752-d687f37fba09'.
14:04:02 EDT loading simulation from simulation_data.hdf5
[20]:
mode_amps = sim_data_final["gc_efficiency"]
coeffs_f = mode_amps.amps.sel(direction="-")
power_0 = np.abs(coeffs_f.sel(mode_index=0)) ** 2
power_0_db = 10 * np.log10(power_0)
sim_plot = sim_final.updated_copy(
symmetry=(0, 0, 0), monitors=(field_xy, field_xz, gc_efficiency)
)
sim_data_plot = sim_data_final.updated_copy(simulation=sim_plot)
f, ax = plt.subplots(2, 2, figsize=(8, 6), tight_layout=True)
sim_plot.plot_eps(z=0, source_alpha=0, monitor_alpha=0, ax=ax[0, 1])
ax[1, 0].plot(wl_range, power_0_db, "-k")
ax[1, 0].set_xlabel("Wavelength (um)")
ax[1, 0].set_ylabel("Power (db)")
ax[1, 0].set_ylim(-15, 0)
ax[1, 0].set_xlim(wl - bw / 2, wl + bw / 2)
ax[1, 0].set_title("Coupling Efficiency")
sim_data_plot.plot_field("field_xy", "E", "abs^2", z=0, ax=ax[1, 1])
ax[0, 0].plot(obj_vals, "ro")
ax[0, 0].set_xlabel("iterations")
ax[0, 0].set_ylabel("objective function")
ax[0, 0].set_ylim(-1, 1)
ax[0, 0].set_title(f"Final Objective Function Value: {obj_vals[-1]:.2f}")
plt.show()
[21]:
loss_db = max(power_0_db)
print(f"optimized loss of {loss_db:.2f} dB")
optimized loss of -2.38 dB
Export to GDS#
The Simulation
object has the .to_gds_file convenience function to export the final design to a GDS
file. In addition to a file name, it is necessary to set a cross-sectional plane (z = 0
in this case) on which to evaluate the geometry, a frequency
to evaluate the permittivity, and a permittivity_threshold
to define the shape boundaries in custom
mediums. See the GDS export notebook for a detailed example on using .to_gds_file
and other GDS related functions.
[22]:
sim_final.to_gds_file(
fname="./misc/inverse_designed_gc.gds",
z=0,
permittivity_threshold=(eps_max + eps_min) / 2,
frequency=freq,
)