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
Section titled “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.
import jsonimport time
import photonforge as pfimport siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every component in this examplepf.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 belowpf.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 unclutteredpf.config.svg_labels = False
# Start the live viewer that displays the layoutsviewer = pf.live_viewer.LiveViewer()LiveViewer started at http://127.0.0.1:38781. Call its 'stop()' method to free the port.The switching element
Section titled “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.
# Load the Y branch, which splits the light at the input of each elementy_branch = siepic.component("ebeam_y_1550")
# Load the 2 by 2 broadband directional coupler, which recombines the armscoupler = siepic.component("ebeam_bdc_te1550")
# Load the TE grating coupler used for the fiber arraygrating = 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.
# Define the switching element as a parametric component@pf.parametric_componentdef 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 elementThe cell below creates one element with the default dimensions and shows it in the viewer.
# Create one switching element with the default dimensionselement = switch_element()
# Show the element in the viewerviewer(element)Placing the tree and the fiber array
Section titled “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.
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.
# Place the elements and the fiber array of a selector with n outputsdef 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
Section titled “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.
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 belowroute_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.
fan_copies = 2 # Number of times the routed half fan appears in the layoutmirror_rotation = 180.0 # Rotation that completes the mirror image of the fan (deg)
# Call a function and measure how long it takesdef 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 imagedef 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 inputdef 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 bundledef 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 layoutdef 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
Section titled “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.
n_outputs = 16 # Number of outputs of the selector shown in this example
# Keep every case of the comparisondesigns = {}
# Build the 1 by 16 selector with automatic routingchip, devices, bundles, seconds = build_selector(n_outputs, match=False)
# Keep the layout for the checks belowdesigns["auto routing"] = (chip, devices, bundles)
# Print the summary of the layoutsummarize("auto routing", bundles, seconds)
# Show the layout in the viewerviewer(chip)auto routing: routed in 39.62 s, 62 bends of 90 degrees, channels from 833.8 to 1443.5 μmThe 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
Section titled “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.
# Build the same selector with matchingchip, devices, bundles, seconds = build_selector(n_outputs, match=True)
# Keep the layout for the checks belowdesigns["with matching"] = (chip, devices, bundles)
# Print the summary of the layoutsummarize("with matching", bundles, seconds)
# Read the matching report stored in the properties of the fanmatched = json.loads(bundles[1].properties["path_length_matching"])[0]
# Print whether the group was matched, and the bends added to each netprint(f"handled: {matched['handled']}, bends added per net: {matched['added_bends']}")
# Show the layout in the viewerviewer(chip)with matching: routed in 52.06 s, 118 bends of 90 degrees, channels from 1443.5 to 1443.5 μmhandled: True, bends added per net: [0, 4, 4, 4, 4, 4, 4, 4]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
Section titled “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.
sliver_area = 1e-3 # Smallest polygon that counts as a real overlap (μm²)
# Check every layout of the comparisonfor 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 portswith matching: 0 obstacle overlaps, 17 open portsNo 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
Section titled “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.
floor_plan_margin = 20.0 # Distance from the die to the floor plan rectangle (μm)
# Take the chip of the matched selectorchip = 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 needsfloor_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 chipchip.add("Floor Plan-Not Fabricated", floor_plan)
# Write the layout to a GDSII filepf.write_layout("selector_switch.gds", chip)Summary
Section titled “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.