Quantum Chip Layout

a4ad64aded3c441b91b489d743453d3b

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:

  1. 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
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
[2]:
import warnings

# Ignores RuntimeWarnings to keep the notebook clean
warnings.simplefilter("ignore", RuntimeWarning)

viewer = LiveViewer()
LiveViewer started at http://localhost:39505

The following cell defines the s_bend_route utility function. This function dynamically generates and connects an S-bend waveguide between two specified ports, incorporating physically realistic parameters—such as propagation loss and phase delay—into the routing model.

[3]:
def s_bend_route(circuit, port1, port2):
    """
    Dynamically generates and connects an S-bend waveguide between two ports.
    Incorporate realistic physical parameters (loss, phase delay) into the routing.
    """

    # Define the physical physics model for the connecting waveguide
    s_bend_model = pf.AnalyticWaveguideModel(
        n_eff=n_eff,
        reference_frequency=freq0,
        propagation_loss=propagation_loss,
        n_group=n_group,
    )

    # Calculate the geometric bounding box required to connect the ports
    path = port2.center - port1.center

    # Generate the physical S-bend geometry
    s_bend = pf.parametric.s_bend(length=path[0], offset=path[1], model=s_bend_model)

    # Instantiate the S-bend in the circuit and fuse its input (P0) to port1
    circuit.add_reference(s_bend).connect("P0", port1)

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)

