Quick Start II: Passive¶
This quick start takes you from a foundry PDK to a simulated photonic circuit in five steps:
Load a foundry PDK and inspect its technology.
Import a component from a GDS file and make it simulation-ready by adding ports and a simulation model.
Convert the 2D layout to 3D and compute its S parameters with a cloud FDTD simulation.
Assemble a Mach-Zehnder interferometer (MZI) using PhotonForge’s routing utilities.
Simulate the full circuit and verify the interference fringes against theory.
To follow along you only need PhotonForge and the open-source SiEPIC EBeam PDK:
pip install photonforge siepic-forge
Step 1: Loading a foundry PDK¶
Foundry PDKs are loaded as PhotonForge technologies. A technology bundles everything the foundry process defines: layers, the layer extrusion recipe used to build 3D simulations, and the standard waveguide cross-sections (port specifications). Here we load the SiEPIC EBeam technology and set it as the default for this session.
[1]:
import numpy as np
import photonforge as pf
import siepic_forge as siepic_pdk
from matplotlib import pyplot as plt
technology = siepic_pdk.ebeam()
pf.config.default_technology = technology
technology
[1]:
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_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()
The technology also carries fabrication statistics from the foundry, which enable Monte Carlo yield analysis:
[2]:
technology.random_variables
[2]:
[RandomVariable('si_thickness', **{'value': 0.22, 'stdev': 0.0037166666666666667})]
We can plot the technology’s layer stack in cross-section to see exactly what the foundry process looks like - the silicon device layer over the buried oxide, the heater metal, and the routing metal levels:
[3]:
siepic_pdk.plot_cross_section(technology)
plt.show()
Step 2: Loading a GDS layout¶
Components can come from the PDK library, from parametric generators, or from any GDS file. Here we import a compact Y splitter layout. Download y_splitter.gds and place it next to this notebook. We use it exactly as you would use a layout received from a colleague or another tool.
[4]:
y_splitter = pf.load_layout("y_splitter.gds")["y_splitter"]
y_splitter
[4]:
The imported component contains only geometry - no ports and no models. These are the two ingredients that make a layout simulation-ready:
Ports define where light enters and exits, and which waveguide cross-section (mode) is used there.
A model defines how the component is simulated.
PhotonForge can detect ports automatically from the geometry: we search the component boundaries for cross-sections that match the strip waveguide specification. (Ports can also be placed manually with Port when more control is needed.)
[5]:
y_splitter.add_port(y_splitter.detect_ports(["TE_1550_500"], on_boundary="x"))
y_splitter
[5]:
Finally we attach a Tidy3DModel, which tells PhotonForge to compute this component’s S parameters with a full 3D FDTD simulation in the cloud. Attaching a model takes no configuration: the defaults come from the component and its technology.
[6]:
y_splitter.add_model(pf.Tidy3DModel())
y_splitter.active_model
[6]:
Tidy3DModel(run_time=None, medium=None, symmetry=(0, 0, 0), boundary_spec=None, monitors=(), structures=(), grid_spec=None, shutoff=None, subpixel=None, courant=None, port_symmetries=(), bounds=((None, None, None), (None, None, None)), source_gap=None, simulation_updates=None, autograd_config=None, verbose=True)
Step 3: Convert 2D layouts to 3D for electromagnetic simulation¶
PhotonForge automatically extrudes the 2D layout into a full 3D structure using the technology’s extrusion specification - no manual 3D modeling required. We can inspect exactly what the FDTD solver will see - here, a top view of the extruded permittivity at the waveguide core:
[7]:
pf.tidy3d_plot(y_splitter, z=0.11)
plt.show()
Now we compute the S parameters. The simulation runs in the cloud and the result is cached, so re-running this cell (or reusing this component inside a larger circuit) does not re-simulate:
[8]:
wavelengths = np.linspace(1.53, 1.57, 201)
freqs = pf.C_0 / wavelengths
s_matrix = y_splitter.s_matrix(freqs)
Starting…
15:30:21 -03 Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=fdve-f5c2af14-c40 5-481a-b1e0-0d94f333b7e1'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=fdve-314dc7ff-a4f a-4793-b8a0-1a7d1ba83653'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=fdve-8c8d9b09-4de e-46de-a346-c6690d659700'.
Progress: 100%
[9]:
pf.plot_s_matrix(s_matrix, y="dB", input_ports=["P0"])
plt.show()
The splitter transmits close to the ideal -3 dB into each output arm with low reflection across the full band.
Step 4: Building an MZI with routing¶
With a verified splitter in hand, we build an unbalanced Mach-Zehnder interferometer: the same Y splitter is used twice (as splitter and combiner), and both arms are created in a single call to route_auto, PhotonForge’s automatic, obstacle-aware router.
What makes the interferometer unbalanced is the path-length difference between its arms, and we state that as an intent rather than drawing it by hand. A PathLengthMatching group tells the router what to equalize; it then splices displacement-neutral meanders into the routes until the targets are met, leaving the endpoints untouched. Matching equalizes offset + routed length, so crediting the first arm with 80 um drives the second one
to be 80 um longer.
The same mechanism matches arms to each other (the default), to an absolute target, or to a ladder of per-net targets, and it can equalize geometric length, phase, or group delay.
[10]:
mzi = pf.Component("mzi")
splitter = mzi.add_reference(y_splitter)
combiner = mzi.add_reference(y_splitter)
combiner.rotate(180)
combiner.translate((80, 0))
radius = 5 # bend radius in um; tight enough that a meander fits in a 70 um arm
target_delta_l = 80.0 # extra path length wanted in the long arm (um)
# Both arms in one call. The matching group carries the intent: crediting the first
# net with 80 um makes the router drive the second one 80 um longer.
arms = pf.parametric.route_auto(
port1=[(splitter, "P1"), (splitter, "P2")],
port2=[(combiner, "P2"), (combiner, "P1")],
radius=radius,
grid_size=1.0, # finer search grid gives a cleaner meander
matching=[pf.PathLengthMatching(offsets=[target_delta_l, 0.0])],
)
mzi.add_reference(arms)
mzi
[10]:
The router reports what it achieved, and route_length confirms it on each arm. Note that the matcher needs somewhere to put the meander: if no leg of a route is long enough to host it, the pass refuses, leaves the geometry untouched, and says so, rather than returning a layout that quietly misses its target. That is what sets the bend radius here. A meander is four bends, so a wider one needs a longer straight leg than these 70 um arms can offer.
[11]:
lengths = [pf.route_length(route.component) for route in arms.references]
delta_l = max(lengths) - min(lengths)
print(f"Short arm: {min(lengths):.2f} um")
print(f"Long arm: {max(lengths):.2f} um")
print(f"Path imbalance: {delta_l:.2f} um")
Short arm: 69.50 um
Long arm: 149.50 um
Path imbalance: 80.00 um
Step 5: Circuit simulation¶
To simulate the MZI we expose its input and output ports and attach a CircuitModel.
A circuit model does not simulate any physics of its own. When you ask this MZI for its S matrix, the circuit model:
walks down the component hierarchy and finds every sub-component, here two Y splitter references and the two routed arms,
asks each one for its own S matrix, which invokes whatever model that sub-component carries,
reads the connectivity from the layout, that is, from which ports actually meet, and
cascades those S matrices into a single response for the whole circuit.
Two consequences are worth noticing. First, models mix freely: the Y splitter answers from its cached FDTD result, one cloud simulation reused for both references, while the routed arms answer from fast analytic waveguide models. Full-wave accuracy goes where it matters and everything else stays instant.
Second, there is no netlist to maintain. The connectivity comes from the layout, so moving a component or rerouting an arm changes the next circuit simulation automatically, and the layout and the system-level prediction cannot drift apart.
[12]:
mzi.add_port([splitter["P0"], combiner["P0"]])
mzi.add_model(pf.CircuitModel())
mzi_s = mzi.s_matrix(freqs)
Starting…
15:30:22 -03 Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-b6f390a9-837e- 44de-bdff-8bfe47144eba'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-00296ff7-d10a- 43c0-b6a6-120e178da8c4'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-cb3ab898-db6a- 4914-8437-2bb04cfbf0a2'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-b99a69fe-e92d- 4e1f-a87c-36250f1e6896'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-925590bf-97fe- 4ce7-b920-6a3f7d9ea284'.
Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-193386b2-eac5- 4e1a-8397-24c70148b965'.
Progress: 100%
[13]:
transmission = np.abs(mzi_s[("P0@0", "P1@0")]) ** 2
plt.figure()
plt.plot(wavelengths, 10 * np.log10(transmission))
plt.xlabel("Wavelength (um)")
plt.ylabel("Transmission (dB)")
plt.title("Unbalanced MZI transmission")
plt.ylim(-40, 3)
plt.grid(True, alpha=0.3)
plt.show()
The interference fringes are exactly what the path imbalance predicts. The free spectral range of an unbalanced MZI is
where \(n_g\) is the group index of the strip waveguide. The mode solver returns it directly when asked with group_index=True, so we can compare:
[14]:
modes = pf.port_modes("TE_1550_500", [pf.C_0 / 1.55], group_index=True)
n_group = modes.data.n_group.item()
fsr_theory = 1.55**2 / (n_group * delta_l) * 1000 # nm
# Measured FSR from the spacing of transmission minima
minima = [
i
for i in range(1, len(transmission) - 1)
if transmission[i] < transmission[i - 1] and transmission[i] < transmission[i + 1]
]
fsr_measured = np.diff(wavelengths[minima]).mean() * 1000 # nm
print(f"Group index n_g = {n_group:.3f}")
print(f"FSR (theory): {fsr_theory:.2f} nm")
print(f"FSR (simulated): {fsr_measured:.2f} nm")
Starting…
15:30:23 -03 Loading simulation from local cache. View cached task using web UI at 'https://tidy3d.simulation.cloud/workbench?taskId=mo-3f94da58-2fca- 41df-b705-a0b6b1ddeed0'.
Progress: 100%
Group index n_g = 4.181
FSR (theory): 7.18 nm
FSR (simulated): 7.16 nm
Theory and simulation agree to well under 1%.
Want to learn more?¶
Continue with Quick Start III to make this MZI active: add a thermal phase shifter to bias it, add electro-optic phase modulators, run a time-domain simulation, and export the project to the PhotonForge GUI.
Browse the example library for complete device and system designs.
The short guides cover each PhotonForge concept in depth.