MMI Optimization with Tidy3D Design Plugin¶
In this notebook, we present a comprehensive workflow for optimizing photonic components within PhotonForge, utilizing the Tidy3D Design Plugin (tidy3d.plugins.design
). We will specifically focus on designing and optimizing a multimode interference (MMI) splitter component.
The workflow covers:
Initializing the Process Design Kit (PDK)
Designing the photonic component
Setting up simulations
Conducting parameter sweeps and Bayesian optimization
Evaluating optimization results
Initialize the Process Design Kit (PDK)¶
We begin by initializing a standard technology stack for the Silicon-on-Insulator (SOI) platform. PhotonForge supports several additional PDKs, including two open-source options: SiEPIC OpenEBL and Luxtelligence LNOI400.
In this notebook, we use the SiEPIC OpenEBL technology, which provides predefined settings for materials, layer thicknesses, and other critical photonic parameters.
[1]:
# The Bayesian optimizer uses the bayesian-optimization external package version 1.5.1.
# Uncomment the following line to install the package
# pip install bayesian-optimization==1.5.1
import matplotlib.pyplot as plt
import numpy as np
import photonforge as pf
import siepic_forge as siepic_pdk
import tidy3d as td
import tidy3d.plugins.design as tdd
from photonforge.live_viewer import LiveViewer
from rich.pretty import Pretty
viewer = LiveViewer()
Starting live viewer at http://localhost:5001
Note that the technology stacks in PhotonForge are parametric objects, meaning parameters such as layer thicknesses, material media, sidewall angles, and more can easily be modified according to design requirements. In this notebook, we include the air above the cladding since the cladding is really thin.
Below is the technology setting and the list of the technology layers. For our design, we’ll specifically utilize the “Si” and “Si slab” layers to create a rib-waveguide-based MMI splitter.
We use a default_mesh_refinement of 12 to decrease simulation time. It is still sufficiently accurate for optimization purpose. We will simulate the final optimal design with the default mesh refinement of 20.
[2]:
# modified the technology stack to include substreate and air above the cladding
tech = siepic_pdk.ebeam(include_top_opening=True)
# some configuration settings
pf.config.default_technology = tech
pf.config.default_mesh_refinement = 12
td.config.logging_level = "ERROR"
# set wavelength of interest (c-band)
wavelengths = np.linspace(1.53, 1.565, 51)
frequencies = pf.C_0 / wavelengths
tech.layers
[2]:
Name | Layer | Description | Color | Pattern |
---|---|---|---|---|
BlackBox | (998, 0) | SiEPIC | #00408018 | solid |
Chip design area | (290, 0) | Misc | #80005718 | hollow |
Deep Trench | (201, 0) | Misc | #c0c0c018 | solid |
DevRec | (68, 0) | SiEPIC | #00408018 | hollow |
Dicing | (210, 0) | Misc | #a0a0c018 | solid |
Errors | (999, 0) | SiEPIC | #00008018 | / |
FDTD | (733, 0) | SiEPIC | #80005718 | hollow |
FbrTgt | (81, 0) | SiEPIC | #00408018 | / |
FloorPlan | (99, 0) | Misc | #8000ff18 | hollow |
Isolation Trench | (203, 0) | Misc | #c0c0c018 | solid |
Keep out | (202, 0) | Misc | #a0a0c018 | // |
M1_heater | (11, 0) | Metal | #ebc63418 | xx |
M2_router | (12, 0) | Metal | #90857018 | xx |
M_Open | (13, 0) | Metal | #3471eb18 | xx |
Oxide open (to BOX) | (6, 0) | Waveguides | #ffae0018 | \ |
PinRec | (1, 10) | SiEPIC | #00408018 | / |
PinRecM | (1, 11) | SiEPIC | #00408018 | / |
SEM | (200, 0) | Misc | #ff00ff18 | \ |
Si | (1, 0) | Waveguides | #ff80a818 | \ |
Si N | (20, 0) | Doping | #7000ff18 | \ |
Si N++ | (24, 0) | Doping | #0000ff18 | : |
Si slab | (2, 0) | Waveguides | #80a8ff18 | / |
SiN | (4, 0) | Waveguides | #a6cee318 | \ |
Si_Litho193nm | (1, 69) | Waveguides | #cc80a818 | \ |
Text | (10, 0) | #0000ff18 | \ | |
VC | (40, 0) | Metal | #3a027f18 | xx |
Waveguide | (1, 99) | Waveguides | #ff80a818 | \ |
[3]:
Pretty(tech.parametric_kwargs, max_depth=1)
[3]:
{ 'si_thickness': 0.22, 'si_slab_thickness': 0.09, 'sin_thickness': 0.4, 'si_mask_dilation': 0.0, 'si_slab_mask_dilation': 0.0, 'sin_mask_dilation': 0.0, 'sidewall_angle': 0.0, 'metal_si_separation': 2.2, 'router_thickness': 0.6, 'heater_thickness': 0.2, 'top_oxide_thickness': 0.3, 'bottom_oxide_thickness': 3.017, 'include_top_opening': True, 'include_substrate': False, 'sio2': {...}, 'si': {...}, 'sin': {...}, 'router_metal': {...}, 'heater_metal': {...}, 'opening': Medium(...) }
Creating a 1x2 MMI Component¶
Next, we’ll create a parametric 1x2 MMI component using PhotonForge. We’ll use the stencils.mmi method to generate the basic geometry and add this to the core waveguide layer (“Si”).
To form the rib waveguide structure, we apply the envelope function around the core geometry with an offset defined by the parameter padding
. This envelope is then added to the “Si slab” layer.
Afterward, ports of type Rib_TE_1550_500
are automatically identified and assigned using the detect_ports method. Lastly, we incorporate the Tidy3DModel for simulation purposes.
[4]:
@pf.parametric_component
def mmi_1x2(
*,
length=12,
width=5,
port_length=6,
tapered_width=1.5,
padding=3,
port_spec=tech.ports["Rib_TE_1550_500"]
):
"""
Creates a parametric 1x2 multimode interference (MMI) splitter component.
Parameters:
length (float): Length of the central MMI region (μm).
width (float): Width of the central MMI region (μm).
port_length (float): Length of input/output waveguide ports (μm).
tapered_width (float): Width at the tapered junction between ports and MMI region (μm).
padding (float): Padding offset around the core layer to form slab waveguide (μm).
port_spec (photonforge.PortSpec): Port specifications for this component
Returns:
PhotonForge Component: Configured MMI splitter with ports and simulation model.
"""
if isinstance(port_spec, str):
port_spec = pf.config.default_technology.ports[port_spec]
# Create an empty component named "MMI1x2"
mmi = pf.Component("MMI1x2")
port_width, _ = port_spec.path_profile_for("Si")
# Generate the base geometry for the MMI splitter
mmi_structure = pf.stencil.mmi(
length=length,
num_ports=(1, 2),
width=width,
port_length=port_length,
port_width=port_width,
tapered_width=tapered_width,
)
# Add the geometry to the "Si" layer
mmi.add("Si", *mmi_structure)
# Create slab structure surrounding the core geometry
slab_structures = pf.envelope(mmi, offset=padding, trim_x_min=True, trim_x_max=True)
# Add slab structure to "Si slab" layer
mmi.add("Si slab", slab_structures)
# Detect and add ports automatically
mmi.add_port(mmi.detect_ports([port_spec]))
assert len(mmi.ports) == 3, "Port detection failed: expected exactly 3 ports."
# Include the Tidy3D simulation model
mmi.add_model(pf.Tidy3DModel(port_symmetries=[("P0", "P2", "P1")]), "Tidy3DModel")
return mmi
# Instantiate the component with custom dimensions
mmi = mmi_1x2(length=15, width=5, port_length=6)
viewer.display(mmi)
[4]:
Defining the Figure of Merit (FoM) for MMI Optimization¶
Next, we define a function to create the MMI splitter component and calculate its figure of merit (FoM). Given that the 1x2 MMI splitter is symmetric, we expect equal transmitted power at both output ports. Therefore, our figure of merit is defined as the average transmitted power at the two output ports across the specified frequency range.
The following function constructs the MMI component, simulates it to obtain the scattering parameters (S-parameters), and calculates the average transmitted power as our optimization metric:
[5]:
def fom_mmi(
frequencies=frequencies,
length=12,
width=5,
port_length=6,
tapered_width=1.5,
padding=3,
port_spec=tech.ports["Rib_TE_1550_500"],
):
# create mmi component
mmi = mmi_1x2(
length=length,
width=width,
port_length=port_length,
tapered_width=tapered_width,
padding=padding,
port_spec=port_spec,
)
# Get the S-matrix object from the MMI component
s_matrix = mmi.s_matrix(frequencies=frequencies, model_kwargs={"inputs": ["P0"]})
# Keys: ('P0@0', 'P1@0') is S21, ('P0@0', 'P2@0') is S31.
# Because of symmetry we only need S21
S21 = s_matrix["P0@0", "P1@0"]
# Compute power (|S|^2) for the transmission coefficients over all frequencies
P21 = np.abs(S21) ** 2
# Average transmitted power (which is the complement of loss)
avg_transmitted_power = 2 * np.mean(P21)
fom_value = avg_transmitted_power
return fom_value
Defining Design Parameters¶
First, we define the key design parameters that serve as inputs to our previously defined fom_mmi
function.
In this example, we focus on four critical parameters: length
, width
, tapered_width
, and port_length
. Each parameter is defined as a named tdd.ParameterFloat
, with clearly specified spans. The parameter names must match exactly with the argument names of the fom_mmi
function.
Since we intend to perform parameter sweeps specifically on port_length
and tapered_width
, we include the num_points
parameter to determine the resolution of these sweeps. Note that the num_points
attribute is only relevant for parameter sweeps and will be ignored during Bayesian optimization.
[6]:
param_length = tdd.ParameterFloat(name="length", span=(10, 20))
param_width = tdd.ParameterFloat(name="width", span=(4, 6))
param_tapered_width = tdd.ParameterFloat(
name="tapered_width", span=(1, 2), num_points=3
)
param_port_length = tdd.ParameterFloat(name="port_length", span=(5, 9), num_points=5)
Defining and Running the Parameter Sweep¶
Next, we set up and execute a parameter sweep using the Tidy3D Design plugin. We select the MethodGrid()
method, which performs a structured grid sweep over the specified parameters.
We then create a DesignSpace
, specifying the parameters (param_port_length
and param_tapered_width
) defined earlier, along with the chosen method (method
). The path_dir
argument indicates where the simulation data will be stored.
Finally, we execute the parameter sweep by calling the run()
method of the DesignSpace
object, passing the previously defined figure-of-merit function fom_mmi
.
[7]:
method = tdd.MethodGrid()
design_space = tdd.DesignSpace(
parameters=[param_port_length, param_tapered_width],
method=method,
path_dir="./data",
)
sweep_results = design_space.run(fom_mmi)
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-PIBRCUPZ4XZ5RRDRR2JFZGZXN2WWVK5D55BQTTQ6A6AMAQYXP25A.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-JCIJXKIFMJRGO67EID4GHT2YOYO6YOZDLEDWEBFRJDKLORMAE3JA.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-YMMMLGNELHGCWJSNLE7GASI4BMQ4C5LHQ22SIZ7N7RYB4CY7SQSQ.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-E7XLUXH2PYXSX52CFYWHBGQL233DKZ4NJZIOVYPYAMKUCO6R3T6Q.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-T3VKZGDW6EU2US6PD2TYMH4GB2GG45EBBZDGB6VLOCRHOP2TCSAQ.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-LAFMFKCN74AKP2SXKKUVMZGHTNHZF5RLPI6TTTKPOOZ3JLZZM2VA.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-JMLD6SY7BW7IMEGFDOPC57JYNFHTE2XND4AFWTU7SJSAEOBOKJ2Q.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-4P7SOZXKT3G46LC57WCIWH4ECWYBOJYYKL22D26IUC3DDTSLHBYA.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-3MKNTAVNS2VHORKK6R6B7MKDOD6Q3EXOMKKK2L2GMEWL5NXUAI7A.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-OQS2KWGF7NTSCM5L6J5NWFUSH3QCA5SIL5TEHPNROD3YAB6GB57A.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-32FCQHGZXTLP4JQ2RKBLFAABB74A726OA2M6WKV3V72FHED5YLOQ.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-VGWF3NVUD63IXGJ37MM5C6D3XTZYFPFJOUFWGYJZSV2LS47YSD3A.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-A6ITKGPUBHDVNAEOQDTHAYQ4GOLWDUBMYOEPDJLTTGPCGPRMUNLA.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-D4VG7UB4AFNCO4NPWLUYKU2IDVY76LOBAQ4WVJMPLG4FX4IVDG4Q.json.
Progress: 100%
Loading cached simulation from .tidy3d/pf_cache/XVO/fdtd_info-ZTGKRMCESVHN4ACC7K4GL3MDDQKAZXDPGNKR2QBMVIYK56ON77OQ.json.
Progress: 100%
We convert the sweep results into a DataFrame for easy visualization. Below are the first five data points from our parameter sweep:
[8]:
# The first 5 data points
df = sweep_results.to_dataframe()
df.head()
[8]:
port_length | tapered_width | output | |
---|---|---|---|
0 | 5.0 | 1.0 | 0.129294 |
1 | 6.0 | 1.0 | 0.134263 |
2 | 7.0 | 1.0 | 0.128901 |
3 | 8.0 | 1.0 | 0.129677 |
4 | 9.0 | 1.0 | 0.133235 |
We visualize the sweep results by plotting the Figure of Merit (FoM) against port_length
for different values of tapered_width
. Each curve corresponds to a unique tapered_width
, clearly showing how this parameter influences the overall performance of the MMI splitter.
[9]:
# Create a figure and axis
fig, ax = plt.subplots()
# Loop over the unique tapered_width values and plot each curve
for tw in sorted(df["tapered_width"].unique()):
# Filter the DataFrame for the current tapered_width value
df_subset = df[df["tapered_width"] == tw]
# Sort by port_length for a smoother curve
df_subset = df_subset.sort_values(by="port_length")
ax.plot(
df_subset["port_length"],
df_subset["output"],
marker="o",
label=f"tapered_width = {tw}",
)
# Set labels and title
ax.set_xlabel("Port Length")
ax.set_ylabel("Figure of Merit (FOM)")
ax.set_title("FOM vs Port Length for Different Tapered Widths")
ax.legend()
# Show the plot
plt.show()

