Switch Array

In this notebook, we create a switch array inspired by the work of Suzuki et al. [1], which demonstrated a 32×32 silicon photonic switch with ultra-low insertion loss and power-efficient operation. Their design utilizes a high-Δ silica planar lightwave circuit (PLC) platform to achieve compact and low-crosstalk routing. The switch architecture enables scalable integration of photonic components with precise routing of optical signals across multiple paths.
We implement a scalable version of such a switch using an array of unit cells, where each unit cell can be interconnected via parameterized routing. Our implementation mimics the concept of highly efficient and integrated photonic switching fabrics proposed in [1].
This architecture is particularly advantageous when using p-i-n junctions for fast switching. In such cases, carrier injection through the junction introduces additional loss, which can increase insertion loss, degrade uniformity, and cause crosstalk. However, in this architecture, only one Mach–Zehnder Interferometer is active per switching configuration, resulting in consistently low insertion loss and improved signal uniformity across the array [2].
References
- Suzuki, Keijiro, et al. “Low-insertion-loss and power-efficient 32×32 silicon photonics switch with extremely high-Δ silica PLC connector.” Journal of Lightwave Technology 2019 37 (1), 116-122, doi: 10.1109/JLT.2018.2867575.
- Konoike, Ryotaro, Keijiro Suzuki, and Kazuhiro Ikeda. “Path-independent insertion loss 8× 8 silicon photonics switch with nanosecond-order switching time.” Journal of Lightwave Technology 2023 41 (3), 865-870, doi: 10.1364/JLT.41.000865
- Y. Ma et al., “Ultralow loss single layer submicron silicon waveguide crossing for SOI optical interconnect,” Opt Express, 2023 21 (24), 29374–29382, doi: 10.1364/OE.21.029374
import matplotlib.pyplot as pltimport numpy as npimport photonforge as pfimport siepic_forge as siepic_pdkimport tidy3d as tdfrom photonforge.live_viewer import LiveViewer
viewer = LiveViewer()LiveViewer started at http://127.0.0.1:43055. Call its 'stop()' method to free the port.We start by setting up the simulation environment using the SiEPIC OpenEBL PDK platform. This includes defining waveguide parameters, default simulation settings, and wavelength ranges. The technology is configured to use Tidy3D for simulating components such as bends and S-bends.
To focus the simulation, we define a narrow wavelength sweep around 1.55 µm—representing the telecom C-band—and convert this wavelength range to corresponding optical frequencies for use in frequency-domain simulations.
bend_radius = 10 # default bend radius in micronswg_width = 0.43 # waveguide width in micronswidth_nm = int(wg_width * 1e3)
# Load SiEPIC OpenEBL technology PDKtech = siepic_pdk.ebeam()
# Set the default technology to SiEPIC OpenEBLpf.config.default_technology = tech
# Set Tidy3D logging level to suppress non-critical messagestd.config.logging.level = "ERROR"
# Configure default parameters for photonic componentspf.config.default_kwargs = { "bend": {"model": pf.Tidy3DModel()}, "s_bend": {"model": pf.Tidy3DModel()}, "radius": bend_radius, # default bend radius "euler_fraction": 0.5, # Euler bend fraction}
# Define wavelength range for the simulationwavelengths = np.linspace(1.53, 1.57, 51)
lambda_0 = 1.55 # central wavelength in microns
# Convert wavelengths to frequencies (Hz)frequencies = pf.C_0 / wavelengthsAdditionally, we configure the port specification to use a 430 nm–wide waveguide—matching the design in [1]—and integrate this port into the technology.
# copy & tweak the existing 500 nm TE portport_spec = tech.ports["TE_1550_500"].copy()port_spec.path_profiles = [(wg_width, 0.0, (1, 0))]
# build your new port name & description from wg_widthport_name = f"TE_1550_{width_nm}"port_spec.description = f"Strip TE 1550 nm, w={width_nm} nm"
# register ittech.add_port(port_name, port_spec)Layers
| Name | Layer | Description | Color | Pattern | Device marker | Conductive | Min. width (μm) | Min. spacing (μm) |
|---|---|---|---|---|---|---|---|---|
| Si | (1, 0) | SiEPIC - Waveguide | #ff80a818 | \\ | ||||
| PinRec | (1, 10) | SiEPIC | #ff80a818 | xx | ||||
| PinRecM | (1, 11) | SiEPIC | #80000018 | + | ||||
| Si Slab | (2, 0) | Dedicated Run Layers - Device…… Layer Partial Etch | #c080ff18 | / | ||||
| Direct Metal | (5, 0) | Dedicated Run Layers | #80a8ff18 | || | ||||
| Oxide open to BOX | (6, 0) | Dedicated Run Layers | #ff000018 | - | ||||
| Text | (10, 0) | Text-Not Fabricated | #00000018 | hollow | ||||
| M1_heater | (11, 0) | TiW Heater | #0000ff18 | \\ | ||||
| M2_router | (12, 0) | TiW/Au Routing Bilayer | #ffbf0018 | // | ||||
| M_Open | (13, 0) | Bond Pad Open | #80005718 | \\ | ||||
| Si n | (20, 0) | Dedicated Run Layers | #afff8018 | - | ||||
| Si p | (21, 0) | Dedicated Run Layers | #ffd9df18 | = | ||||
| Si n+ | (22, 0) | Dedicated Run Layers | #ff800018 | x | ||||
| Si p+ | (23, 0) | Dedicated Run Layers | #ddff0018 | xx | ||||
| Si n++ | (24, 0) | Dedicated Run Layers | #00ffff18 | + | ||||
| Si p++ | (25, 0) | Dedicated Run Layers | #00800018 | ++ | ||||
| ANT Reserved | (31, 0) | SiEPIC/ANT Reserved | #9580ff18 | / | ||||
| ANT Reserved 1 | (33, 0) | ANT Reserved | #9580ff18 | / | ||||
| Via to silicon | (40, 0) | Dedicated Run Layers | #0000ff18 | . | ||||
| DevRec | (68, 0) | SiEPIC | #00800018 | . | ||||
| FbrTgt | (81, 0) | SiEPIC/Dedicated Run Layers | #80808018 | ++ | ||||
| ANT Reserved 2 | (102, 0) | ANT Reserved | #9580ff18 | / | ||||
| ANT Reserved 3 | (110, 0) | ANT Reserved | #9580ff18 | / | ||||
| Custom Dicing | (189, 0) | #00000018 | hollow | |||||
| SEM Imaging | (200, 0) | #ff000018 | x | |||||
| Deep Trench | (201, 0) | #00ff0018 | . | |||||
| Deep Trench Handling Exclusion | (202, 0) | #00760018 | : | |||||
| Thermal Isolation Trenches | (203, 0) | #00800018 | \ | |||||
| Laser Integration Shelf | (205, 0) | Dedicated Run Layers | #69ff0518 | xx | ||||
| Floor Plan-Not Fabricated | (290, 0) | #c080ff18 | hollow | |||||
Error: device layer width is…… less than design rule | (301, 0) | DRC Errors | #80005718 | = | ||||
Error: device layer spacing is…… less than design rule | (301, 1) | DRC Errors | #80005718 | - | ||||
Warning: polygons/paths on…… PinRec layer (1/10) will NOT be fabricated | (301, 2) | DRC Errors | #80005718 | || | ||||
Error: direct metal width is…… less than 5 microns | (305, 0) | DRC Errors | #80808018 | ++ | ||||
Error: direct metal spacing is…… less than 10 microns | (305, 1) | DRC Errors | #80808018 | + | ||||
Error: TiW width is less than 3…… microns | (311, 0) | DRC Errors | #ffa08018 | // | ||||
Error: TiW spacing is less than…… 3 microns | (311, 1) | DRC Errors | #ffa08018 | / | ||||
Error: Al width is less than…… design rule | (312, 0) | DRC Errors | #00ffff18 | | | ||||
Error: Al spacing is less than…… design rule | (312, 1) | DRC Errors | #00ffff18 | // | ||||
Error: Spacing between TiW and…… Al is less than 5 microns | (312, 3) | DRC Errors | #00ffff18 | \\ | ||||
Error: Oxide window width is…… less than 10 microns | (313, 0) | DRC Errors | #01ff6b18 | || | ||||
Error: Oxide window spacing is…… less than 10 microns | (313, 1) | DRC Errors | #01ff6b18 | | | ||||
Error: Oxide window is not…… placed over Al | (313, 2) | DRC Errors | #01ff6b18 | // | ||||
| Standard Design Area | (350, 0) | DRC Errors | #ddff0018 | \ | ||||
Error: Features outside design…… area. Verify design size and centering. | (350, 1) | DRC Errors | #ddff0018 | : | ||||
Error: Dicing lane width is…… less than 100 microns | (389, 0) | DRC Errors | #ff00ff18 | ++ | ||||
Error: Spacing between dicing…… lane and devices is less than 50 microns | (389, 1) | DRC Errors | #ff00ff18 | + | ||||
Error: SEM width is less than…… 500 nm | (400, 0) | DRC Errors | #ff9d9d18 | x | ||||
| Deep Trench Design Area | (401, 0) | DRC Errors | #80a8ff18 | xx | ||||
Error: Metal, SEM, or handling…… region overlap with deep trenches. Verify design centering | (401, 1) | DRC Errors | #80a8ff18 | x | ||||
Warning: Silicon features…… outside deep trench design area. Verify accuracy before submission | (401, 2) | DRC Errors | #80a8ff18 | = | ||||
Error: Spacing between metal…… and deep trench is less than 30 microns | (401, 3) | DRC Errors | #80a8ff18 | - | ||||
Error: Deep trench width is…… less than 260 microns | (401, 4) | DRC Errors | #80a8ff18 | || | ||||
Error: Deep trench handling…… area missing. Please add handling area of size shown by polygons | (402, 0) | DRC Errors | #ff000018 | + | ||||
Error: Features inside deep…… trench handling area | (402, 1) | DRC Errors | #ff000018 | xx | ||||
Error: Thermal isolation width…… is less than design rule | (403, 0) | DRC Errors | #50008018 | ++ | ||||
Error: Thermal isolation…… spacing is less than design rule | (403, 1) | DRC Errors | #50008018 | + | ||||
Error: Spacing between thermal…… isolation and metal is less than design rule | (403, 2) | DRC Errors | #50008018 | xx | ||||
Error: Thermal isolation and…… device layer overlap, or spacing is less than design rule | (403, 3) | DRC Errors | #50008018 | x | ||||
Dream Photonics Black Box-Not…… Fabricated | (998, 0) | #00000018 | hollow | |||||
| Errors | (999, 0) | SiEPIC | #0000ff18 | || |
Extrusion specs
| # | Mask | Limits (μm) | Sidewall (°) | Opt. Medium | Elec. Medium |
|---|---|---|---|---|---|
| 0 | () | -inf, 0 | 0 | cSi_Li1993_293K | Si |
| 1 | () | -2, 2.5 | 0 | SiO2_Palik_LowLoss | SiO2 |
| 2 | 'M1_heater'**0.3 | 2.2, 2.7 | 0 | SiO2_Palik_LowLoss | SiO2 |
| 3 | 'M2_router'**0.3 | 2.2, 3.3 | 0 | SiO2_Palik_LowLoss | SiO2 |
| 4 | 'M1_heater' | 2.2, 2.4 | 0 | W_Werner2009 | LossyMetalMedium……(frequency_range=(100000000.0, 200000000000.0), conductivity=1.6, fit_param={'max_num_poles': 16}) |
| 5 | 'M2_router' | 2.4, 3 | 0 | Au_Olmon2012evaporated | LossyMetalMedium……(frequency_range=(100000000.0, 200000000000.0), conductivity=17.0, fit_param={'max_num_poles': 16}) |
| 6 | 'M_Open' | 3, 3.3 | 0 | Medium() | Medium() |
| 7 | 'Oxide open to BOX' | 0, inf | 0 | Medium() | Medium() |
| 8 | 'Si' | 0, 0.22 | 0 | cSi_Li1993_293K | Si |
| 9 | 'Si Slab' | 0, 0.09 | 0 | cSi_Li1993_293K | Si |
| 10 | 'Deep Trench' + 'Thermal…… Isolation Trenches' | -inf, inf | 0 | Medium() | Medium() |
Ports
| Name | Classification | Description | Width (μm) | Limits (μm) | Radius (μm) | Modes | Target n_eff | Path profiles (μm) |
|---|---|---|---|---|---|---|---|---|
| MM_TE_1550_2000 | optical | Multimode Strip TE 1550 nm,…… w=2000 nm | 6 | -2, 2.22 | 0 | 10 | 3.5 | 'Si': 2 |
| MM_TE_1550_3000 | optical | Multimode Strip TE 1550 nm,…… w=3000 nm | 6 | -2, 2.22 | 0 | 15 | 3.5 | 'Si': 3 |
| Rib_TE_1310_350 | optical | Rib (90 nm slab) TE 1310 nm,…… w=350 nm | 2.35 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.35, 'Si Slab': 3 |
| Rib_TE_1550_500 | optical | Rib (90 nm slab) TE 1550 nm,…… w=500 nm | 2.5 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.5, 'Si Slab': 3 |
| Slot_TE_1550_500 | optical | Slot TE 1550 nm, w=500 nm,…… gap=100nm | 3 | -1, 1.22 | 0 | 1 | 3.5 | 'Si': 0.2 (-0.15), 'Si': 0.2…… (+0.15) |
| TE-TM_1550_450 | optical | Strip TE-TM 1550, w=450 nm | 2.2 | -1, 1.22 | 0 | 2 | 3.5 | 'Si': 0.45 |
| TE_1310_350 | optical | Strip TE 1310 nm, w=350 nm | 1.5 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.35 |
| TE_1310_410 | optical | Strip TE 1310 nm, w=410 nm | 1.5 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.41 |
| TE_1550_430 | optical | Strip TE 1550 nm, w=430 nm | 1.5 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.43 |
| TE_1550_500 | optical | Strip TE 1550 nm, w=500 nm | 1.5 | -0.6, 0.82 | 0 | 1 | 3.5 | 'Si': 0.5 |
| TM_1310_350 | optical | Strip TM 1310 nm, w=350 nm | 1.5 | -0.6, 0.82 | 0 | 1 + 1 (TM) | 3.5 | 'Si': 0.35 |
| TM_1550_500 | optical | Strip TM 1550 nm, w=500 nm | 1.5 | -0.6, 0.82 | 0 | 1 + 1 (TM) | 3.5 | 'Si': 0.5 |
| eskid_TE_1550 | optical | eskid TE 1550 | 2 | -0.7, 0.92 | 0 | 1 | 3.5 | 'Si': 0.35, 'Si': 0.06…… (+0.265), 'Si': 0.06 (-0.265), 'Si': 0.06 (+0.385), 'Si': 0.06 (-0.385), 'Si': 0.06 (+0.505), 'Si': 0.06 (-0.505), 'Si': 0.06 (+0.625), 'Si': 0.06 (-0.625) |
Background medium
- Optical: Medium()
- Electrical: Medium()
We examine the port’s field profile to ensure it decays sufficiently at its boundaries; otherwise, we would adjust the port’s width and limits. In this case, the field has already decayed adequately.
mode_solver = pf.port_modes(port_spec, [pf.C_0 / lambda_0])mode_solver.plot_field("S", mode_index=0, scale="dB", vmin=-20, robust=False)Uploading task 'Mode-ModeSolver…'Starting task 'Mode-ModeSolver': https://tidy3d.simulation.cloud/workbench?taskId=mo-334decdc-be79-44e2-8178-12dd7471af94Downloading data from 'Mode-ModeSolver'…
Angled Adiabatic Waveguide Crossing
Section titled “Angled Adiabatic Waveguide Crossing”We define a 4-arm angled adiabatic waveguide crossing with smooth cubic taper profiles, inspired by the device presented in [3]. This structure has been designed to minimize insertion loss and crosstalk by employing gradually varying widths along the propagation direction. The crossing is symmetrically constructed using four identical arms created through geometric operations.
The crossing incorporates adiabatic tapers formed via cubic spline interpolation, which ensures smooth transitions between waveguide widths. The component is constructed in compliance with symmetry conditions and is modeled using Tidy3D with specified port symmetries for reducing simulation costs.
from scipy.interpolate import make_interp_spline
default_widths = ( 0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5,)
@pf.parametric_component(name_prefix="CROSSING")def angled_adiabatic_crossing( *, port_spec="TE_1550_500", arm_length=4.5, widths=default_widths, radius=bend_radius,): """ Create a 4-arm adiabatic crossing with smooth cubic taper profiles.
Parameters: port_spec (str or PortSpec): Input/output port specification name or object. arm_length (float): Length of the main arm (µm). widths (Sequence[float]): Target waveguide widths (µm) along each arm. radius (float): Radius of the 45-deg arcs (µm).
Returns: A crossing component. """ if isinstance(port_spec, str): port_spec = pf.config.default_technology.ports[port_spec]
wg_width, _ = port_spec.path_profile_for("Si") # extract initial waveguide width
# Number of points used to discretize the cubic spline profile. num_points = int(arm_length / pf.config.tolerance)
# Calculate port locations based on input dimensions (projected to the 45° axes) projected_arm_length = arm_length * 2**-0.5 arc_y = radius * 2**-0.5 arc_x = radius - arc_y # Snap port coordinates to the grid to avoid gaps/overlaps xp = pf.snap_to_grid(projected_arm_length + arc_x) yp = pf.snap_to_grid(projected_arm_length + arc_y)
# Create cubic spline interpolation for widths coords = np.linspace(0, projected_arm_length, len(widths)) # Reverse widths, because we're starting from 0 at the center of the cross spline = make_interp_spline(coords, widths[::-1], k=3)
# Pre-compute widths and positions from the interpolation coords = np.linspace(projected_arm_length, 0, num_points) widths = spline(coords)
arm1 = pf.Path((xp, yp), wg_width) arm1.arc( 0, -45, radius, width=(widths[0], "smooth"), endpoint=(projected_arm_length, projected_arm_length), ) for x, w in zip(coords[1:], widths[1:]): arm1.segment((x, x), w)
arm2 = arm1.copy().mirror() arm3 = arm1.copy().rotate(180) arm4 = arm2.copy().rotate(180)
c = pf.Component() c.add("Si", *pf.boolean([arm1, arm2], [arm3, arm4], "+"))
# Add ports c.add_port(c.detect_ports([port_spec])) assert len(c.ports) == 4, "Port detection failed: expected exactly 4 ports."
# Add Tidy3D simulation model with symmetry conditions c.add_model( pf.Tidy3DModel( port_symmetries=[ ("P1", "P0", "P3", "P2"), # symmetry about x-axis ("P2", "P3", "P0", "P1"), # symmetry about y-axis ("P3", "P2", "P1", "P0"), # inversion symmetry ], ), "Tidy3DModel", ) return c
crossing = angled_adiabatic_crossing(port_spec=port_spec)viewer(crossing)To evaluate the optical performance of the angled adiabatic crossing, we compute its scattering matrix (S-matrix) over the defined wavelength range. The S-matrix characterizes the power transmission and reflection between each pair of ports. We then visualize the resulting S-matrix to inspect insertion loss and crosstalk across the device.
# Compute the scattering matrix of the crossing over the frequency ranges_matrix_crossing = crossing.s_matrix(frequencies)
# Plot the magnitude of the S-matrix to evaluate transmission and crosstalk_ = pf.plot_s_matrix(s_matrix_crossing)Uploading task 'P0@0…'Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-66bfdb20-0b45-4b90-9807-2ded26e75872Downloading data from 'P0@0'…
Directional Coupler
Section titled “Directional Coupler”We define a dual-ring directional directional coupler to be used in the switch array. The design includes two curved waveguides with a specified coupling distance and coupling length. The radius and Euler bend fraction are set to ensure adiabatic transitions. To facilitate efficient simulation with Tidy3D, we explicitly define the port symmetries, ensuring accurate modeling of the device behavior.
# Define a dual-ring directional coupler with specified geometry and waveguide spacingdc_coupler = pf.parametric.dual_ring_coupler( port_spec=port_spec, # use predefined port specification coupling_distance=0.75, # gap between the coupled waveguides (µm) radius=5, # bend radius (µm) euler_fraction=0.5, # fraction of Euler bend coupling_length=20.8, # coupling region length (µm) model=pf.Tidy3DModel(),)
# Define symmetries for accurate simulation with Tidy3Ddc_coupler.models["Tidy3D"].port_symmetries = [ ("P1", "P0", "P3", "P2"), # x-axis reflection ("P2", "P3", "P0", "P1"), # y-axis reflection ("P3", "P2", "P1", "P0"), # inversion symmetry]
dc_length, dc_width = dc_coupler.size()
viewer(dc_coupler) # Display the couplerWe simulate the dual-ring coupler to extract its S-matrix across the defined frequency range.
# Compute the S-matrix of the dual-ring coupler over the specified frequenciess_matrix_dc = dc_coupler.s_matrix(frequencies)
# Plot the S-matrix to visualize power transmission and coupling behavior_ = pf.plot_s_matrix(s_matrix_dc)Uploading task 'P0@0…'Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-8bb07609-c229-45d4-afaf-143d4c47987fDownloading data from 'P0@0'…
Thermal Phase Shifter
Section titled “Thermal Phase Shifter”We model a straight waveguide to serve as a thermal phase shifter. To account for propagation loss, we compute the imaginary part of the refractive index from a specified loss value in dB/cm. Using mode solving, we extract the complex effective index across the wavelength range.
We then define a function that maps applied voltage to a change in the real part of the refractive index, simulating the thermo-optic effect. This function enables modeling phase modulation based on input voltage. Finally, we assign the zero-voltage thermal model as the default waveguide model in the phase shifter component.
wg_length = 70 # length of the straight waveguide in micronsalpha = 3 # propagation loss in dB/cm
# Calculate imaginary part of refractive index from losskappa = (alpha * wavelengths * 1e-4 * np.log(10)) / (40 * np.pi)
# Solve for waveguide modes to obtain complex effective indexmode_solver = pf.port_modes(port=port_spec, frequencies=pf.C_0 / wavelengths)n_complex = mode_solver.data.n_complex.values.T + 1j * kappa # include loss in index
# Create straight waveguide to model thermal phase shifterphase_shifter = pf.parametric.straight( port_spec=port_spec, length=wg_length, name="Phase Shifter")wg = pf.parametric.straight(port_spec=port_spec, length=wg_length, name="wg")wg_model = pf.WaveguideModel(n_complex=n_complex)wg.add_model(wg_model) # assign loss model to auxiliary waveguide
# Define voltage-dependent refractive index model for thermal tuningdef index(voltage, resistance=0.9, coefficient=1e-3): """ Calculate complex refractive index as a function of applied voltage.
Parameters: voltage (float): Applied voltage (V). resistance (float): Heater resistance (Ω). coefficient (float): Proportionality constant.
Returns: np.ndarray: Complex refractive index array. """ dn = coefficient * voltage**2 / resistance # thermo-optic phase shift return n_complex + dn
# Set the default waveguide model at 0 V as the initial thermal modelthermal_model = pf.WaveguideModel(n_complex=index(0))
# Add thermal model to phase shifter component_ = phase_shifter.add_model(thermal_model, "Waveguide")Uploading task 'Mode-ModeSolver…'Starting task 'Mode-ModeSolver': https://tidy3d.simulation.cloud/workbench?taskId=mo-1172bf26-4286-4ae6-8cc5-30f0bb632802Downloading data from 'Mode-ModeSolver'…Tunable Mach–Zehnder Interferometer (MZI)
Section titled “Tunable Mach–Zehnder Interferometer (MZI)”We construct a tunable Mach–Zehnder Interferometer using a netlist-based approach. The MZI consists of two directional couplers and two thermal phase shifters placed in each interferometer arm. Routing is done automatically between connected ports, ensuring proper alignment and spacing.
By configuring the thermal phase shifters with voltage-dependent refractive index models, this MZI design enables dynamic phase control and interference tuning, which are essential for applications like modulation and switching in photonic circuits.
netlist_mzi = { "name": "Tunable MZI", # name of the component "instances": { "dc0": dc_coupler, # first directional coupler "ps0": { "component": phase_shifter, "origin": (dc_length + bend_radius, -wg_length / 2 - dc_width), # lower arm }, "ps1": { "component": phase_shifter, "origin": (dc_length + bend_radius, wg_length / 2), # upper arm }, "dc1": { "component": dc_coupler, "origin": ( 2 * dc_length + 2 * bend_radius + wg_length, 0, ), # second coupler }, }, # Define waveguide routes between instances "routes": [ (("dc0", "P2"), ("ps0", "P0"), pf.parametric.route), (("dc0", "P3"), ("ps1", "P0"), pf.parametric.route), (("dc1", "P1"), ("ps1", "P1"), pf.parametric.route), (("dc1", "P0"), ("ps0", "P1"), pf.parametric.route), ], # Expose external ports "ports": [("dc0", "P0"), ("dc0", "P1"), ("dc1", "P2"), ("dc1", "P3")], # Assign simulation model for the full circuit "models": [(pf.CircuitModel(), "Circuit")],}
# Create the MZI component from the defined netlistmzi = pf.component_from_netlist(netlist_mzi)
# Visualize the MZIviewer(mzi)We evaluate the voltage-dependent behavior of the Mach–Zehnder Interferometer by sweeping the applied voltage on one of its thermal phase shifters. For each voltage, we update the refractive index model accordingly and compute the S-matrix to extract transmission values at the central wavelength.
This analysis identifies the “bar” and “cross” states of the MZI—key operational modes used in optical switching and modulation. We visualize how power routing between output ports changes with applied voltage and mark the voltages corresponding to the bar and cross states on the plot.
voltages = np.linspace(0, 3.5, 301) # voltage sweep range
# Initialize array to store transmission values at both output portstransmission = np.zeros((voltages.size, 2))
m = len(wavelengths) // 2 # index of central wavelength
# Loop over voltage values and compute updated transmissionfor i, v in enumerate(voltages): updates = { ("Phase Shifter", 0): { "model_updates": {"n_complex": index(v)} }, # apply voltage ("Phase Shifter", 1): {"model_updates": {"n_complex": index(0)}}, # keep fixed }
# Compute S-matrix for current voltage configuration s = mzi.s_matrix( pf.C_0 / wavelengths, model_kwargs={"updates": updates}, show_progress=False )
# Extract and store transmission magnitude squared for each output transmission[i, 0] = np.abs(s[("P0@0", "P2@0")][m]) ** 2 transmission[i, 1] = np.abs(s[("P0@0", "P3@0")][m]) ** 2
# Find voltage corresponding to bar state (minimum output at port 3)idx_zero = np.argmin(np.abs(transmission[:, 1]))v_bar = voltages[idx_zero] # bar state voltage
v_cross = 0 # cross state defined at 0 volts
# Plot transmission curves vs voltageplt.plot(voltages, transmission[:, 0], label="|S₂₀|²") # transmission to port 2plt.plot(voltages, transmission[:, 1], label="|S₃₀|²") # transmission to port 3plt.axvline(v_bar, linestyle=":", label=f"Bar state at {v_bar:.2f} V")plt.axvline(v_cross, linestyle="-.", label=f"Cross state at {v_cross:.2f} V")plt.xlabel("Voltage on phase shifter 0 (V)")plt.ylabel("Transmission")plt.legend()
plt.show() # display plotUploading task 'Mode-StripTE1550nmw430nm…'Uploading task 'Mode-StripTE1550nmw430nm…'Uploading task 'Mode-StripTE1550nmw430nm…'Uploading task 'Mode-StripTE1550nmw430nm…'Uploading task 'P0@0…'Uploading task 'P1@0…'Starting task 'Mode-StripTE1550nmw430nm': https://tidy3d.simulation.cloud/workbench?taskId=mo-6ac82059-3a81-4ac6-8508-0cbdb61d4b45Starting task 'Mode-StripTE1550nmw430nm': https://tidy3d.simulation.cloud/workbench?taskId=mo-d49c1bcd-0483-4ae6-8e82-44e87cc4193cStarting task 'Mode-StripTE1550nmw430nm': https://tidy3d.simulation.cloud/workbench?taskId=mo-988e42b3-6165-422d-a237-6a3f17f6f2f2Uploading task 'P0@0…'Uploading task 'P1@0…'Starting task 'Mode-StripTE1550nmw430nm': https://tidy3d.simulation.cloud/workbench?taskId=mo-72d613c9-8429-4e92-8123-ddf0d6ebe0c7Downloading data from 'Mode-StripTE1550nmw430nm'…Downloading data from 'Mode-StripTE1550nmw430nm'…Downloading data from 'Mode-StripTE1550nmw430nm'…Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-f7b7099f-7824-4b85-9daf-dabd6f6cde34Downloading data from 'Mode-StripTE1550nmw430nm'…Starting task 'P1@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-65bafa3a-91ce-4c5b-974d-9339941c5249Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-ae9045a5-482b-4703-bbb8-5e6db1704e79Downloading data from 'P0@0'…Starting task 'P1@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-3546c68e-d522-4ebe-9edb-5c7db637ce90Downloading data from 'P1@0'…Downloading data from 'P0@0'…Downloading data from 'P1@0'…
Tunable Unit Cell
Section titled “Tunable Unit Cell”Custom Waveguide Bends and S-Bends
Section titled “Custom Waveguide Bends and S-Bends”We define three types of curved waveguide components to manage routing in this circuit: an S-bend, an extended bend, and a long extended bend. Each path is constructed using arcs and linear segments to ensure smooth transitions and maintain consistent mode propagation.
All components are assigned appropriate ports and use the same complex waveguide model to include propagation loss. These elements will be used in routing scenarios where geometric constraints or thermal tuning paths require customized bending profiles.
# Define an S-bend using arcs and diagonal segments_bend_path = ( pf.Path((0, 0), wg_width) .arc(180, 135) # create initial arc .segment( (wg_length / 2 - bend_radius, wg_length / 2 - bend_radius), relative=True, ) # diagonal segment .arc(-45, 0) # final arc to complete S shape)
# Create S-bend component and assign waveguide models_bend = pf.Component("Custom S Bend").add("Si", s_bend_path)s_bend.add_port(s_bend.detect_ports([port_spec]))_ = s_bend.add_model(wg_model, "Waveguide")
# Define a 90-degree bend with extended diagonal segmentbend_path = ( pf.Path((0, 0), wg_width) .arc(0, 45) # initial arc .segment( (-wg_length / 2 + bend_radius, wg_length / 2 - bend_radius), relative=True, ) # diagonal segment .arc(45, 90) # final arc to complete the bend)
# Create extended bend component and assign waveguide modelbend = pf.Component("Extended Bend").add("Si", bend_path)bend.add_port(bend.detect_ports([port_spec]))_ = bend.add_model(wg_model, "Waveguide")
# Define a longer bend with extended length and tighter arc overlaplong_bend_length = ( wg_length - bend_radius / 2 - 2 * wg_width) # calculate diagonal segment lengthlong_bend_path = ( pf.Path((0, 0), wg_width) .arc(0, 45) # initial arc .segment( (-long_bend_length, long_bend_length), relative=True, ) # long diagonal segment .arc(45, 90) # final arc)
# Create long bend component and assign waveguide modellong_bend = pf.Component("Long Extended Bend").add("Si", long_bend_path)long_bend.add_port(long_bend.detect_ports([port_spec]))_ = long_bend.add_model(wg_model, "Waveguide")Constructing a Tunable Unit Cell
Section titled “Constructing a Tunable Unit Cell”We create a tunable unit cell composed of various photonic building blocks: an MZI for modulation, bends, S-bends and waveguides for connectivity. The structure is assembled using a netlist that explicitly defines all component instances, their spatial transformations, and port-level connections.
This modular unit cell forms the basis for scalable switch arrays and can be easily replicated to build larger photonic networks. A circuit model is attached to enable end-to-end simulation of its behavior under voltage-controlled tuning.
netlist_uc = { "name": "Unit Cell", "instances": { "b0": {"component": s_bend, "x_reflection": True}, "wg0": wg, "mzi": mzi, "wg1": wg, "b1": s_bend, "b2": {"component": bend, "x_reflection": True}, "b3": bend, "wg2": wg, "wg3": wg, "lb0": long_bend, "lb1": {"component": long_bend, "x_reflection": True}, }, # Explicitly define connections between component ports "connections": [ (("wg0", "P0"), ("b0", "P1")), (("mzi", "P1"), ("wg0", "P1")), (("wg1", "P0"), ("mzi", "P0")), (("b1", "P0"), ("wg1", "P1")), (("b2", "P0"), ("mzi", "P2")), (("b3", "P0"), ("mzi", "P3")), (("wg2", "P0"), ("b2", "P1")), (("wg3", "P0"), ("b3", "P1")), (("lb0", "P0"), ("wg2", "P1")), (("lb1", "P0"), ("wg3", "P1")), ], # Define external ports of the tunable unit "ports": [("b1", "P1"), ("b0", "P0"), ("lb0", "P1"), ("lb1", "P1")], # Assign circuit model to the assembled unit "models": [(pf.CircuitModel(), "Circuit")],}
# Create a photonic component from the defined netlistunit_cell = pf.component_from_netlist(netlist_uc)
# Visualize the assembled tunable unitviewer(unit_cell)Creating an n × n Switch Array
Section titled “Creating an n × n Switch Array”We define a function to construct a tunable optical switch array by tiling unit cells in an grid. Each 2×2 block of cells includes a crossing element at the center for inter-cell routing. The structure is built with proper alignment and spacing based on the bend radius and unit dimensions.
Routing is added between adjacent units along the top and bottom rows, and internal connections are made through the crossings. Ports on the array’s edges are exposed for external connectivity, and a circuit model is attached to the entire structure for simulation purposes.
This modular architecture enables scalable switching networks for programmable photonic systems.
def make_switch_array(n, unit_cell, crossing): """ Build an n×n grid of `unit_cell` with crossings in each 2×2 cell block, edge‐row interconnects, port exports, and a CircuitModel.
Parameters ---------- n : int Number of cells along each side. unit_cell : pf.Component The base cell to tile. crossing : pf.Component The crossing element to place at each 2×2 center.
Returns ------- pf.Component A top‐level Component containing the full array. """ # Create top‐level switch array container switch_array = pf.Component("Switch Array")
port_width = unit_cell.ports["P0"].spec.width # waveguide width for spacing
# Compute horizontal and vertical pitches between unit cells px = unit_cell.size()[0] + crossing.size()[0] - 2 * port_width py = unit_cell.size()[1] + 1.1 * crossing.size()[1]
# Instantiate and place unit cells in n×n grid cells = [] for i in range(n): row = [] for j in range(n): u = switch_array.add_reference(unit_cell) u.translate((j * px, i * py)) row.append(u) cells.append(row)
# Route between adjacent unit cells in the first row (P2→P0) for j in range(n - 1): switch_array.add_reference( pf.parametric.route( port1=cells[0][j]["P2"], port2=cells[0][j + 1]["P0"], ) )
# Route between adjacent unit cells in the last row (P3→P1) for j in range(n - 1): switch_array.add_reference( pf.parametric.route(port1=cells[-1][j]["P3"], port2=cells[-1][j + 1]["P1"]) )
# Insert crossings and route 2×2 internal blocks for i in range(n - 1): for j in range(n - 1): u0 = cells[i][j] # lower-left unit u1 = cells[i][j + 1] # lower-right unit u2 = cells[i + 1][j] # upper-left unit u3 = cells[i + 1][j + 1] # upper-right unit
c = switch_array.add_reference(crossing) c.x_mid = (u0.x_mid + u3.x_mid) / 2 # center the crossing c.y_mid = (u0.y_mid + u3.y_mid) / 2
# Connect each neighboring unit cell port into the crossing switch_array.add_reference( pf.parametric.route(port1=u0["P3"], port2=c["P0"]) ) switch_array.add_reference( pf.parametric.route(port1=u1["P1"], port2=c["P2"]) ) switch_array.add_reference( pf.parametric.route(port1=u2["P2"], port2=c["P1"]) ) switch_array.add_reference( pf.parametric.route(port1=u3["P0"], port2=c["P3"]) )
# Export ports on left and right edges of the grid for i in range(n): switch_array.add_port(cells[i][0]["P0"]) switch_array.add_port(cells[i][0]["P1"]) for i in range(n): switch_array.add_port(cells[i][-1]["P2"]) switch_array.add_port(cells[i][-1]["P3"])
# Attach a circuit model for simulation switch_array.add_model(pf.CircuitModel())
return switch_array
# Create and visualize a 4×4 switch arrayswitch_array = make_switch_array(4, unit_cell, crossing)viewer(switch_array)Simulating Configured Switch Paths
Section titled “Simulating Configured Switch Paths”We simulate the switch array with one of the internal MZIs configured in the bar state, routing a designated input to a specific output. In the layout diagram below, Unit Cell 14 is highlighted in yellow, indicating where the control voltage is applied to tune the refractive index via the integrated phase shifter.
This configuration directs light from input port P4 to output port P14, following the green-highlighted optical path.
The simulation results show approximately 0.78 dB insertion loss at the central wavelength. For a full switch array, this scales to about 6.2 dB, which aligns closely with the measured on-chip loss of 6.1 dB reported in [1]. We also plot the worst-case measured crosstalk in the same figure, which agrees well with the simulation results.
The “noisy” appearance in the final plots can be mitigated by increasing the mesh refinement. These fluctuations are likely caused by small phase errors in the FDTD simulations, which propagate and manifest as noise in the circuit-level results.

updates = { ("Unit Cell", 14, "Tunable MZI", 0, "Phase Shifter", 0): { "model_updates": {"n_complex": index(v_bar)} # Bar state }}
# Compute the S-matrix of the full circuit using the updatess_matrix = switch_array.s_matrix( pf.C_0 / wavelengths, model_kwargs={"updates": updates})
# Plot transmission from input port P4 to output portsfig, ax = pf.plot_s_matrix( s_matrix, input_ports=["P4"], output_ports=["P8", "P10", "P12", "P14"], y="dB")
# Measures datawavelengths_m = [1.530, 1.535, 1.540, 1.545, 1.547, 1.550, 1.555, 1.560, 1.565]crosstalk_m = [-13.3, -15.8, -19.0, -24.2, -26.6, -22.8, -19.7, -15.9, -13.1]
# Plot the measured crosstalk values vs. wavelengthax[1].plot(wavelengths_m, crosstalk_m, "x-", label="Measurement")ax[1].legend()Uploading task 'P0@0…'Uploading task 'P1@0…'Uploading task 'P0@0…'Uploading task 'P1@0…'Starting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-3aefb3fd-4e85-4a5b-84ea-a6c9f645048dStarting task 'P1@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-6ebde64d-3beb-4fed-9844-36984045bd58Downloading data from 'P0@0'…Starting task 'P1@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-eea2bd78-a817-48f3-baf7-a6fc6ff7052dStarting task 'P0@0': https://tidy3d.simulation.cloud/workbench?taskId=fdve-89a3f2c0-b836-47ab-a0c7-50183e2fbbdcDownloading data from 'P1@0'…Downloading data from 'P1@0'…Downloading data from 'P0@0'…
In another configuration, we apply a control voltage to Unit Cell 10 (highlighted in yellow) to drive its MZI into the bar state. As shown in the figure below, this setting redirects the signal from input port P0 to output port P10, following the green-highlighted optical path.

updates = { ("Unit Cell", 10, "Tunable MZI", 0, "Phase Shifter", 0): { "model_updates": {"n_complex": index(v_bar)} # Bar state }}
# Compute the S-matrix of the full circuit using the updatess_matrix = switch_array.s_matrix( pf.C_0 / wavelengths, model_kwargs={"updates": updates})
# Plot transmission from P0 to the output portsfig, ax = pf.plot_s_matrix( s_matrix, input_ports=["P0"], output_ports=["P8", "P10", "P12", "P14"], y="dB")