Automated Routing of a Selector Switch¶
A 1 by N selector switch sends the light of one input to any one of N outputs. It is built as a binary tree of 1 by 2 switching elements: N - 1 elements in log2(N) stages, with exactly one element per stage on any path from the input to an output. That is the tree architecture of Spanke [1], and it is still the building block of silicon photonic switch fabrics: a strictly non-blocking N by N fabric is N of these trees feeding N mirrored combining trees [2].
Each element is a Mach-Zehnder interferometer with a thermo-optic phase shifter on one arm [3]. The tree points down, toward a single fiber array below it on the 127 μm pitch a fiber array is built to: one channel for the input and one per output.
Every route is drawn by route_auto with plain 90 degree corners. The question of this example is what path length matching does to the layout, so the same 1 by 16 selector, with every element in the same place, is routed twice:
Auto routing:
route_autoroutes the whole selector, and the fan that joins the tree to the fibers ends up with channels of different lengths.With matching: the fan asks for path length matching, and the router adds meanders to the short channels until every channel has the same length.
Each case prints its routing time and shows its layout.
References
Spanke, Ronald A. “Architectures for guided-wave optical space switching systems.” IEEE Communications Magazine 1987 25 (5), 42-48, doi: 10.1109/MCOM.1987.1093615.
Lee, Benjamin G., and Nicolas Dupuis. “Silicon photonic switch fabrics: technology and architecture.” Journal of Lightwave Technology 2019 37 (1), 6-20, doi: 10.1109/JLT.2018.2876828.
Harris, Nicholas C., et al. “Efficient, compact and low loss thermo-optic phase shifter in silicon.” Optics Express 2014 22 (9), 10487-10493, doi: 10.1364/OE.22.010487.
Chrostowski, Lukas, et al. “Silicon photonic circuit design using rapid prototyping foundry process design kits.” IEEE Journal of Selected Topics in Quantum Electronics 2019 25 (5), 8201326, doi: 10.1109/JSTQE.2019.2917501.
Setting up¶
The SiEPIC OpenEBL technology [4] is loaded through the siepic_forge module. Every parametric call in this example inherits the default arguments below, so the elements and the routes that connect them are built in one waveguide type: a 500 nm wide silicon strip that guides the TE mode at 1550 nm. The bend radius is 10 μm everywhere.
[1]:
import json
import time
import photonforge as pf
import siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every component in this example
pf.config.default_technology = siepic.ebeam()
bend_radius = 10.0 # Bend radius of the elements and of every route (μm)
euler_fraction = 0.5 # Fraction of each bend drawn as an Euler curve
# Set the default arguments shared by all parametric calls below
pf.config.default_kwargs = {
"port_spec": "TE_1550_500", # Port specification of the waveguides
"radius": bend_radius, # Bend radius (μm)
"euler_fraction": euler_fraction, # Fraction of each bend drawn as Euler curve
}
# Hide port and label text so the layout views stay uncluttered
pf.config.svg_labels = False
# Start the live viewer that displays the layouts
viewer = pf.live_viewer.LiveViewer()
LiveViewer started at http://127.0.0.1:46661. Call its 'stop()' method to free the port.
The switching element¶
Three cells come from the PDK: a Y branch, which splits the light at the input of each element, a 2 by 2 broadband directional coupler, which recombines it at the output, and a TE grating coupler for the fiber array.
The element is a Mach-Zehnder interferometer with one heated arm. Light entering P0 is split in two, the heater sets the relative phase of the arms, and the coupler sends the result to P1 or to P2 according to that phase. Both arms are the same length, so the element is balanced when the heater is off.
The two outputs of the coupler are only 4.7 μm apart, closer than the router can separate two waveguides. A pair of S bends spreads them to 40 μm, the output pitch of the tree, so the outputs of one element are spaced exactly like the outputs of two neighboring elements.
The heater is a resistive strip on the M1_heater layer with a contact on M2_router at either end, registered as terminals. Each contact sits on an M1_heater patch that extends 3 μm past it on every side, which is the overlap the design rules ask for. For the optical routing, both metal layers are blockers.
[2]:
# Load the Y branch, which splits the light at the input of each element
y_branch = siepic.component("ebeam_y_1550")
# Load the 2 by 2 broadband directional coupler, which recombines the arms
coupler = siepic.component("ebeam_bdc_te1550")
# Load the TE grating coupler used for the fiber array
grating = siepic.component("ebeam_gc_te1550")
# Measure the spacing between the two output ports of the Y branch (μm)
y_pitch = y_branch["P2"].center[1] - y_branch["P1"].center[1]
# Measure the spacing between the two output ports of the coupler (μm)
coupler_pitch = coupler["P3"].center[1] - coupler["P2"].center[1]
fiber_pitch = 127.0 # Pitch of a standard single mode fiber array (μm)
output_pitch = 40.0 # Spacing between adjacent outputs of the tree (μm)
arm_bend_length = 25.0 # Length of the S bends that open and close the arms (μm)
fan_bend_length = 40.0 # Length of the S bends that spread the outputs (μm)
contact_margin = 3.0 # Distance the M1 patch extends past each M2 contact (μm)
switch_element builds one element from these three cells. Its arguments set the length of the two arms, the distance between them, the width of the heater strip, and the side of each contact. The defaults give an element 236 μm long and 52 μm wide. The S bends that open and close the arms are 25 μm long, and those that spread the outputs are 40 μm long.
[3]:
# Define the switching element as a parametric component
@pf.parametric_component
def switch_element(
*,
arm_length=60.0, # Length of the straight section of each arm (μm)
arm_separation=30.0, # Distance between the two arms (μm)
heater_width=3.0, # Width of the heater strip (μm)
contact_size=12.0, # Side length of each square heater contact (μm)
):
# Create the parent component of the element
element = pf.Component()
# Add the Y branch that splits the input light
split = element.add_reference(y_branch)
# Calculate the offset of the S bends after the Y branch (μm)
split_offset = arm_separation / 2 - y_pitch / 2
# Calculate the offset of the S bends before the coupler (μm)
merge_offset = arm_separation / 2 - coupler_pitch / 2
# Create the S bend that opens the upper arm after the Y branch
up_in = pf.parametric.s_bend(length=arm_bend_length, offset=split_offset)
# Create the S bend that opens the lower arm after the Y branch
down_in = pf.parametric.s_bend(length=arm_bend_length, offset=-split_offset)
# Create the S bend that closes the lower arm before the coupler
up_out = pf.parametric.s_bend(length=arm_bend_length, offset=merge_offset)
# Create the S bend that closes the upper arm before the coupler
down_out = pf.parametric.s_bend(length=arm_bend_length, offset=-merge_offset)
# Create the straight section shared by both arms
arm = pf.parametric.straight(length=arm_length)
# Open the upper arm, the one the heater sits on, add its straight section, and close it
# toward the coupler
upper = pf.reference_chain(
{"open": up_in, "arm": arm, "close": down_out}, "open - arm - close", start=split["P2"]
)
element.add(*upper)
_, heated, upper_out = upper
# Read the start and end of the heated section for the heater strip
(x_start, y_strip), (x_stop, _) = heated["P0"].center, heated["P1"].center
# Build the lower arm the same way, with no heater and as long as the upper one
lower = pf.reference_chain(
{"open": down_in, "arm": arm, "close": up_out}, "open - arm - close", start=split["P1"]
)
element.add(*lower)
# Add the coupler, connected to the upper arm so it also aligns with the lower one
combine = element.add_reference(coupler).connect("P1", upper_out["P1"])
# Calculate the offset of the S bends that spread the outputs (μm)
fan_offset = output_pitch / 2 - coupler_pitch / 2
# Create the S bend that spreads the lower coupler output
low_bend = pf.parametric.s_bend(length=fan_bend_length, offset=-fan_offset)
# Add the S bend to the element
low = element.add_reference(low_bend)
# Connect the S bend to the lower coupler output
low.connect("P0", combine["P2"])
# Create the S bend that spreads the upper coupler output
high_bend = pf.parametric.s_bend(length=fan_bend_length, offset=fan_offset)
# Add the S bend to the element
high = element.add_reference(high_bend)
# Connect the S bend to the upper coupler output
high.connect("P0", combine["P3"])
# Create the heater strip along the upper arm
heater = pf.Rectangle(
(x_start, y_strip - heater_width / 2), (x_stop, y_strip + heater_width / 2)
)
# Add the heater strip to the heater layer
element.add("M1_heater", heater)
# Add one contact at each end of the heater
for i, x_contact in enumerate((x_start, x_stop)):
# Find the center of the contact, on the upper edge of the heater strip
center = (x_contact, y_strip + heater_width / 2 + contact_size / 2)
# Create the M2 contact
contact = pf.Rectangle(center=center, size=(contact_size, contact_size))
# Add the contact to the router layer
element.add("M2_router", contact)
# Calculate the side of the larger M1 patch under the contact (μm)
patch_size = contact_size + 2 * contact_margin
# Create the M1 patch under the contact
patch = pf.Rectangle(center=center, size=(patch_size, patch_size))
# Add the patch to the heater layer
element.add("M1_heater", patch)
# Register the contact as an electrical terminal
element.add_terminal(pf.Terminal("M2_router", contact), f"T{i}")
# Expose the input and the two outputs: P0 input, P1 lower output, P2 upper output
element.add_port([split["P0"], low["P1"], high["P1"]])
# Attach a circuit model so the element can be simulated later
element.add_model(pf.CircuitModel(), "Circuit")
return element
The cell below creates one element with the default dimensions and shows it in the viewer.
[4]:
# Create one switching element with the default dimensions
element = switch_element()
# Show the element in the viewer
viewer(element)
[4]:
Placing the tree and the fiber array¶
Element (stage, index) steers a block of n >> stage outputs and sits at the middle of that block, one stage_pitch below the stage before it. Each element is turned so its input faces up and its outputs face down. The tree is symmetric about the vertical line x = 0, and its n outputs end on one row, 40 μm apart.
The fiber array is one row of n + 1 grating couplers array_drop below the tree, each turned so its port faces north. The n output channels are centered on the tree axis, which is what makes the fan between them mirror symmetric; the input channel is one pitch to the left of them. The drop of 800 μm leaves room for the meanders of the matched fan; with less, the matching report returns handled: False. The stage pitch of 320 μm is larger than the element, 236 μm tall, so the hops
between stages have room to turn.
place_selector returns the chip with every device placed, the fiber ports, the outputs of the tree, and the two port lists of the tree interconnect.
[5]:
stage_pitch = 320.0 # Vertical distance between two stages of the tree (μm)
array_drop = 800.0 # Distance from the tree to the fiber array (μm)
element_rotation = -90.0 # Rotation that turns an element to face downward (deg)
grating_rotation = 90.0 # Rotation that turns a grating coupler to face north (deg)
place_selector builds the chip. It places the elements and the grating couplers, and lists the ports that the first routing call joins. The two rotations in the cell above turn each element to face downward and each grating coupler to face north.
[6]:
# Place the elements and the fiber array of a selector with n outputs
def place_selector(n_outputs):
# Count the stages of the tree, for example 3 stages for 8 outputs
stages = n_outputs.bit_length() - 1
# Create the parent component of the selector
chip = pf.Component(f"selector_{n_outputs}")
# Create the switching element
element = switch_element()
# Find the center of the block of outputs an element steers (μm)
def block_center(stage, index):
# Count the outputs in one block of this stage
width = n_outputs >> stage
# Return the center of the block, measured from the tree axis
return (index * width + (width - 1) / 2 - (n_outputs - 1) / 2) * output_pitch
# Create the dictionary that holds the elements, indexed by stage and position
elements = {}
# Place every element at the center of its block, stage by stage
for stage in range(stages):
# Go through the elements of this stage
for index in range(2**stage):
# Find the position of the element (μm)
position = (block_center(stage, index), -stage * stage_pitch)
# Create the element, turned to face downward
reference = pf.Reference(
element, origin=position, rotation=element_rotation
)
# Add the element to the chip
elements[(stage, index)] = chip.add_reference(reference)
# Read the lowest edge of the tree
(_, y_min), _ = chip.bounds()
# Find the row of the fiber array, a drop below the tree and on the layout grid
row_y = pf.snap_to_grid(y_min - array_drop)
# Create the list that holds the grating couplers
gc = []
# Place the grating couplers: the input channel first, then one per output
for channel in range(n_outputs + 1):
# Find the position of the grating coupler (μm)
position = ((channel - (n_outputs + 1) / 2) * fiber_pitch, row_y)
# Create the grating coupler, turned so its port faces north
reference = pf.Reference(grating, origin=position, rotation=grating_rotation)
# Add the grating coupler to the chip
gc.append(chip.add_reference(reference))
# Create the two port lists of the tree interconnect
port1, port2 = [], []
# Join each element to its two children, stage by stage
for stage in range(stages - 1):
# Go through the elements of this stage
for index in range(2**stage):
# Join the element to its left child first, then to its right child
for name, child in (("P1", 2 * index), ("P2", 2 * index + 1)):
# Take the output port of the parent element
port1.append((elements[(stage, index)], name))
# Take the input port of the child element
port2.append((elements[(stage + 1, child)], "P0"))
# List the outputs of the tree from left to right
leaves = [
(elements[(stages - 1, index)], name)
for index in range(2 ** (stages - 1))
for name in ("P1", "P2")
]
return chip, elements, gc, leaves, (port1, port2)
Routing the selector¶
build_selector places a selector and routes it in three route_auto calls:
The tree interconnect joins each element output to the input of one child element.
The fan joins every output of the tree to its fiber. Only its left half is routed, from the fibers to the outputs, and the right half is the same bundle reflected about
x = 0, so the finished layout is exactly symmetric. A keep-out rectangle over the right half stops the left half from crossing the axis. The argumentmatchdecides whether the call asks forPathLengthMatching(), which matches all nets to the longest one.The input joins the input fiber to the root element, around the left side of the tree. It is routed last, so the first two calls are obstacles for it.
The obstacles argument lists what a route must avoid. The route_kwargs shared by the three calls allow only 90 degree bends, no S bends, and no crossings, and block the metal and silicon layers. A routing grid of 10 μm keeps the search fast, and a gap of 5 μm keeps room between neighboring routes for the meanders. The keep-out rectangle only has to extend past the die. timed measures how long each call takes.
[7]:
route_grid = 10.0 # Pitch of the routing search grid (μm)
route_gap = 5.0 # Minimum gap between neighboring waveguides (μm)
keepout_margin = 100.0 # Distance the keep-out rectangle extends past the die (μm)
blockers = ["Si", "M1_heater", "M2_router"] # Layers that block a route
# Collect the arguments shared by every route_auto call below
route_kwargs = dict(
radius=bend_radius, # Bend radius of every route (μm)
grid_size=route_grid, # Pitch of the search grid (μm)
collision_offset=route_gap, # Gap kept between neighboring waveguides (μm)
collision_layers=blockers, # Layers that block a route
max_crossings=0, # Maximum number of waveguide crossings per bundle
allow_s_bend=False, # Whether the router may use S bends
allow_diagonals=False, # Whether the router may use 45 degree bends
allow_partial=True, # Whether to report failed nets instead of raising
diagnostics=True, # Whether to attach the routing report to the result
show_progress=False, # Whether to show a progress bar
)
The functions below do the routing. timed measures a call, route_fan routes the fan, build_selector places and routes a whole selector, and count_bends and summarize measure it afterwards. The fan appears in the layout as its left half and the mirror image of that half, so summarize counts its bends fan_copies times, and mirror_rotation completes the reflection.
[8]:
fan_copies = 2 # Number of times the routed half fan appears in the layout
mirror_rotation = 180.0 # Rotation that completes the mirror image of the fan (deg)
# Call a function and measure how long it takes
def timed(function, *args, **kwargs):
# Note the time before the call
start = time.perf_counter()
# Call the function
result = function(*args, **kwargs)
# Return the result together with the time the call took (s)
return result, time.perf_counter() - start
# Route the left half of the fan and create its mirror image
def route_fan(chip, leaves, gc, obstacles, match):
# Count the outputs in the left half of the fan
half = len(leaves) // 2
# Read the bounds of the chip to size the keep-out rectangle
(_, y_min), (x_max, y_max) = chip.bounds()
# Cover the right half of the die so the left half never reaches its mirror image
right_half = pf.Rectangle(
(0, y_min - keepout_margin), (x_max + keepout_margin, y_max + keepout_margin)
)
# Route the left half of the fan, from the fibers to the outputs of the tree
fan = pf.parametric.route_auto(
port1=[(ref, "P0") for ref in gc[1 : half + 1]], # Fiber ports of the fan
port2=leaves[:half], # Outputs of the tree on the left half
obstacles=obstacles + [right_half], # Devices and the keep-out rectangle
net_reorder=True, # Whether the router may reorder the nets
# Match all nets to the longest one when requested
matching=[pf.PathLengthMatching()] if match else None,
**route_kwargs,
)
# Mirror the left half: a reflection about the x axis plus a half turn is a
# reflection about x = 0
mirrored = pf.Reference(fan, rotation=mirror_rotation, x_reflection=True)
return fan, mirrored
# Place a selector and route its tree, its fan, and its input
def build_selector(n_outputs, match):
# Place the elements and the fiber array
chip, elements, gc, leaves, (port1, port2) = place_selector(n_outputs)
# Collect every placed device as an obstacle for the routes
devices = list(elements.values()) + gc
# Route the tree interconnect, and time the call
tree, tree_time = timed(
pf.parametric.route_auto,
port1=port1, # Output port of the parent element of every hop
port2=port2, # Input port of the child element of every hop
obstacles=devices, # Devices the routes must avoid
**route_kwargs,
)
# Route both halves of the fan, and time the call
(fan, mirrored_fan), fan_time = timed(
route_fan, chip, leaves, gc, [tree] + devices, match
)
# Route the input fiber to the root element, around the left side of the tree
route_in, input_time = timed(
pf.parametric.route_auto,
port1=[(gc[0], "P0")], # Port of the input fiber
port2=[(elements[(0, 0)], "P0")], # Input port of the root element
obstacles=[tree, fan, mirrored_fan] + devices, # Everything routed so far
**route_kwargs,
)
# Add every routed bundle to the chip, including the mirror image of the fan
for bundle in (tree, fan, mirrored_fan, route_in):
# Add the bundle to the chip
chip.add_reference(bundle)
return chip, devices, (tree, fan, route_in), tree_time + fan_time + input_time
# Count the 90 degree bends of a routed bundle
def count_bends(bundle):
# Read the routing report attached to the bundle
data = json.loads(bundle.properties["route_auto"])
# Add up the 90 degree bends of every net
return sum(net["num_bends_90"] for net in data["routes"])
# Print the routing time, the bends, and the channel lengths of a layout
def summarize(label, bundles, seconds):
# Unpack the three routed bundles
tree, fan, route_in = bundles
# Count the 90 degree bends of the whole layout, with the fan counted twice
bends = count_bends(tree) + fan_copies * count_bends(fan) + count_bends(route_in)
# Measure the length of every channel of the left half of the fan (μm)
lengths = [pf.route_length(route.component) for route in fan.references]
# Print the routing time, the bends, and the range of channel lengths
print(
f"{label}: routed in {seconds:.2f} s, {bends} bends of 90 degrees, "
f"channels from {min(lengths):.1f} to {max(lengths):.1f} μm"
)
Auto routing¶
The first case is the 1 by 16 selector routed automatically by route_auto. The outputs of the tree are 40 μm apart and the fibers 127 μm, so the outer outputs have to travel much farther sideways than the inner ones, and the router has no reason to make up the difference.
[9]:
n_outputs = 16 # Number of outputs of the selector shown in this example
# Keep every case of the comparison
designs = {}
# Build the 1 by 16 selector with automatic routing
chip, devices, bundles, seconds = build_selector(n_outputs, match=False)
# Keep the layout for the checks below
designs["auto routing"] = (chip, devices, bundles)
# Print the summary of the layout
summarize("auto routing", bundles, seconds)
# Show the layout in the viewer
viewer(chip)
auto routing: routed in 42.04 s, 62 bends of 90 degrees, channels from 833.8 to 1443.5 μm
[9]:
The fan is a clean staircase: each of its routes has two corners, the least a 90 degree route can have, and the whole layout has 62 corners of 90 degrees. But the channels are not equal. The shortest is 833.8 μm and the longest 1443.5 μm, a spread of 610 μm, so every output of the selector would see a different length of waveguide, and with it a different loss and phase. The routing took about six seconds on the machine that ran this notebook.
With matching¶
The second case is the same selector, with every element in the same place and the fan asking for PathLengthMatching(). The router lengthens the short channels with meanders, small detours that add length without moving the end points, until all of them match the longest one. Compare the fan with the one from the auto routing case.
[10]:
# Build the same selector with matching
chip, devices, bundles, seconds = build_selector(n_outputs, match=True)
# Keep the layout for the checks below
designs["with matching"] = (chip, devices, bundles)
# Print the summary of the layout
summarize("with matching", bundles, seconds)
# Read the matching report stored in the properties of the fan
matched = json.loads(bundles[1].properties["path_length_matching"])[0]
# Print whether the group was matched, and the bends added to each net
print(f"handled: {matched['handled']}, bends added per net: {matched['added_bends']}")
# Show the layout in the viewer
viewer(chip)
with matching: routed in 22.18 s, 118 bends of 90 degrees, channels from 1443.5 to 1443.5 μm
handled: True, bends added per net: [0, 4, 4, 4, 4, 4, 4, 4]
[10]:
The fan now has meanders: in each half, seven of the eight channels carry a detour, and the router added four bends to each of them. The longest channel is left alone and sets the target, so every channel is 1443.5 μm. Matching never shortens a route, it only lengthens the short ones. The elements, the tree, and the fiber array are exactly where they were, so the difference is entirely in the fan. The price is 118 corners instead of 62, and the extra waveguide of the meanders. The routing time is about the same.
Checking the layouts¶
routing_collisions tests a routed bundle against the placed devices on the layers that were declared as blockers. Each route touches the port it connects to, and those junctions come back as slivers of zero area, so only polygons above 1e-3 μm² count as real overlaps.
disconnected_reference_ports then lists the ports that no route reached. Here they are the fiber ports of the grating couplers, which are meant to stay open.
[11]:
sliver_area = 1e-3 # Smallest polygon that counts as a real overlap (μm²)
# Check every layout of the comparison
for label, (chip, devices, bundles) in designs.items():
# Start counting the real overlaps of this layout
overlaps = 0
# Check the three bundles against the devices
for bundle in bundles:
# Find the overlaps of the bundle with the devices
found, _ = pf.routing_collisions(
bundle, obstacles=devices, collision_layers=blockers
)
# Add the polygons large enough to be real overlaps and not port slivers
overlaps += len([polygon for polygon in found if polygon.area() > sliver_area])
# List the ports that no route reached
open_ports = chip.disconnected_reference_ports()
# Print the overlaps and the number of open ports of the layout
print(f"{label}: {overlaps} obstacle overlaps, {len(open_ports)} open ports")
auto routing: 0 obstacle overlaps, 17 open ports
with matching: 0 obstacle overlaps, 17 open ports
No route touches a device in either layout. Each has seventeen open ports, the fiber ports of the grating couplers: the input and the sixteen outputs. Every other port of the circuit is connected.
Layout export¶
The matched 1 by 16 selector is written to GDSII, with a floor plan rectangle around the die for the DRC boundary check. The routed bundles carry a circuit model, so the same component can be simulated later without changing the layout.
[12]:
floor_plan_margin = 20.0 # Distance from the die to the floor plan rectangle (μm)
# Take the chip of the matched selector
chip = designs["with matching"][0]
# Read the bounding box of the die
(x_min, y_min), (x_max, y_max) = chip.bounds()
# Create the floor plan rectangle that the DRC boundary check needs
floor_plan = pf.Rectangle(
(x_min - floor_plan_margin, y_min - floor_plan_margin),
(x_max + floor_plan_margin, y_max + floor_plan_margin),
)
# Add the floor plan rectangle to the chip
chip.add("Floor Plan-Not Fabricated", floor_plan)
# Write the layout to a GDSII file
pf.write_layout("selector_switch.gds", chip)
The history saving thread hit an unexpected error (OperationalError('database is locked')).History will not be written to the database.
Summary¶
Auto routing gives the fewest corners, 62, but the channels of the fan differ in length by 610 μm.
With matching, the router lengthens every channel to the longest one, 1443.5 μm, by adding meanders. That costs 56 more corners and the length of the detours, and takes about the same routing time.
The two layouts share every element and the fiber array. Only the routing of the fan differs, so the meanders in the second layout are what matching adds.
build_selectorlays out any number of outputs, with or without matching. The 1 by 16 needs a drop of 800 μm to the fiber array to give the meanders room.The checks find no overlap between a route and a device, and the only open ports are the fibers.