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.

66b17555c4734821ae03d82b102a581e

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]:
NameLayerDescriptionColorPattern
BlackBox(998, 0)SiEPIC#00408018solid
Chip design area(290, 0)Misc#80005718hollow
Deep Trench(201, 0)Misc#c0c0c018solid
DevRec(68, 0)SiEPIC#00408018hollow
Dicing(210, 0)Misc#a0a0c018solid
Errors(999, 0)SiEPIC#00008018/
FDTD(733, 0)SiEPIC#80005718hollow
FbrTgt(81, 0)SiEPIC#00408018/
FloorPlan(99, 0)Misc#8000ff18hollow
Isolation Trench(203, 0)Misc#c0c0c018solid
Keep out(202, 0)Misc#a0a0c018//
M1_heater(11, 0)Metal#ebc63418xx
M2_router(12, 0)Metal#90857018xx
M_Open(13, 0)Metal#3471eb18xx
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#3a027f18xx
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]:
../_images/examples_MMI_Optimization_With_Tidy3d_Design_Plugin_7_0.svg

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()
../_images/examples_MMI_Optimization_With_Tidy3d_Design_Plugin_17_0.png

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%
../_images/examples_MMI_Optimization_With_Tidy3d_Design_Plugin_23_1.png