Performing Bayesian Optimization¶
Next, we perform Bayesian optimization to efficiently explore the design space and identify optimal parameters. We select the MethodBayOpt method, specifying:
initial_iter=4
: Number of initial random iterations to explore the design space broadly. This provides a starting point for the Gaussian processor to optimize from.n_iter=6
: Number of additional iterations guided by the Gaussian processor to refine the parameter search.
We create a new DesignSpace
, including the parameters param_length
, param_width
, and param_tapered_width
. We then execute the optimization process by running our defined figure-of-merit function (fom_mmi
):
[10]:
method = tdd.MethodBayOpt(initial_iter=4, n_iter=6)
design_space = tdd.DesignSpace(
parameters=[param_length, param_width, param_tapered_width],
method=method,
path_dir="./data",
)
optimization_result = design_space.run(fom_mmi, verbose=True)
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-bb80895c-a529-4f4b-86bd-bcb55fec3a63
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-63a15a1a-b388-4d8b-b7d4-e96dca28573b
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-b2876caa-1982-4fa7-be78-627ed60d0cc1
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-d7dc029d-94fe-43de-83df-9f7ec7303dc5
Progress: 100%
16:02:44 -03 Best Fit from Initial Solutions: 0.963
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-e7051257-bfb2-452c-8e17-eee700d412f1
Progress: 100%
16:04:24 -03 Latest Best Fit on Iter 0: 0.988
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-b362bd91-c582-46fa-b128-6aa251f83828
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-8ed76aa2-ecfc-4cc4-8cf2-8fb342289525
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-5ed09ef0-0cf3-44d5-9c2b-f2f41d598019
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-3057748a-d8e0-4211-b0d6-95654b606e40
Progress: 100%
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-6fcba7f4-3799-4639-be4f-3400aa37f237
Progress: 100%
16:12:56 -03 Best Result: 0.9877550970388183 Best Parameters: length: 18.019534503898903 tapered_width: 1.6211538591066565 width: 4.531407788142714
Retrieving Optimal Design Parameters¶
We extract the optimal parameters obtained from the Bayesian optimization. The best parameters are those that maximize our figure of merit (fitness). We print both the best fitness value and the corresponding optimized parameters:
[11]:
best_params = optimization_result.optimizer.max["params"]
print(f"Best fitness: {optimization_result.optimizer.max['target']}")
print(f"Best parameters: {optimization_result.optimizer.max['params']}")
Best fitness: 0.9877550970388183
Best parameters: {'length': np.float64(18.019534503898903), 'tapered_width': np.float64(1.6211538591066565), 'width': np.float64(4.531407788142714)}
Using the optimal parameters obtained from the Bayesian optimization, we construct the optimized MMI splitter component. Then, we compute its scattering matrix (S-matrix) across the specified frequency range and visualize the transmission results as a function of wavelength. We perform final simulation with the default mesh refinement value of 20, to make sure that the results are accurate. The loss of the device is less than 0.075 dB over the whole bandwidth, which is impressive.
[12]:
pf.config.default_mesh_refinement = 20
mmi = mmi_1x2(**best_params)
# Get the S-matrix object from the optimized MMI component and plot the results
s_matrix = mmi.s_matrix(frequencies=frequencies, model_kwargs={"inputs": ["P0"]})
ax = pf.plot_s_matrix(s_matrix, x="wavelength")
Uploading task 'P0@0'…
Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-5a62666d-384b-47d2-b136-41a1afec15ff
Progress: 100%
