Quantum Chip Layout¶
This notebook demonstrates the layout phase of a photonic quantum chip design [1]. Using the PhotonForge library, we will programmatically assemble the physical components of the quantum circuit.
The following layout establishes the core architecture for routing and manipulating entangled photon pairs, categorized into three primary sub-assemblies:
Photon-pairs Routing: This front-end section places Asymmetric Mach-Zehnder Interferometers (AMZIs) to separate the generated signal and idler photons. A crossing matrix is then constructed to physically route these photons into distinct logical pathways (Alice and Bob), terminating at an initial layer of thermo-optic phase shifters (TPS).
Quantum Projector Tree Assembly: This section forms the core of the quantum projectors via cascaded Mach-Zehnder Interferometers (MZIs). The routing algorithm pairs the phase shifters and recursively merges them to build two independent binary MZI trees leading to the final detector ports.
Pump Splitters & Routing: Finally, the script dynamically generates the distribution network for the pump laser.
References:
Wang, Jianwei, et al. “Multidimensional quantum entanglement with large-scale integrated optics.” Science 2018 360 (6386), 285-291. doi: 10.1126/science.aar7053
[1]:
%%capture
# Download it here: https://docs.flexcompute.com/projects/photonforge/en/latest/examples/Quantum_Chip_Components.html
%run Quantum_Chip_Components.ipynb
[2]:
import warnings
# Ignores RuntimeWarnings to keep the notebook clean
warnings.simplefilter("ignore", RuntimeWarning)
viewer = LiveViewer()
LiveViewer started at http://localhost:65481
The following cell defines the analytic waveguide model used by every routing component in this notebook. Setting it as a default keyword argument means each bend, S bend and straight segment created by the routers carries physically realistic parameters: propagation loss and phase delay from the effective and group indices.
[3]:
# Define an analytic waveguide model to simulate realistic on-chip optical propagation,
# explicitly accounting for propagation losses due to fabrication imperfections.
wg_model = pf.AnalyticWaveguideModel(
n_eff=n_eff,
reference_frequency=freq0,
propagation_loss=propagation_loss,
n_group=n_group,
)
# Apply this realistic model globally to all routing components. The automatic
# routers build their paths out of these three primitives, so every route they
# create inherits the model.
pf.config.default_kwargs["bend"] = {"model": wg_model}
pf.config.default_kwargs["s_bend"] = {"model": wg_model}
pf.config.default_kwargs["straight"] = {"model": wg_model}
# route_s_bend draws its own path instead of calling s_bend, so it reads its own
# default and has to be listed separately.
pf.config.default_kwargs["route_s_bend"] = {"model": wg_model}
Signal/Idler Input Stage and Crossing Network Layout¶
This cell programmatically generates the front-end of the physical photonic chip. It performs three key layout tasks:
AMZI Placement: Places the input Mach-Zehnder wavelength filters to separate the generated photon pairs (Signal/Idler).
Crossing Matrix: Builds an intersecting waveguide network to physically route the separated photons into two distinct logical trees (Alice and Bob).
Initial Phase Shifters: Attaches the first layer of thermo-optic phase shifters (TPS) to the output of the crossing network.
[4]:
# System dimension (must be a power of 2)
n_src = 2**4
# Layout geometry parameters
# Calculate the starting X-coordinate for the crossing matrix.
# This ensures adequate clearance (4 bend radii) for waveguide routing immediately following the input AMZIs.
crossing_offset = amzi.size()[0] + 4 * bend_radius
# Extract the trace width directly from the thermo-optic phase shifter's (TPS) pad specifications
# to ensure electrical routing dimensions remain consistent with the component design.
trace_width = tps.parametric_kwargs["pad_width"]
# Determine the structural bond pads spacing between the T1 and T0 terminals of the AMZI.
bp_spacing = amzi["T1"].center()[0] - amzi["T0"].center()[0]
# Calculate the horizontal alignment offset between the TPS's optical input port (P0)
# and its electrical terminal (T0) to accurately place connecting waveguides.
tps_port_offset = tps["P0"].center[0] - tps["T0"].center()[0]
# Define the vertical pitch (period_y) for row spacing in the component array.
# It takes the maximum required space to accommodate either the electrical routing traces (8x trace width)
# or the physical height of the spiral delay lines (plus a 3-micron buffer).
period_y = max(8 * trace_width, spiral.size()[1] + 3)
period_y = 2 * pf.snap_to_grid(period_y / 2) # force even multiple of grid
# Define the horizontal pitch (period_x) for the cascaded crossing network grid.
# This accounts for the physical size of the angled crossing plus clearance for routing S-bends between them.
period_x = angled_crossing.size()[1] + 8 * bend_radius
# Calculate the excess waveguide length required to bridge the crossing network to the TPS layer.
# This evaluates an absolute target coordinate that reserves horizontal space for electrical routing
# (allocating 6 trace widths per photon source channel, n_src) and subtracts the linear space
# already consumed by the crossing matrix.
wg_excess_length = (
amzi["T0"].center()[0]
+ 6 * n_src * trace_width
+ tps_port_offset
- (n_src * period_x + angled_crossing.size()[1] + crossing_offset + 4 * bend_radius)
)
# Initialize circuit and component arrays
projector_circuit = pf.Component("Projector Circuit")
amzi_list = []
crossing_list = []
wg_list = []
tps_list = []
mzi_list = []
for i in range(n_src):
# Place Input AMZIs in a vertical column, stepping down by the defined y-pitch
amzi_list.append(projector_circuit.add_reference(amzi))
amzi_list[i].y_min -= period_y * i
p0_i = amzi_list[i]["P0"]
p2_i = amzi_list[i]["P2"]
p3_i = amzi_list[i]["P3"]
# Calculate the centerline between the two output ports (P2 and P3).
# This serves as the vertical reference axis for aligning the crossing matrix.
c_i = (p2_i.center[1] + p3_i.center[1]) / 2
projector_circuit.add_port(p0_i) # Define projector_circuit input port
# Build the cascaded Crossing Matrix.
for j in range(i + 1):
crossing_list.append(
projector_circuit.add_reference(angled_crossing).rotate(90)
)
# Position crossings along the horizontal grid, staggering them vertically
# at half the standard pitch to interleave the optical paths.
crossing_list[-1].y_mid = c_i + j * period_y / 2
crossing_list[-1].x_min = crossing_offset + j * period_x
p0_j = crossing_list[-1]["P0"]
p1_j = crossing_list[-1]["P1"]
p2_j = crossing_list[-1]["P2"]
p3_j = crossing_list[-1]["P3"]
# Optical Routing Logic:
if j == 0:
# The first crossing in the row connects directly to the AMZI outputs
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p3_i, port2=p3_j)
)
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p2_i, port2=p1_j)
)
else:
# Subsequent crossings are chained to the previously placed crossings.
# Negative indexing fetches the correct adjacent components from the flat list.
p2_j_minus = crossing_list[-2]["P2"]
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p2_j_minus, port2=p1_j)
)
p3_j_minus = crossing_list[-(i + 2)]["P0"]
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p3_j_minus, port2=p3_j)
)
# Attach output waveguides and initial Phase Shifters (TPS)
# Top paths (Alice's tree)
if j == i:
wg_list.append(
projector_circuit.add_reference(
create_wg(length=(n_src - j) * period_x + wg_excess_length)
)
)
wg_list[-1].x_min = crossing_list[-1].x_max + 4 * bend_radius
wg_list[-1].y_mid = crossing_list[-1].y_max + 2 * bend_radius
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p2_j, port2=wg_list[-1]["P0"])
)
tps_list.append(projector_circuit.add_reference(tps))
tps_list[-1].connect("P0", wg_list[-1]["P1"])
# Bottom paths (Bob's tree)
if i == n_src - 1:
wg_list.append(
projector_circuit.add_reference(
create_wg(length=(n_src - j) * period_x + wg_excess_length)
)
)
wg_list[-1].x_min = crossing_list[-1].x_max + 4 * bend_radius
wg_list[-1].y_mid = crossing_list[-1].y_min - 2 * bend_radius
projector_circuit.add_reference(
pf.parametric.route_s_bend(port1=p0_j, port2=wg_list[-1]["P0"])
)
tps_list.append(projector_circuit.add_reference(tps))
tps_list[-1].connect("P0", wg_list[-1]["P1"])
viewer(projector_circuit)
[4]:
Quantum Projector Tree Assembly¶
This cell programmatically generates the cascaded MZI mesh that forms the core of the quantum projectors.
Because the physical layout interleaves Alice and Bob’s components, the algorithm sorts the initial phase shifters (TPS) from top to bottom. It then recursively groups them into pairs, placing a new MZI to merge each pair. This elegantly builds two independent binary MZI trees simultaneously: one terminating at Alice’s detector, and one terminating at Bob’s.
[5]:
# Track pitch used by the automatic router in the stages below. It must stay
# below bend_radius so that a bend always fits between two adjacent tracks.
route_pitch = 9
# 1. Sort all TPS components strictly from Top to Bottom
# This ensures Alice's components (top half) and Bob's (bottom half) pair correctly
sorted_indices = sorted(
range(len(tps_list)), key=lambda i: tps_list[i].origin[1], reverse=True
)
# 2. Initialize the base of the tree with the sorted phase shifters
previous_layer = [tps_list[idx] for idx in sorted_indices]
mzi_list = []
layer_number = 0
# Placement runs first: every connection is appended to a pair of port lists and
# routed once the trees are complete. The tree hops and the detector fan-out are
# collected separately because they are routed with different track pitches.
tree_port1 = []
tree_port2 = []
fanout_port1 = []
fanout_port2 = []
# 3. Build the MZI trees
# Note: We stop when 2 components remain because we are building TWO independent
# trees (Alice's final MZI and Bob's final MZI).
while len(previous_layer) > 2:
current_layer = []
if layer_number == 0:
mzi_offset = bp_spacing - trace_width
else:
mzi_offset = max(bp_spacing, len(previous_layer) * 2 * trace_width)
# Step through the previous layer, pairing components 2 at a time
for i in range(0, len(previous_layer), 2):
top_ref = previous_layer[i]
bottom_ref = previous_layer[i + 1]
# Instantiate the new merging MZI
new_mzi = projector_circuit.add_reference(mzi)
# Geometrically position the MZI centered between and to the right of its parents
new_mzi.y_mid = (top_ref.y_mid + bottom_ref.y_mid) / 2
new_mzi.x_min = top_ref.x_max + mzi_offset
# Snap the MZI's origin onto the grid. The MZI's port-pitch midline sits on
# origin.y (not on y_mid), so this is what controls whether P0/P1/P2/P3 land
# on a grid coincident with the incoming routes.
dx = pf.snap_to_grid(new_mzi.origin[0]) - new_mzi.origin[0]
dy = pf.snap_to_grid(new_mzi.origin[1]) - new_mzi.origin[1]
if dx or dy:
new_mzi.translate((dx, dy))
mzi_list.append(new_mzi)
current_layer.append(new_mzi)
# If the previous layer had 4 items, this is the final layer of our 2 trees.
# Expose the final outputs as the main circuit detector ports.
if len(previous_layer) == 4:
gc_ref0 = projector_circuit.add_reference(gc)
gc_ref0.x_min = new_mzi.x_max + 4 * bend_radius
gc_ref0.y_mid = new_mzi["P2"].center[1] - gc.size()[1]
gc_ref1 = projector_circuit.add_reference(gc)
gc_ref1.x_min = gc_ref0.x_min
gc_ref1.y_mid = new_mzi["P3"].center[1] + gc.size()[1]
# Detector fan-out: P2 is the lower output and drops to the lower
# grating coupler, P3 the upper one, so the pair does not cross.
fanout_port1 += [(new_mzi, "P2"), (new_mzi, "P3")]
fanout_port2 += [(gc_ref0, "P0"), (gc_ref1, "P0")]
projector_circuit.add_port(gc_ref0["P1"])
projector_circuit.add_port(gc_ref1["P1"])
# 4. Collect the parent -> child connections.
# The first layer connects to TPS components (which only have port 'P1').
# Subsequent layers connect to previous MZIs (ports 'P2' and 'P3').
if layer_number == 0:
name_top, name_bottom = "P1", "P1"
else:
name_top, name_bottom = "P2", "P3"
tree_port1 += [(top_ref, name_top), (bottom_ref, name_bottom)]
tree_port2 += [(new_mzi, "P1"), (new_mzi, "P0")]
# The new column we just built becomes the "previous" layer for the next iteration
previous_layer = current_layer
layer_number += 1
# 5. Route the tree hops in a single call. No `cross=` is supplied: a merge
# tree is planar, so the call fails rather than silently adding a crossing.
tree_routes = pf.parametric.route_auto(
port1=tree_port1,
port2=tree_port2,
radius=bend_radius,
grid_size=route_pitch,
net_reorder=False,
show_progress=True,
)
projector_circuit.add(tree_routes)
# 6. Route the detector fan-out on a finer grid: these hops leave ports only
# 1.3 um apart, so the pitch has to resolve them.
fanout_pitch = 2
fanout_routes = pf.parametric.route_auto(
port1=fanout_port1,
port2=fanout_port2,
radius=bend_radius,
grid_size=fanout_pitch,
# This is a separate call, so the tree routes above are not automatically
# visible to it, so hand them over explicitly as obstacles.
obstacles=tree_routes,
net_reorder=False,
show_progress=True,
)
projector_circuit.add(fanout_routes)
# Add the circuit model for S-matrix extraction
projector_circuit.add_model(pf.CircuitModel())
# Render the physical layout!
viewer(projector_circuit)
[5]:
Pump Splitter Assembly¶
This section constructs the optical distribution network for the pump laser. It generates a hierarchical, binary tree of MZIs designed to evenly split the incoming pump beam across all photon source channels (n_src).
A critical design pattern introduced in this component is the explicit promotion of electrical terminals. As the binary tree is assembled, the electrical terminals (T0, T1) of each internal MZI are mapped directly to the top-level Pump Splitter component. Because this entire sub-assembly will be translated (displaced) when placed into the final main circuit, attempting to locate terminal coordinates later using the local splitter_list would result in spatial referencing errors.
Promoting the terminals to the top-level component ensures their coordinates automatically track with the component’s displacement, making top-level electrical routing reliable.
[6]:
# Initialize the top-level Pump Splitter component and a tracking list for its internal nodes
pump_splitter = pf.Component("Pump Splitter")
splitter_list = []
# Instantiate the root MZI of the splitter tree
ref_0 = pump_splitter.add_reference(mzi)
splitter_list.append(ref_0)
# CRITICAL: Promote the internal MZI electrical terminals to the top-level component.
# This ensures terminals remain accessible and correctly mapped after the entire
# Pump Splitter is displaced in the final top-level layout.
pump_splitter.add_terminal(ref_0["T0"])
pump_splitter.add_terminal(ref_0["T1"])
n_layer = 0
# As in the projector trees, parent -> child connections are collected during
# placement and routed by one route_auto call after the tree is complete.
pump_port1 = []
pump_port2 = []
# Recursively build the binary tree layer by layer until the number of outputs
# equals the required number of source channels (n_src).
while 2 ** (n_layer + 1) < n_src:
n_layer += 1
# Dynamically calculate the horizontal offset for the current layer.
# This expands the spacing deeper in the tree to accommodate the growing number
# of vertical electrical traces that must route cleanly between the MZIs.
mzi_offset = max(bp_spacing, (2**n_layer) * 2 * trace_width)
# Step through the current branch count, placing two child MZIs for every parent
for i in range(0, 2**n_layer, 2):
# Retrieve the parent MZI from the previous layer using negative indexing
perv_ref = splitter_list[-i // 2 - 2 ** (n_layer - 1)]
top_ref = pump_splitter.add_reference(mzi)
bot_ref = pump_splitter.add_reference(mzi)
# Calculate vertical alignment: scale the pitch based on the current tree depth
# to cleanly fan out the branches without optical overlaps.
top_ref.y_mid = perv_ref.y_mid + period_y * 2 ** (np.log2(n_src) - n_layer) / 2
bot_ref.y_mid = perv_ref.y_mid - period_y * 2 ** (np.log2(n_src) - n_layer) / 2
# Apply the horizontal step calculation
top_ref.x_min = perv_ref.x_max + mzi_offset
bot_ref.x_min = perv_ref.x_max + mzi_offset
# Snap both origins onto the fabrication grid: the y_mid expressions land on
# half-nanometre coordinates, which the DRC flags as Si spacing seams.
for ref in (top_ref, bot_ref):
dx = pf.snap_to_grid(ref.origin[0]) - ref.origin[0]
dy = pf.snap_to_grid(ref.origin[1]) - ref.origin[1]
if dx or dy:
ref.translate((dx, dy))
splitter_list.append(top_ref)
splitter_list.append(bot_ref)
# Collect the parent -> child connections. The parent's lower output (P2)
# feeds the lower child and its upper output (P3) the upper child, so this
# fan-out is planar too and needs no crossings.
pump_port1 += [(perv_ref, "P2"), (perv_ref, "P3")]
pump_port2 += [(bot_ref, "P1"), (top_ref, "P0")]
# Continue promoting the new child terminals to the top-level component
pump_splitter.add_terminal(top_ref["T0"])
pump_splitter.add_terminal(top_ref["T1"])
pump_splitter.add_terminal(bot_ref["T0"])
pump_splitter.add_terminal(bot_ref["T1"])
# If this is the final layer of the tree, expose the optical outputs
# as top-level ports so they can be connected to the rest of the quantum chip.
if 2 ** (n_layer + 1) == n_src:
pump_splitter.add_port(top_ref["P3"])
pump_splitter.add_port(top_ref["P2"])
pump_splitter.add_port(bot_ref["P3"])
pump_splitter.add_port(bot_ref["P2"])
# Route the whole splitter tree in one call.
pump_routes = pf.parametric.route_auto(
port1=pump_port1,
port2=pump_port2,
radius=bend_radius,
grid_size=route_pitch,
net_reorder=False,
show_progress=True,
)
pump_splitter.add(pump_routes)
viewer(pump_splitter)
[6]:
Top-Level Circuit Integration¶
This section integrates the previously constructed sub-assemblies (the quantum projector core and the pump distribution network) into the final, top-level Main Circuit.
[7]:
# Initialize the final, top-level component that will contain the entire chip layout.
main_circuit = pf.Component("Main Circuit")
projector_ref = main_circuit.add_reference(projector_circuit)
pump_splitter_ref = main_circuit.add_reference(pump_splitter)
# Macro-Placement: Position the pump splitter to the left of the projector circuit.
# The calculation leaves exact spatial clearance to accommodate the physical width
# of the spiral delay lines, plus a safety buffer of 4 bend radii.
pump_splitter_ref.x_max = projector_ref.x_min - spiral.size()[0] - 4 * bend_radius
# Apply a vertical offset based on the physical height of the spiral delay lines.
pump_splitter_ref.y_mid = projector_ref.y_mid - spiral.size()[1]
# Iterate through each photon source channel to bridge the sub-assemblies.
for i in range(n_src):
# Instantiate a spiral delay line for the current channel.
spiral_ref = main_circuit.add_reference(spiral).connect(
"P1", projector_ref[f"P{i}"]
)
# Dynamically route an optical S-bend to bridge the remaining gap between
# the pump splitter's output port and the spiral delay line's input port ('P0').
main_circuit.add_reference(
pf.parametric.route_s_bend(
port1=pump_splitter_ref[f"P{i}"],
port2=spiral_ref["P0"],
)
)
# Render the fully assembled top-level optical layout!
viewer(main_circuit)
[7]:
Electrical Routing¶
Pump Splitter Assembly¶
With the optical pathways established, this phase transitions to the electrical layout. The following section programmatically generates the electrical wiring connecting the top and bottom bond pad arrays to the active components, beginning with the Pump Splitter’s MZIs.
To ensure manufacturing viability and prevent short circuits (DRC violations), the script uses Manhattan routing (strictly orthogonal, 90-degree traces). The algorithm calculates precise physical clearances and assigns each trace its own horizontal and vertical channel so the nested traces fan out without overlapping.
[8]:
def pad_trace(pad, terminal, y_channel, x_channel=None, y_elbow=None):
"""Route one Manhattan trace from a bond pad to a device terminal.
The trace leaves `pad` vertically, runs along `y_channel`, drops down
`x_channel` and enters `terminal` vertically. `x_channel=None` routes
straight down when the pad already lines up with the terminal; `y_elbow`
adds the extra row used to reach a terminal from a neighbouring row.
"""
if x_channel is None:
waypoints = [(terminal.center()[0], y_channel)]
elif y_elbow is None:
waypoints = [(x_channel, y_channel), (x_channel, terminal.center()[1])]
else:
waypoints = [
(x_channel, y_channel),
(x_channel, y_elbow),
(terminal.center()[0], y_elbow),
]
return pf.parametric.route_manhattan(
terminal1=pad,
terminal2=terminal,
width=trace_width,
waypoints=waypoints,
direction1="y",
direction2="y",
)
# --- 1. Bond Pad Array Placement ---
# Calculate the total number of bond pads needed per array (top and bottom).
n_bp = 4 * n_src + 2 * (n_src - 1)
# Determine the starting X-coordinate (x0_bp) to align the bond pad arrays.
# This calculation ensures the pads are positioned with enough horizontal clearance
# relative to the input AMZI stage to allow for trace routing.
x0_bp = (
amzi_list[0]["T0"].center()[0]
- (2 * n_src - 1) * bp_spacing
+ 2 * (n_src - 1) * trace_width
)
# Instantiate the top and bottom bond pad arrays.
# The Y-origins incorporate a buffer (8 * n_src * trace_width) to guarantee
# safe physical clearance between the active optical components and the electrical pads.
bp_ref_top = pf.Reference(
bp,
origin=(x0_bp, amzi_list[0].y_max + 8 * n_src * trace_width + bp.size()[1]),
rows=1,
columns=n_bp,
spacing=(bp_spacing, 0),
)
bp_ref_bot = pf.Reference(
bp,
origin=(x0_bp, amzi_list[-1].y_min - 8 * n_src * trace_width - bp.size()[1]),
rows=1,
columns=n_bp,
spacing=(bp_spacing, 0),
)
main_circuit.add(bp_ref_top, bp_ref_bot)
# --- 2. Routing Internal Splitter MZIs ---
# Initialize tracking variables to traverse the binary tree layers.
n_mzi = n_src // 2
n0_mzi = n_src // 2 - 1
n0_bp = n_src - 1
# Iterate through the MZI tree layers, routing from the inner layers outward.
while n_mzi > 1:
n_mzi = n_mzi // 2
for i in range(n_mzi):
# Configurations for (Top Routing, Bottom Routing)
# Tuple format: (bp_reference, mzi_index, direction_sign)
# The direction_sign neatly mirrors the layout math: -1 for top, +1 for bottom.
configs = [
(bp_ref_top, i + n0_mzi, -1),
(bp_ref_bot, 2 * n_mzi - 1 - i + n0_mzi, 1),
]
for bp_ref, mzi_idx, sign in configs:
# Fetch the specific bond pad and MZI electrical terminals
T0_bp = bp_ref["T0"][-2 * i + n0_bp - 1]
T1_bp = bp_ref["T0"][-2 * i + n0_bp]
T0_mzi = pump_splitter_ref[f"T{2*mzi_idx}"]
T1_mzi = pump_splitter_ref[f"T{2*mzi_idx + 1}"]
# Calculate primary routing channels to prevent trace overlapping.
# x0 creates horizontal stepping, while y_c1 creates staggered vertical spacing.
x0 = T0_mzi.center()[0] - (2 * i - 1) * 2 * trace_width
y_c1 = T0_bp.center()[1] + sign * (
bp.size()[1] + 4 * (n_src + i - n0_bp / 2 + 1 / 2) * trace_width
)
y_c2 = T0_mzi.center()[1]
# Define localized offsets to separate the T0 and T1 parallel traces
wp_offset = -sign * 2 * trace_width
# Generate the physical Manhattan (90-degree) routing traces.
# Edge case: The innermost trace requires fewer turns
main_circuit.add(
pad_trace(T0_bp, T0_mzi, y_c1, x0 - 2 * trace_width),
pad_trace(
T1_bp,
T1_mzi,
y_c1 + wp_offset,
None if i == 0 else x0,
y_elbow=y_c2 + wp_offset,
),
)
# Update tracking indices for the next layer of the binary tree
n0_mzi -= n_mzi
n0_bp -= 2 * n_mzi
# --- 3. Routing the Input (Root) MZI ---
# The very first MZI in the pump splitter tree is routed separately to the top bond pad array.
T0_bp = bp_ref_top["T0"][0]
T1_bp = bp_ref_top["T0"][1]
T0_mzi = pump_splitter_ref["T0"]
T1_mzi = pump_splitter_ref["T1"]
x0 = T0_mzi.center()[0] - 2 * trace_width
y_c1 = T0_bp.center()[1] - (
bp.size()[1] + 4 * (n_src - n0_bp / 2 + 1 / 2) * trace_width
)
main_circuit.add(
pad_trace(T0_bp, T0_mzi, y_c1),
pad_trace(T1_bp, T1_mzi, y_c1 + 2 * trace_width),
)
# Render the layout to verify the newly added bond pads and traces
viewer(main_circuit)
[8]:
Signal/Idler Input AMZI Array¶
This section continues the electrical layout by connecting the front-end Asymmetric Mach-Zehnder Interferometers (AMZIs) to the top and bottom bond pad arrays.
Similar to the pump splitter routing, the layout leverages the circuit’s structural symmetry. It iterates through half of the photon source channels (n_src // 2), simultaneously routing one AMZI to the top array and its geometric counterpart to the bottom array. The algorithm uses a sign multiplier (-1 for top, 1 for bottom) to cleanly mirror the routing logic and maintain consistent DRC-compliant clearances.
[9]:
# Initialize the bond pad tracking index.
# This shifts the starting pad index to account for the pads already consumed
n0_bp = n_src
# Iterate through half of the sources, routing simultaneously to top and bottom pads.
for i in range(n_src // 2):
# Configurations for (Top Routing, Bottom Routing)
# Tuple format: (bp_reference, amzi_index, direction_sign)
configs = [
(bp_ref_top, i, -1), # Top half of AMZIs
(bp_ref_bot, -i - 1, 1), # Bottom half of AMZIs (using negative indexing)
]
for bp_ref, amzi_idx, sign in configs:
# Fetch the corresponding electrical terminals for the bond pads and AMZIs
T0_bp = bp_ref["T0"][2 * i + n0_bp]
T1_bp = bp_ref["T0"][2 * i + n0_bp + 1]
T0_amzi = amzi_list[amzi_idx]["T0"]
T1_amzi = amzi_list[amzi_idx]["T1"]
# Calculate primary routing channels
# x0 determines the horizontal stepping to avoid overlapping parallel traces
x0 = T1_amzi.center()[0] + (2 * i - 1) * 2 * trace_width
# y_c1 staggers the vertical height of the traces fanning out from the pads.
# The sign multiplier elegantly unifies the addition/subtraction for top vs. bottom.
y_c1 = T0_bp.center()[1] + sign * (
bp.size()[1] + 4 * (n_src // 2 - i) * trace_width
)
y_c2 = T0_amzi.center()[1]
# Define the waypoint offset, inverted relative to the main y_c1 offset
wp_offset = -sign * 2 * trace_width
# Generate the physical Manhattan (90-degree) routing traces.
# Edge case: The outermost AMZI requires fewer turns as it has direct line-of-sight
# Add the generated routes to the projector circuit
projector_circuit.add(
pad_trace(
T0_bp,
T0_amzi,
y_c1,
None if i == 0 else x0,
y_elbow=y_c2 + wp_offset,
),
pad_trace(T1_bp, T1_amzi, y_c1 + wp_offset, x0 + 2 * trace_width),
)
# Update the bond pad tracking index for the next routing phase
n0_bp += n_src
# Render the layout to verify the AMZI traces
viewer(main_circuit)
[9]:
Thermo-Optic Phase Shifters (TPS)¶
This section routes the electrical connections for the initial layer of Thermo-Optic Phase Shifters (TPS).
Because the optical crossing matrix physically interleaved Alice and Bob’s pathways, the TPS components in tps_list are not strictly ordered by their vertical Y-coordinates. To ensure clean, non-overlapping electrical routing, the algorithm utilizes the sorted_indices array generated during the optical tree assembly. This guarantees that traces fan out sequentially from top to bottom, perfectly mapping Alice’s upper phase shifters to the top bond pad array and Bob’s lower phase shifters
to the bottom array.
[10]:
for i in range(n_src):
# Calculate x0 once per 'i' since both top and bottom use bp_ref_top for x-alignment
x0 = bp_ref_top["T0"][n0_bp].x_mid + i * 4 * trace_width
# Configurations for (Top Routing, Bottom Routing)
# Tuple format: (bp_reference, tps_index, direction_sign)
configs = [(bp_ref_top, n_src - i - 1, -1), (bp_ref_bot, n_src + i, 1)]
for bp_ref, tps_idx, sign in configs:
# Fetch terminals
T0_bp = bp_ref["T0"][2 * i + n0_bp]
T1_bp = bp_ref["T0"][2 * i + n0_bp + 1]
T0_tps = tps_list[sorted_indices[tps_idx]]["T0"]
T1_tps = tps_list[sorted_indices[tps_idx]]["T1"]
# Apply the sign multiplier to unify the addition/subtraction
y_c1 = T0_bp.center()[1] + sign * (bp.size()[1] + 4 * i * trace_width)
y_c2 = T0_tps.center()[1]
# Waypoint offsets
wp_offset_c1 = sign * 2 * trace_width
wp_offset_c2 = -sign * 2 * trace_width
main_circuit.add(
pad_trace(T0_bp, T0_tps, y_c1, x0),
pad_trace(
T1_bp,
T1_tps,
y_c1 + wp_offset_c1,
None if i == n_src - 1 else x0 + 2 * trace_width,
y_elbow=y_c2 + wp_offset_c2,
),
)
n0_bp += 2 * n_src
viewer(main_circuit)
[10]:
Quantum Projector MZI Trees¶
This section concludes the active electrical layout by routing the core Mach-Zehnder Interferometers (MZIs) that make up the quantum projector trees.
Because the projectors were built as two independent binary trees (Alice’s tree routing upward, Bob’s tree routing downward), the algorithm processes the mzi_list layer by layer. Starting from the widest part of the trees (closest to the photon sources) and stepping outward toward the final detectors, it systematically connects Alice’s components to the top bond pad array and Bob’s components to the bottom array.
[11]:
# n_mzi tracks the number of MZIs in the current layer being routed.
n_mzi = n_src
n0_mzi = 0
# Iterate through the binary tree layers, from the widest base to the final tip.
while n_mzi > 1:
# Halve the count for the current layer of the binary tree
n_mzi = n_mzi // 2
for i in range(n_mzi):
# Configurations for (Top Routing, Bottom Routing)
# Tuple format: (bp_reference, mzi_index, direction_sign)
configs = [
(bp_ref_top, i + n0_mzi, -1),
(bp_ref_bot, n0_mzi + 2 * n_mzi - 1 - i, 1),
]
for bp_ref, mzi_idx, sign in configs:
# Fetch terminals
T0_bp = bp_ref["T0"][2 * i + n0_bp]
T1_bp = bp_ref["T0"][2 * i + n0_bp + 1]
T0_mzi = mzi_list[mzi_idx]["T0"]
T1_mzi = mzi_list[mzi_idx]["T1"]
x0 = T1_mzi.center()[0] + (2 * i - 1) * 2 * trace_width
# Apply the sign multiplier to unify the addition/subtraction
y_c1 = T0_bp.center()[1] + sign * (
bp.size()[1] + 4 * (n_src + i + n0_mzi // 2) * trace_width
)
y_c2 = T0_mzi.center()[1]
# Waypoint offsets
wp_offset_c1 = sign * 2 * trace_width
wp_offset_c2 = -sign * 2 * trace_width
main_circuit.add(
pad_trace(
T0_bp,
T0_mzi,
y_c1,
None if i == 0 else x0,
y_elbow=y_c2 + wp_offset_c2,
),
pad_trace(T1_bp, T1_mzi, y_c1 + wp_offset_c1, x0 + 2 * trace_width),
)
# Increment state for the next layer of the MZI tree
n0_mzi += 2 * n_mzi
n0_bp += 2 * n_mzi
viewer(main_circuit)
[11]:
Floorplanning and Dicing Boundaries¶
With the optical and electrical routing complete, this section finalizes the physical footprint of the chip.
To prepare the design for foundry fabrication, we must explicitly define the active chip area, the overall floorplan, and the dicing “streets”, the sacrificial margins surrounding the active area where the saw will physically cut the die from the silicon wafer. The algorithm extracts the exact bounding box of our assembled circuit and uses Boolean geometry to generate these critical manufacturing boundaries.
[12]:
# Define the width of the dicing street (margin) required for physically cutting the wafer.
dicing_size = 200
# 1. Define the Active Chip Area
chip_area = pf.Rectangle(
corner1=main_circuit.bounds()[0],
corner2=main_circuit.bounds()[1]
)
# 2. Define the Total Floorplan Area
floorplan_area = pf.Rectangle(
corner1=(
main_circuit.bounds()[0][0] - dicing_size,
main_circuit.bounds()[0][1] - dicing_size,
),
corner2=(
main_circuit.bounds()[1][0] + dicing_size,
main_circuit.bounds()[1][1] + dicing_size,
),
)
# 3. Generate the Dicing Streets (Boolean Math)
dicing_area = pf.boolean(pf.offset(chip_area, dicing_size), chip_area, "-")
# 4. Add these logical boundaries to the top-level layout.
main_circuit.add("Standard Design Area", chip_area)
main_circuit.add("Custom Dicing", *dicing_area)
main_circuit.add("Floor Plan-Not Fabricated", floorplan_area)
# Render the final layout, now complete with its manufacturing boundaries!
viewer(main_circuit)
[12]:
GDSII Export and Design Rule Check¶
This final section prepares the completed layout for manufacturing. First, it exports the entire top-level circuit into a standard GDSII (.gds) file, which is the industry-standard format required by foundries for photonic fabrication.
Next, it performs an automated Design Rule Check (DRC) using KLayout via the Tidy3D plugin. The DRC script evaluates the physical layout against a specific foundry rule set (SiEPIC_EBeam.drc) to ensure no manufacturing constraints (such as minimum trace widths, component spacing, or waveguide bend radii) have been violated. Passing the DRC is a critical milestone, confirming the chip is physically viable for production.
For more information on DRC see this guide.
[13]:
# Import the necessary KLayout DRC utilities from the Tidy3D plugin
from tidy3d.plugins.klayout.drc import DRCResults, DRCRunner
# --- 1. GDSII Export ---
gds_name = "Quantum_Chip.gds"
main_circuit.write_gds(gds_name)
# --- 2. Design Rule Check (DRC) ---
runner = DRCRunner(drc_runset="SiEPIC_EBeam.drc")
# Execute the automated DRC on the newly generated GDSII file
results = runner.run(source=gds_name)
# Evaluate and output the results of the physical verification
if results.is_clean:
print(" DRC passed!\n")
else:
print(" DRC did not pass!\n")
14:58:03 Eastern Daylight Time Running KLayout DRC on GDS file 'Quantum_Chip.gds' with runset 'SiEPIC_EBeam.drc' and saving results to 'drc_results.lyrdb'...
14:59:02 Eastern Daylight Time KLayout DRC completed successfully.
DRC passed!
Conclusion and Next Steps¶
We have successfully completed the physical layout of the quantum photonic chip. By programmatically assembling the optical and electrical components, we achieved a robust, scalable architecture ready for foundry fabrication.
What’s Next: Circuit Simulation
Passing the DRC guarantees that the chip can be manufactured, but we must now verify that its optical characteristics behave as intended.
In the next notebook, we will transition from physical layout to performance verification. We will:
Extract the S-matrix of our assembled projector circuit.
Simulate the propagation of quantum states (entangled photon pairs) through the network.
Analyze the output fidelity of our quantum projectors.