# 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
            s_bend_route(projector_circuit, p3_i, p3_j)
            s_bend_route(projector_circuit, p2_i, 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"]
            s_bend_route(projector_circuit, p2_j_minus, p1_j)

            p3_j_minus = crossing_list[-(i + 2)]["P0"]
            s_bend_route(projector_circuit, p3_j_minus, 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
            s_bend_route(projector_circuit, p2_j, 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
            s_bend_route(projector_circuit, p0_j, 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]:
../_images/examples_Quantum_Chip_Layout_6_0.svg

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]:
# 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

# 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

        mzi_list.append(new_mzi)
        current_layer.append(new_mzi)

        # Extract ports for routing
        p0 = new_mzi["P0"]  # Bottom Input
        p1 = new_mzi["P1"]  # Top Input
        p2 = new_mzi["P2"]  # Top Output
        p3 = new_mzi["P3"]  # Bottom Output

        # 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 = p2.center[1] - gc.size()[1]
            pin_gc0 = gc_ref0["P0"]
            pout_gc0 = gc_ref0["P1"]

            gc_ref1 = projector_circuit.add_reference(gc)
            gc_ref1.x_min = gc_ref0.x_min
            gc_ref1.y_mid = p3.center[1] + gc.size()[1]
            pin_gc1 = gc_ref1["P0"]
            pout_gc1 = gc_ref1["P1"]

            s_bend_route(projector_circuit, p2, pin_gc0)
            s_bend_route(projector_circuit, p3, pin_gc1)

            projector_circuit.add_port(pout_gc0)
            projector_circuit.add_port(pout_gc1)

        # 4. Route the S-bends
        # 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:
            p_top = top_ref["P1"]
            p_bottom = bottom_ref["P1"]
        else:
            p_top = top_ref["P2"]  # Top parent's Top Out
            p_bottom = bottom_ref["P3"]  # Bot parent's Bot Out

        s_bend_route(projector_circuit, p_top, p1)
        s_bend_route(projector_circuit, p_bottom, p0)

    # The new column we just built becomes the "previous" layer for the next iteration
    previous_layer = current_layer
    layer_number += 1

# Add the circuit model for S-matrix extraction
projector_circuit.add_model(pf.CircuitModel())

# Render the physical layout!
viewer(projector_circuit)
[5]:
../_images/examples_Quantum_Chip_Layout_8_0.svg

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

# 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

        splitter_list.append(top_ref)
        splitter_list.append(bot_ref)

        # Fetch the optical ports for routing
        p2_0 = perv_ref["P2"] # Parent Top Output
        p3_0 = perv_ref["P3"] # Parent Bottom Output

        p0 = top_ref["P0"]    # Top Child Bottom Input
        p1 = bot_ref["P1"]    # Bottom Child Top Input

        # Route the optical S-bends from parent to children
        s_bend_route(pump_splitter, p2_0, p1)
        s_bend_route(pump_splitter, p3_0, 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"])

viewer(pump_splitter)
[6]:
../_images/examples_Quantum_Chip_Layout_10_0.svg

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').
    s_bend_route(
        main_circuit,
        pump_splitter_ref[f"P{i}"],
        spiral_ref["P0"],
    )

# Render the fully assembled top-level optical layout!
viewer(main_circuit)
[7]:
../_images/examples_Quantum_Chip_Layout_12_0.svg

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 generates specific coordinate waypoints (waypoints_0, waypoints_1) to cleanly fan out the nested electrical traces without overlapping.

[8]:
# --- 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_c1 = -sign * 2 * trace_width
            wp_offset_c2 = -sign * 2 * trace_width

            # Define the orthogonal path coordinates (waypoints)
            waypoints_0 = [
                (x0 - 2 * trace_width, y_c1),
                (x0 - 2 * trace_width, y_c2),
            ]

            waypoints_1 = [
                (x0, y_c1 + wp_offset_c1),
                (x0, y_c2 + wp_offset_c2),
                (T1_mzi.center()[0], y_c2 + wp_offset_c2),
            ]

            # Edge case: The innermost trace requires fewer turns
            if i == 0:
                waypoints_1 = [(T1_mzi.center()[0], y_c1 + wp_offset_c1)]

            # Generate the physical Manhattan (90-degree) routing traces
            route_0 = pf.parametric.route_manhattan(
                terminal1=T0_bp, terminal2=T0_mzi, width=trace_width,
                waypoints=waypoints_0, direction1="y", direction2="y",
            )
            route_1 = pf.parametric.route_manhattan(
                terminal1=T1_bp, terminal2=T1_mzi, width=trace_width,
                waypoints=waypoints_1, direction1="y", direction2="y",
            )

            main_circuit.add(route_0, route_1)

    # 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
)

waypoints_0 = [(T0_mzi.center()[0], y_c1)]
waypoints_1 = [(T1_mzi.center()[0], y_c1 + 2 * trace_width)]

route_0 = pf.parametric.route_manhattan(
    terminal1=T0_bp, terminal2=T0_mzi, width=trace_width,
    waypoints=waypoints_0, direction1="y", direction2="y",
)
route_1 = pf.parametric.route_manhattan(
    terminal1=T1_bp, terminal2=T1_mzi, width=trace_width,
    waypoints=waypoints_1, direction1="y", direction2="y",
)

main_circuit.add(route_0, route_1)

# Render the layout to verify the newly added bond pads and traces
viewer(main_circuit)
[8]:
../_images/examples_Quantum_Chip_Layout_14_0.svg

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

        # Define the orthogonal path coordinates (waypoints) for both traces
        waypoints_0 = [
            (x0, y_c1),
            (x0, y_c2 + wp_offset),
            (T0_amzi.center()[0], y_c2 + wp_offset),
        ]

        waypoints_1 = [
            (x0 + 2 * trace_width, y_c1 + wp_offset),
            (x0 + 2 * trace_width, y_c2),
        ]

        # Edge case: The outermost AMZI requires fewer turns as it has direct line-of-sight
        if i == 0:
            waypoints_0 = [(T0_amzi.center()[0], y_c1)]

        # Generate the physical Manhattan (90-degree) routing traces
        route_0 = pf.parametric.route_manhattan(
            terminal1=T0_bp, terminal2=T0_amzi, width=trace_width,
            waypoints=waypoints_0, direction1="y", direction2="y",
        )
        route_1 = pf.parametric.route_manhattan(
            terminal1=T1_bp, terminal2=T1_amzi, width=trace_width,
            waypoints=waypoints_1, direction1="y", direction2="y",
        )

        # Add the generated routes to the projector circuit
        projector_circuit.add(route_0, route_1)

# 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]:
../_images/examples_Quantum_Chip_Layout_16_0.svg

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

        waypoints_0 = [(x0, y_c1), (x0, y_c2)]

        waypoints_1 = [
            (x0 + 2 * trace_width, y_c1 + wp_offset_c1),
            (x0 + 2 * trace_width, y_c2 + wp_offset_c2),
            (T1_tps.center()[0], y_c2 + wp_offset_c2),
        ]

        if i == n_src - 1:
            waypoints_1 = [(T1_tps.center()[0], y_c1 + wp_offset_c1)]

        route_0 = pf.parametric.route_manhattan(
            terminal1=T0_bp,
            terminal2=T0_tps,
            width=trace_width,
            waypoints=waypoints_0,
            direction1="y",
            direction2="y",
        )

        route_1 = pf.parametric.route_manhattan(
            terminal1=T1_bp,
            terminal2=T1_tps,
            width=trace_width,
            waypoints=waypoints_1,
            direction1="y",
            direction2="y",
        )

        main_circuit.add(route_0, route_1)

n0_bp += 2 * n_src

viewer(main_circuit)
[10]:
../_images/examples_Quantum_Chip_Layout_18_0.svg

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

            waypoints_0 = [
                (x0, y_c1),
                (x0, y_c2 + wp_offset_c2),
                (T0_mzi.center()[0], y_c2 + wp_offset_c2),
            ]

            waypoints_1 = [
                (x0 + 2 * trace_width, y_c1 + wp_offset_c1),
                (x0 + 2 * trace_width, y_c2),
            ]

            if i == 0:
                waypoints_0 = [(T0_mzi.center()[0], y_c1)]

            route_0 = pf.parametric.route_manhattan(
                terminal1=T0_bp,
                terminal2=T0_mzi,
                width=trace_width,
                waypoints=waypoints_0,
                direction1="y",
                direction2="y",
            )

            route_1 = pf.parametric.route_manhattan(
                terminal1=T1_bp,
                terminal2=T1_mzi,
                width=trace_width,
                waypoints=waypoints_1,
                direction1="y",
                direction2="y",
            )

            main_circuit.add(route_0, route_1)

    # Increment state for the next layer of the MZI tree
    n0_mzi += 2 * n_mzi
    n0_bp += 2 * n_mzi

viewer(main_circuit)
[11]:
../_images/examples_Quantum_Chip_Layout_20_0.svg

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("Chip design area", chip_area)
main_circuit.add("Dicing", *dicing_area)
main_circuit.add("FloorPlan", floorplan_area)

# Render the final layout, now complete with its manufacturing boundaries!
viewer(main_circuit)
[12]:
../_images/examples_Quantum_Chip_Layout_22_0.svg

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:53:31 EDT Running KLayout DRC on GDS file 'Quantum_Chip.gds' with runset
             'SiEPIC_EBeam.drc' and saving results to 'drc_results.lyrdb'...
14:54:25 EDT 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:

  1. Extract the S-matrix of our assembled projector circuit.

  2. Simulate the propagation of quantum states (entangled photon pairs) through the network.

  3. Analyze the output fidelity of our quantum projectors.