PhotonForge is an end-to-end photonic design automation platform that accelerates photonic integrated circuit design by seamlessly integrating simulation tools, layout editing, circuit-level system modeling, and foundry PDKs into one unified platform. Tidy3D’s inverse design framework efficiently calculates the derivative of a figure of merit (such as transmission) with respect to any number of design parameters. Combined, users have access to a powerful and streamlined design automation workflow for optimizing photonic components.
For this example, we will use PhotonForge and inverse design to optimize a grating coupler. The grating coupler is based on this PhotonForge example, which implemented the design from [1].
For more examples of inverse designed grating couplers, please refer to thesenotebooks.
Frederik Van Laere, Tom Claes, Jonathan Schrauwen, Stijn Scheerlinck, Wim Bogaerts, Dirk Taillaert, Liam O’Faolain, Dries Van Thourhout, Roel Baets, “Compact Focusing Grating Couplers for Silicon-on-Insulator Integrated Circuits,” in IEEE Photonics Technology Letters, vol. 19, no. 23, pp. 1919-1921, Dec.1, 2007, doi: 10.1109/LPT.2007.908762
The general workflow for this notebook is summarized below:
from functools import partialimport autograd as agimport autograd.numpy as np # Important to import autograd's numpy module for tracing derivativesimport photonforge as pfimport tidy3d as tdfrom matplotlib import pyplot as pltfrom tidy3d.plugins.autograd import adam, optimize, smooth_min# Set the default PhotonForge -> Tidy3D mesh refinement# We are running 2D simulations, so this can be set rather highpf.config.default_mesh_refinement =30.0td.config.logging.level ="ERROR"
1. Technology Setup
In the first part of this notebook, we follow this PhotonForge example to set up the technology stack.
The technology used in the reference work [1] is similar to the default available as PhotonForge’s basic technology, with 2 important differences: the slab thickness and the absence of the top cladding.
Changing the slab thickness to emulate the 70 nm etch depth is a matter of setting slab_thickness to 150 nm:
We can view the layers and extrusions provided by the technology
tech.layers
Name
Layer
Description
Color
Pattern
WG_CLAD
(1, 0)
Waveguide clad
#9da6a218
.
WG_CORE
(2, 0)
Waveguide core
#6db5dd18
/
SLAB
(3, 0)
Slab region
#8851ad18
:
TRENCH
(4, 0)
Deep-etched trench for chip…
… facets
#535e5918
+
METAL
(5, 0)
Metal layer
#b8a18b18
\
tech.extrusion_specs
#
Mask
Limits (μm)
Sidewal (°)
Opt. Medium
Elec. Medium
0
()
-inf, 0
0
cSi_Li1993_293K
Medium(permittivity=12.3)
1
()
-2, 1.72
0
SiO2_Palik_LowLoss
Medium(permittivity=4.2)
2
'WG_CORE'
0, 0.22
0
cSi_Li1993_293K
Medium(permittivity=12.3)
3
'SLAB'
0, 0.15
0
cSi_Li1993_293K
Medium(permittivity=12.3)
4
'METAL'
1.72, 2.22
0
Cu_JohnsonChristy1972
PEC
5
'TRENCH'
-inf, inf
0
Medium()
Medium()
Unfortunately, there is no argument to remove the top cladding, because the basic technology sets top and bottom media to the background medium, and we cannot set the top cladding thickness to 0 because it is used to set the port dimensions.
A better option is to add an new extrusion specification to the technology that creates an extra air volume above the substrate level with infinite thickness. That can be accomplished with the insert_extrusion_spec function:
# An empty mask covers the whole device boundsair_extrusion = pf.ExtrusionSpec( pf.MaskSpec(), td.Medium(permittivity=1.0, name="air"), limits=(0, pf.Z_INF))# Insert air volume first, so that all core extrusions override the air cladtech.insert_extrusion_spec(0, air_extrusion)# Print the updated extrusion specstech.extrusion_specs
#
Mask
Limits (μm)
Sidewal (°)
Opt. Medium
Elec. Medium
0
()
0, inf
0
air
air
1
()
-inf, 0
0
cSi_Li1993_293K
Medium(permittivity=12.3)
2
()
-2, 1.72
0
SiO2_Palik_LowLoss
Medium(permittivity=4.2)
3
'WG_CORE'
0, 0.22
0
cSi_Li1993_293K
Medium(permittivity=12.3)
4
'SLAB'
0, 0.15
0
cSi_Li1993_293K
Medium(permittivity=12.3)
5
'METAL'
1.72, 2.22
0
Cu_JohnsonChristy1972
PEC
6
'TRENCH'
-inf, inf
0
Medium()
Medium()
2. Grating Coupler Adjoint Optimization
Next, let’s set up the grating coupler adjoint optimization. For this workflow, we are using PhotonForge to construct the Tidy3D model. The benefit is that PhotonForge automatically constructs the Tidy3D geometry layers from the technology stack and extrusion specs. PhotonForge will create the initial Tidy3D model, and then we will modify that model for adjoint optimizations.
Background simulation setup with PhotonForge
First, we will create a starting simulation with PhotonForge. This simulation will contain all of the objects that will not change during optimization, including the background layer stack (input waveguide, cladding oxide, and air), Gaussian beam source and waveguide monitor.
To create the starting simulation, we create a PhotonForge component. The component will:
Draw the background structures using the selected technology stack.
Generate an initial Tidy3D simulation model, which we will modify for adjoint optimization.
Here are the setup parameters for the component:
# Setup parameterswg_width =6wg_length =30fiber_angle =10fiber_translation =3waist_radius =6.0gaussian_port_height =0.5# Define the gaussian port input vector based on the fiber anglesin_angle = np.sin(fiber_angle /180* np.pi)cos_angle = np.cos(fiber_angle /180* np.pi)input_vector = (-sin_angle, 0, -cos_angle)
Next, we create the background PhotonForge component:
# Create an empty Photonforge componentbackground = pf.Component()
To this empty component, we will add the input waveguide as a rectangle.
We then add two ports, one Gaussian port above the center of the grating, and another at the input to the waveguide. The Gaussian port will be converted to a Gaussian source, and the waveguide port will be converted to a waveguide Mode monitor when PhotonForge generates the Tidy3D simulation.
We can now see that the two ports have been added to the component:
background
Now we add a Tidy3D simulation model to the component. We will get our starting Tidy3D simulation from this model.
# Add a Tidy3D simulation model to the componentbackground.add_model(pf.Tidy3DModel(), "Tidy3D")
'Tidy3D'
From the model that we just added to the component, we get the base simulation for optimizing
wavelengths = np.linspace(1.5, 1.6, 21)sims = background.models["Tidy3D"].get_simulations(background, pf.C_0 / wavelengths)basesim = sims["P1@0"] # This will be the base simulation that we work with
Let’s view this simulation:
basesim.plot(y=0)basesim.plot_eps(y=0)plt.show()
We can see that PhotonForge has automatically generated the simulation contains background structures for this technology stack, and has added a Gaussian beam source (green) and a waveguide mode monitor (orange).
3. Grating coupler simulation
Now that we have our base simulation with the objects that won’t change during optimization, we can modify the simulation to include the grating structure and run 2D grating simulations.
We will first convert the base simulation from 3D to 2D, and we will also remove the field monitor below the Gaussian source because it’s not needed for optimization (it is automatically added by PhotonForge for computing S-parameters):
# Update the simulation size so that it is 2Dsize = (basesim.size[0], 0, basesim.size[2])# Update the boundary spec for 2D simulationsboundary_spec = basesim.boundary_spec.updated_copy( y=td.Boundary(minus=td.Periodic(), plus=td.Periodic()),)# Update the monitor sizemonitors = []for monitor in basesim.monitors:# We will remove the field monitor since it's not needed during optimizationif monitor.type=="ModeMonitor": mon_size = (monitor.size[0], td.inf, monitor.size[2]) monitors.append(monitor.updated_copy(size=mon_size))basesim = basesim.updated_copy( size=size, boundary_spec=boundary_spec, monitors=monitors,)
The size of the simulation is now two dimensional:
print(basesim.size)
(33.565, 0.0, 3.72)
Next, we add in the grating coupler structure. Below are the starting parameters for our grating coupler, taken from [1].
period =0.63gc_len =15.5ncells =int(round(gc_len / period))fill_factor =0.5gc_widths = np.ones(shape=ncells) * period * fill_factorgc_gaps = period - gc_widths
From the technology, we get the z-bounds of the grating etch
# Extract the layers and their bounds from the extrusion specsextrusion_layers = [ex.mask_spec.layer for ex in tech.extrusion_specs]extrusion_bounds = [ex.limits for ex in tech.extrusion_specs]# Get the slab, core, and etch boundsslabbounds = extrusion_bounds[extrusion_layers.index(tech.layers["SLAB"].layer)]corebounds = extrusion_bounds[extrusion_layers.index(tech.layers["WG_CORE"].layer)]etch_zbounds = (slabbounds[1], corebounds[1])
Now, we create a function that adds the grating teeth to the simulation
def gc_sim( gc_gaps: np.ndarray, gc_widths: np.ndarray, basesim: td.Simulation = basesim, gc_startx: float=5,):"""Returns a 2D grating simulation for optimization. Params: ------- gc_gaps : np.ndarray Widths of the partially-etched regions gc_widths : np.ndarray Widths of the unetched regions basesim : td.Simulation Base simulation to update gc_startx : float The position of where the grating begins. """# Add the grating teeth to the simulation grating_structures = [] cx = gc_startxfor w, g inzip(gc_widths, gc_gaps): cx += g /2 grating_structures.append( td.Structure( geometry=td.Box.from_bounds( rmin=(cx - g /2, -td.inf, etch_zbounds[0]), rmax=(cx + g /2, td.inf, etch_zbounds[1]), ), medium=td.Medium(permittivity=1.0), ) ) cx += g /2+ w structures =list(basesim.structures) + grating_structures# Etch away the remaining waveguide layer structures.append( td.Structure( geometry=td.Box.from_bounds( rmin=(cx, -td.inf, corebounds[0]), rmax=(1e3, td.inf, corebounds[1]) ), medium=td.Medium(permittivity=1.0), ) )return basesim.updated_copy(structures=structures)
Let’s test the gc simulation to make sure it’s working. First let’s view the simulation with the grating.
# test gc simtestsim = gc_sim( gc_gaps, gc_widths,)testsim.plot(y=0)plt.show()
The source position is likely sub-optimal, but let’s run the simulation to make sure there aren’t any errors in the setup.
Now let’s sweep the source position and find the position for the best coupling.
# sweep source position for best couplingfiber_pos = np.linspace(5, 15, 11)# Generate simulations with updated fiber source positionssims = []for fpos in fiber_pos: source = testsim.sources[0] source = source.updated_copy(center=(fpos, source.center[1], source.center[2])) sims.append(testsim.updated_copy(sources=[source]))# Consolidate simulations into a dict for passing to td.web.rungcsims = {f"fiber_pos{fpos}": sim for fpos, sim inzip(fiber_pos, sims)}# Run the batch of simulations.batchdata = td.web.run(gcsims, path="simdata", verbose=False)
# Get transmission at the center wavelength vs. source positioncenterwl =1.55T_centerwl = []for key, simdata in batchdata.items(): amps = simdata["P0"].amps.sel(direction="-") T = amps.abs**2 T_centerwl.append(T.interp(f=td.C_0 / centerwl).data[0])
f, ax = plt.subplots()ax.plot(fiber_pos, T_centerwl, "-o")ax.set_xlabel("fiber source position (um)")ax.set_ylabel(f"transmission at {centerwl} wavelength")ax.grid()plt.show()
It looks like a good source position is 9 um. Let’s update the base simulation, then we will be ready for adjoint optimizations.
First we’ll define an objective value to improve. For example, it can be improving the worst case loss within a specified bandwidth.
The smooth_min() function provides a differentiable calculation of the minimum of a set of values. Differentiability is important for gradient descent to work.
def worstloss(simdata, wl_range):"""Returns the worst transmission across measured frequencies from the monitor""" freqrange = td.C_0 / np.array(wl_range) amps = simdata["P0"].amps.sel(direction="-", f=slice(freqrange[0], freqrange[1])).data T = np.abs(amps) **2return smooth_min(T, tau=1e-2)
We will need to wrap this value within an objective function that takes in the optimization parameters as a single array. For compatibility with Autograd, the package that handles differentiation through general Python code, it is important that the optimization parameters are passed into the objective function as positional arguments. If interested, please see this article for an overview of how Autograd fits into the Tidy3D inverse design pipeline.
Before making that function, let’s make a couple of helper functions that will convert between the design as a dictionary, and the design as an array. This will make it easier for us to keep track of the parameters outside of the optimization loop.
Now we make an objective function, which is just a wrapper around our objective value. The function
Takes in the parameters as a single array (needed for compatibility with Autograd), and converts them to their constituent design parameters.
Creates and runs a simulation from the design parameters.
Calls and returns our objective value function.
def objective(params_array: np.ndarray, param_lengths, wl_range):"""The objective function for optimization. Creates the simulation, and returns the loss."""# first convert the parameter array to a dictionary for passing to the simulation maker design = array_to_design(params_array, param_lengths)# then create and run the simulation sim = gc_sim(**design, basesim=basesim) simdata = td.web.run(sim, task_name="gcopt", path="simdata/gc.hdf5", verbose=False)# finally return the objective valuereturn worstloss(simdata, wl_range=wl_range)
A real PIC platform will also have minimum feature constraints. We’ll define a minimum line (width) and gap (space) here, and enforce these constraints inside of the optimization loop through parameter clipping. Clipping will prevent values from decreasing below the minimum line and gap.
min_line =0.15min_space =0.2def minfeat_cliparray(design, min_line=min_line, min_space=min_space):"""This function returns a single array of minimum line and space for clipping.""" _, param_lengths = design_to_array(design) min_spaces = min_space * np.ones(shape=param_lengths[0]) min_lines = min_line * np.ones(shape=param_lengths[1])return np.concatenate((min_spaces, min_lines))
Now we can assemble the various pieces together into a main optimization loop.
Along with the starting design and objective function, we will also choose parameters for our optimizer such as the number of steps, learning rate, and optimizer to use. For the optimizer, we use Tidy3D’s built-in Adam helper.
The hard work of evaluating the gradient is handled by Tidy3D’s adjoint functionality (wrapped with Autograd for chain rule tracing through general Python code). The parameters are then updated using Tidy3D’s built-in Adam helper.
# The wavelength range to optimize forwl_range = (1.545, 1.555)# Optimization parameterslearning_rate =1e-2num_steps =40# The starting grating designdesign0 = {"gc_gaps": gc_gaps,"gc_widths": gc_widths,}# Initialize the optimizer with starting parametersparams_array0, param_lengths = design_to_array(design0)params_array = np.array(params_array0).copy()optimizer = adam(learning_rate=learning_rate)# Create the minimum feature size array for clippingminfeatarr = minfeat_cliparray(design=design0)# Arrays that store the optimization historyobjective_history = []param_history = []gradient_history = []obj_fun = partial(objective, param_lengths=param_lengths, wl_range=wl_range)def report_step(current_params, grad, state, step_index, objective_val):print(f"step = {step_index +1}")print(f"\tJ = {objective_val:.4e}")print(f"\tgrad_norm = {np.linalg.norm(grad):.4e}") objective_history.append(objective_val) param_history.append(np.array(current_params)) gradient_history.append(np.array(grad))
Let’s run the optimization for 40 iterations at a learning rate of 1e-2. We will optimize for a narrow bandwidth of 10 nm, from 1545 to 1555 nm wavelength. Feel free to experiment with these values on your own.
# maximize the objective directly using optimize(..., direction="max").params_array, opt_state, history = optimize( obj_fun, params0=params_array, optimizer=optimizer, num_steps=num_steps, bounds=(minfeatarr, None), callback=report_step, direction="max",)
Once the optimization is complete we can view the results:
# Plot the objective value versus iteration.f, ax = plt.subplots()ax.plot(objective_history, "-o")ax.set_xlabel("iteration")ax.set_ylabel("Objective value")ax.grid()ax.set_title("Objective vs. iteration")# Select the final design parameters returned by optimize.# The callback records pre-update iterates, so param_history# does not include this final vector.final_params = params_array_, param_lengths = design_to_array(design0)final_design = array_to_design(final_params, param_lengths)# Compare the starting and final params.f, ax = plt.subplots()ax.plot(design0["gc_widths"], "o", label="initial design")ax.plot(final_design["gc_widths"], "o", label="final design")ax.set_ylabel("widths")ax.grid()ax.legend()ax.set_title("Initial vs. final widths")f, ax = plt.subplots()ax.plot(design0["gc_gaps"], "o", label="initial design")ax.plot(final_design["gc_gaps"], "o", label="final design")ax.set_ylabel("gaps")ax.grid()ax.legend()ax.set_title("Initial vs. final gaps")plt.show()
Finally, we can simulate the optimized design, and compare it with the initial design.
We can see that the final design has centered the transmission spectrum on our design wavelength, and slightly improved the coupling efficiency. More improvement could be possible by experimenting with the optimization hyperparameters, such as the starting design or the coupling bandwidth. Feel free to experiment if you would like and see if you can improve the design even more.
We use necessary cookies to run this website. With your permission, we
also use analytics cookies to understand site usage and marketing
cookies for advertising, retargeting, and HubSpot tracking.
Learn more about our cookie policy.
Privacy choices
Choose which optional cookies Flexcompute may use. Necessary cookies
are always on because they support core website behavior, security, and
saving your consent record.
Your browser is sending a Global Privacy Control signal, so marketing
cookies are disabled.
Subscribe
Thanks for subscribing
Publish Your Notebook
Thank you for publishingA confirmation email has been sent to your inbox. Your notebook will be available within the next 48 hours.
Community Library
TERMS & CONDITIONS
EFFECTIVE DATE: January 1, 2025
Terms and Conditions for User-Submitted Content
By submitting content to Flexcompute, you agree to the following terms:
1. Ownership and Copyright
All content, including but not limited to text, data, images, and other materials submitted by users,
remains the sole property of the original creator. Flexcompute does not claim ownership of the submitted
content or any intellectual property rights associated with it.
Users affirm that they own the copyright or have obtained all necessary permissions for the submitted
content and are fully responsible for ensuring that their submissions do not infringe on any third-party
rights.
2. Responsibility for Content
Users are solely responsible for the content they submit. Flexcompute does not endorse, guarantee,
or verify the accuracy, legality, or appropriateness of any submitted content. Users are responsible
for ensuring that their content complies with all applicable laws and regulations.
Users declare that they have obtained authorization from all co-authors and collaborators associated
with the submitted content, granting them the right to submit and sign on behalf of all contributors.
Users agree not to submit content that is illegal, defamatory, obscene, or violates the rights
of others, including privacy and intellectual property rights.
3. Modification and Presentation
Flexcompute reserves the right to review, edit, or modify submitted content to improve clarity,
formatting, and presentation while maintaining the original intent and message.
These modifications are made to enhance the overall quality and readability of the published material.
Users acknowledge that Flexcompute may format or present the content in a way
that aligns with our editorial and visual standards.
4. Liability Disclaimer
Flexcompute shall not be held liable for any disputes arising from user-submitted content, including
but not limited to copyright claims, inaccuracies, or damages resulting from the publication of user content.
Users agree to indemnify Flexcompute against any claims or legal actions resulting from their submitted content.
5. Content Use Rights
By submitting content, users grant Flexcompute a non-exclusive, royalty-free, worldwide license to publish, distribute,
and promote the content for the purposes of showcasing user contributions and marketing our services.
This license does not transfer ownership of the content or any copyright to Flexcompute.
Users retain the right to withdraw their submissions at any time.
Upon request, Flexcompute will remove the content from its platform.
6. Privacy and Confidentiality
Users acknowledge that submitted content may be publicly accessible and therefore waive any
rights to confidentiality or privacy regarding the published material.
Flexcompute will not share personal information of users without
their consent, in accordance with our privacy policy.
Community Library
How the process works:
You submit your notebook.
You will get a permanent URL to promote your work.
Cite the URL as a reference for others in your upcoming papers to enhance the impact.
When someone clicks your URL, your notebook will be displayed.
Enter your email address below to receive the presentation slides. In the future, we’ll share you very few emails when we have new tutorial release, development updates, valuable toolkits and technical guidance . You can unsubscribe at any time by clicking the link at the bottom of every email. We’ll never share your information.