Auto Routing¶
route_auto is the automatic router in PhotonForge. It connects many pairs of ports in one call: the routing area is discretized into a search grid, the cells covered by existing polygons are marked as blocked, and each net is given a minimum cost path built from straight sections, 90 degree bends, optional 45 degree bends, and optional waveguide crossings. Nets whose paths conflict are ripped up and rerouted.
Three properties separate it from the other routing functions:
it is obstacle aware, so routes go around devices, bond pads, and keep out regions instead of through them;
it is multi-net, solving all connections against one shared occupancy map;
it optimizes against a cost model, so the trade between length, bends, crossings, and congestion is set by numbers.
The other functions connect exactly two compatible ports, ignore the rest of the layout, and each build one path shape: route a heuristic Manhattan path, optionally guided by waypoints, route_l an L bend between orthogonal ports, route_u and route_z a U and a Z between parallel ports, route_s_bend a single S bend, route_manhattan a Manhattan path between two terminals, and route_taper a taper between cross-sections.
The layout used throughout is an unbalanced 2 by 2 Mach-Zehnder interferometer (MZI) wired to a four element grating coupler array on the 127 um pitch of a standard fiber array. Its components come from the SiEPIC OpenEBL process design kit (PDK) [1], through the siepic_forge module. Ports, the reference connect method, and waypoints are covered in the Routing and Connections guide.
Reference
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¶
We use the SiEPIC OpenEBL technology through the siepic_forge module, and set a few default arguments that every parametric component in this notebook inherits, the routing functions included.
Note that the MZI arms take a bend radius of their own, which we pass to the component in the next section, so the arm and routing radii stay independent.
[1]:
import json
import photonforge as pf
import siepic_forge as siepic
# SiEPIC OpenEBL is the technology for every component in this guide
pf.config.default_technology = siepic.ebeam()
# defaults shared by all parametric calls below, including the routing functions
pf.config.default_kwargs = {
# 500 nm wide silicon strip, single mode transverse electric (TE) at 1550 nm
"port_spec": "TE_1550_500",
"radius": 9.0, # routing bend radius in um, used by every route below
"euler_fraction": 0.5, # partially Euler bends, lower loss than a circular arc
}
pf.config.svg_labels = False # keep the layout views uncluttered
viewer = pf.live_viewer.LiveViewer()
LiveViewer started at http://localhost:51101
The circuit to be wired¶
Let’s build the device that has to be connected. We take two components from the PDK: a 2 by 2 broadband directional coupler, with ports P0 and P1 on the left and P2 and P3 on the right, and a TE grating coupler, with waveguide port P0 and fiber port P1.
The MZI is a custom parametric component that joins two couplers with a straight lower arm and an upper arm detoured through waypoints. Both arms are built with route, the right function for a single pair of ports and a path we choose ourselves. Note that arm_radius is a parameter of the MZI, so the arms keep a bend radius of their own:
[2]:
coupler = siepic.component("ebeam_bdc_te1550") # 2 by 2 broadband directional coupler
grating = siepic.component("ebeam_gc_te1550") # TE grating coupler at 1550 nm
@pf.parametric_component
def mzi(*, coupler, arm_spacing, delta_length, arm_radius):
"""Unbalanced 2 by 2 Mach-Zehnder interferometer built from two 2 by 2 couplers."""
c = pf.Component()
# outer port to port span of the coupler, used to place the second one
coupler_span = coupler["P2"].center[0] - coupler["P0"].center[0]
in_ref = c.add_reference(coupler)
out_ref = c.add_reference(pf.Reference(coupler, origin=(coupler_span + arm_spacing, 0)))
# lower arm: the ports already face each other, so this is a straight section
c.add_reference(pf.parametric.route(
port1=(in_ref, "P2"), port2=(out_ref, "P0"), radius=arm_radius,
))
# upper arm: the waypoints lift the path into a rectangular detour of delta_length,
# turning one bend radius away from each port so the corners fit
detour_y = in_ref["P3"].center[1] + delta_length / 2
c.add_reference(pf.parametric.route(
port1=(in_ref, "P3"), port2=(out_ref, "P1"), radius=arm_radius,
waypoints=[
(in_ref["P3"].center[0] + arm_radius, detour_y),
(out_ref["P1"].center[0] - arm_radius, detour_y),
],
))
# expose the four outer coupler ports as P0 to P3 of the MZI, and make it simulable
c.add_port([in_ref["P0"], in_ref["P1"], out_ref["P2"], out_ref["P3"]])
c.add_model(pf.CircuitModel())
return c
# arm_radius is the only radius set outside pf.config.default_kwargs
mzi_component = mzi(
coupler=coupler, arm_spacing=60.0, delta_length=50.0, arm_radius=5.0
)
viewer(mzi_component)
[2]:
Next, we place the MZI and the grating coupler array on the chip. A quarter turn stands the device on end, so P0 and P1 end up at the top and P2 and P3 at the bottom: two nets will have an almost direct path to their coupler, and two will have to travel past an end of the device and turn back into it.
Note that every coordinate comes from a port position rather than from a bounding box, whose center is the average of two edges and can land off the layout grid. The Grid Snapping guide covers the rounding rules:
[3]:
chip = pf.Component("mzi_io")
# rotations by 90 degrees map the layout grid onto itself, so every port keeps the
# exact coordinates it had before the rotation
mzi_ref = chip.add_reference(pf.Reference(mzi_component, rotation=-90))
gc_pitch = 127.0 # single mode fiber array pitch in um
gc_count = 4
# the waveguide port of the grating coupler sits at its own origin, so placing each
# reference by origin puts that port exactly where we ask for it
mzi_port_x = [mzi_ref[name].center[0] for name in ("P0", "P1", "P2", "P3")]
gc_x = pf.snap_to_grid(min(mzi_port_x) - 220.0) # column clear of the MZI, to its left
gc_y = pf.snap_to_grid( # array centered on the MZI
(mzi_ref["P0"].center[1] + mzi_ref["P2"].center[1]) / 2
)
gc_refs = [
chip.add_reference(pf.Reference(
grating, origin=(gc_x, gc_y + (i - (gc_count - 1) / 2) * gc_pitch)
))
for i in range(gc_count)
]
viewer(chip)
[3]:
Net by net with route: collisions and shorted waveguides¶
Both routing functions take the nets as port1 and port2 and connect them index by index, so port1[i] is wired to port2[i]. Each entry is a terminal: a tuple of a reference and a port name, plus a repetition index for array references. We pass the reference rather than the port itself so that the router knows where the port ended up after placement and what geometry surrounds it.
route connects a single pair of ports and knows nothing about the rest of the layout, so let’s see what we get by calling it once per net:
[4]:
# after rotation=-90, P0 and P1 are at the top of the MZI and P2 and P3 at the bottom.
# Pairing them bottom up against the array keeps the nets from having to cross.
mzi_ports = [(mzi_ref, "P3"), (mzi_ref, "P2"), (mzi_ref, "P0"), (mzi_ref, "P1")]
# P0 is the waveguide port of each grating coupler, P1 is the fiber port
gc_ports = [(ref, "P0") for ref in gc_refs]
direct_routes = [
pf.parametric.route(port1=p1, port2=p2)
for p1, p2 in zip(mzi_ports, gc_ports)
]
# a shallow copy keeps the original chip clean for the next section
with_direct = chip.copy(deep=False)
for route in direct_routes:
with_direct.add_reference(route)
viewer(with_direct)
[4]:
Two of the nets are drawn straight across the MZI arms, and two of them run over each other. Note that no error is raised along the way: route does no collision checking, so shorted waveguides like these only show up in a design rule check or in a simulation that no longer matches the schematic. We can test a finished route against obstacles and its siblings with routing_collisions, but when several nets share a corridor it is easier
to let the automatic router place them.
Automatic routing with route_auto¶
Now we hand the same four nets to route_auto, which solves them together. We can shape the result with several arguments:
grid_sizesets the pitch of the search grid, andgrid_size + collision_offsetis the center to center distance the router aims for between parallel waveguides.collision_offsetsets the clearance stamped around every route section, that is, the minimum edge to edge gap between neighboring waveguides.collision_layersselects the layers that block a route. The default, an empty sequence, uses all layers, and in SiEPIC those include the DevRec and FloorPlan rectangles that cover the whole design, which would leave the router nowhere to go.include_port_references_as_obstaclesadds the references that own the routed ports to the obstacles, so the MZI and the grating couplers block routes without being listed. It is on by default.
The call returns a single component holding one sub-component per net, two ports per net, and a Circuit model on both levels, so the routed chip can be simulated as it is. We measure the result with route_length, which accepts the whole bundle or any single net:
[5]:
# settings reused by every route_auto call in this guide
router_options = dict(
grid_size=4.0, # with collision_offset, the target waveguide pitch in um
collision_offset=0.0, # minimum edge to edge gap between neighboring routes
allow_s_bend=True, # let the router reach an aligned goal with an S bend
max_crossings=0, # no crossings for now
allow_partial=True, # report failed nets instead of raising
diagnostics=True, # attach the JSON routing report to the result
show_progress=False,
)
auto_route = pf.parametric.route_auto(
port1=mzi_ports,
port2=gc_ports,
collision_layers=["Si"], # only silicon blocks, not the DevRec or FloorPlan boxes
**router_options,
)
print(f"total routed length: {pf.route_length(auto_route):.1f} um")
with_auto = chip.copy(deep=False)
with_auto.add_reference(auto_route)
viewer(with_auto)
total routed length: 1204.2 um
[5]:
Routing diagnostics¶
With diagnostics=True the router stores a JSON report in component.properties.route_auto. It holds the net counts, the crossing count, the costs, the timings, and one entry per net with success, a failure_message, the bend and crossing counts that drive the loss budget, and the search statistics.
Note that every time in the report is given in microseconds. route_all_time_us is the total, split into prepare_time_us, initial_route_time_us, reroute_time_us, and cleanup_time_us, and each net also carries its own route_time_us. Let’s read the summary and the per net entries:
[6]:
report = json.loads(auto_route.properties.get("route_auto"))
print(f"{report['successful_nets']} of {report['total_nets']} nets routed, "
f"{report['total_crossings']} crossings, cost {report['total_cost']:.4g}, "
f"{report['route_all_time_us'] / 1000:.1f} ms") # microseconds to milliseconds
for net in report["routes"]:
# 90 degree bends dominate the bend loss budget of a routed net
print(f" net {net['net_key']}: success={net['success']}, "
f"bends90={net['num_bends_90']}, crossings={net['num_crossings']}, "
f"{net['iterations']} iterations, {net['route_time_us'] / 1000:.1f} ms")
4 of 4 nets routed, 0 crossings, cost 0.01138, 40.1 ms
net 0: success=True, bends90=1, crossings=0, 754 iterations, 5.4 ms
net 1: success=True, bends90=3, crossings=0, 1020 iterations, 4.5 ms
net 2: success=True, bends90=3, crossings=0, 915 iterations, 18.4 ms
net 3: success=True, bends90=1, crossings=0, 735 iterations, 4.6 ms
The search grid¶
grid_size is the argument to reach for first when the router feels slow or a path looks clumsy. A fine grid searches many more cells and can thread narrower gaps, while a coarse grid is faster but snaps every path to a coarse pitch. Note that it is better to stay below radius, since a bend cannot fit inside a single cell and PhotonForge warns when it does not, so we stop the sweep at 8 um:
[7]:
for grid_size in (1.0, 2.0, 4.0, 8.0):
routed = pf.parametric.route_auto(
port1=mzi_ports, port2=gc_ports, collision_layers=["Si"],
**{**router_options, "grid_size": grid_size},
)
r = json.loads(routed.properties.get("route_auto"))
print(f"grid_size {grid_size:4.1f} um: "
f"{sum(net['num_bends_90'] for net in r['routes']):2d} bends, "
f"{pf.route_length(routed):7.1f} um, "
f"{sum(net['iterations'] for net in r['routes']):6d} search iterations, "
f"{r['route_all_time_us'] / 1000:6.1f} ms")
grid_size 1.0 um: 8 bends, 1170.2 um, 55544 search iterations, 448.5 ms
grid_size 2.0 um: 8 bends, 1176.2 um, 13912 search iterations, 123.9 ms
grid_size 4.0 um: 8 bends, 1204.2 um, 3424 search iterations, 40.1 ms
grid_size 8.0 um: 8 bends, 1260.2 um, 974 search iterations, 30.0 ms
Obstacles¶
Besides the references that own the routed ports, we can block regions of the layout explicitly through the obstacles argument, which accepts 2D structures, components, references, or sequences of those. Here we add a bond pad on the routing metal, just above the device and in the channel that the two upper nets share on their way past it.
Note that the pad only blocks a route if its layer is listed in collision_layers, and that we also add it to the layout, not only to the router, otherwise the routes would avoid something that is not in the exported file:
[8]:
# a 60 um square bond pad on the routing metal, 40 um to the left of the device and
# just above its top ports, where the two upper nets run
pad_x = pf.snap_to_grid(min(mzi_port_x) - 40.0)
pad_y = pf.snap_to_grid(mzi_ref["P0"].center[1] + 45.0)
pad = pf.Component("bond_pad")
pad.add("M2_router", pf.Rectangle(center=(pad_x, pad_y), size=(60.0, 60.0)))
auto_pad = pf.parametric.route_auto(
port1=mzi_ports,
port2=gc_ports,
obstacles=[pad], # extra blockers, beyond the port references
collision_layers=["Si", "M2_router"], # the metal now blocks routes as well
**router_options,
)
print(f"without the pad: {pf.route_length(auto_route):.1f} um")
print(f"around the pad : {pf.route_length(auto_pad):.1f} um")
with_pad = chip.copy(deep=False)
with_pad.add_reference(pad)
with_pad.add_reference(auto_pad)
viewer(with_pad)
without the pad: 1204.2 um
around the pad : 1332.2 um
[8]:
Diagonal sections¶
By default the router places only 90 degree bends, so every path is Manhattan. Providing a 45 degree bend through bend45 enables allow_diagonals and lets the search move diagonally, which shortens the paths across a fanned out array.
Note that the grid has to be coarse enough for a diagonal step to hold that bend, so we raise grid_size here: on the 4 um grid used above, a 9 um bend does not fit and the diagonals degenerate into a staircase of small sections. Building the bend also warns about grid alignment, since angles that are not multiples of 90 degrees can leave ports off grid:
[9]:
bend45 = pf.parametric.bend(angle=45) # radius and euler_fraction come from the defaults
auto_diagonal = pf.parametric.route_auto(
port1=mzi_ports, port2=gc_ports, collision_layers=["Si"],
bend45=bend45, # supplying a 45 degree bend switches allow_diagonals on
allow_diagonals=True,
# a diagonal step has to hold the 45 degree bend, so the grid is coarser here
**{**router_options, "grid_size": 8.0},
)
r = json.loads(auto_diagonal.properties.get("route_auto"))
print(f"Manhattan only: {pf.route_length(auto_route):7.1f} um, "
f"{sum(net['num_bends_90'] for net in report['routes'])} bends of 90 degrees")
print(f"with diagonals: {pf.route_length(auto_diagonal):7.1f} um, "
f"{sum(net['num_bends_90'] for net in r['routes'])} bends of 90 degrees, "
f"{sum(net['num_bends_45'] for net in r['routes'])} of 45 degrees")
with_diagonal = chip.copy(deep=False)
with_diagonal.add_reference(auto_diagonal)
viewer(with_diagonal)
C:\Users\AminKhavasi\photonics_project\env\Lib\site-packages\photonforge\parametric_utils.py:112: RuntimeWarning: Using bends with angles not multiples of 90° might lead to disconnected waveguides. Consider building a continuous path with grid-aligned ports instead of connecting sections with non grid-aligned ports.
return parametric_fn(*args, **kwargs)
Manhattan only: 1204.2 um, 8 bends of 90 degrees
with diagonals: 1151.8 um, 2 bends of 90 degrees, 8 of 45 degrees
[9]:
Waveguide crossings and the cost model¶
The pairing we have used so far is planar, so no crossing is ever needed. Let’s feed the two upper couplers from the two MZI ports at the bottom, and the two lower couplers from the two at the top, which forces every net past two others.
Crossings are inserted only when we supply a crossing component through cross, and we use the PDK crossing so that the inserted geometry is a characterized foundry device. Note that its ports have to be compatible with the routed port spec, and that each crossing is emitted once, as its own reference, with the two nets that meet there split into segments connected to its ports:
[10]:
# bottom MZI ports to the upper couplers, top MZI ports to the lower ones
crossing_ports = [gc_ports[2], gc_ports[3], gc_ports[0], gc_ports[1]]
crossing = siepic.component("ebeam_crossing4")
auto_crossings = pf.parametric.route_auto(
port1=mzi_ports, port2=crossing_ports, collision_layers=["Si"],
cross=crossing,
**{**router_options, "max_crossings": -1}, # negative removes the per net cap
)
r = json.loads(auto_crossings.properties.get("route_auto"))
print(f"{r['total_crossings']} crossings, {pf.route_length(auto_crossings):.1f} um, "
f"{sum(net['num_bends_90'] for net in r['routes'])} bends")
with_crossings = chip.copy(deep=False)
with_crossings.add_reference(auto_crossings)
viewer(with_crossings)
4 crossings, 1893.4 um, 12 bends
[10]:
Whether those crossings are worth having is a cost decision. Every candidate path is scored by a weighted sum of its length, its bends, its crossings, and the local congestion, and the router keeps the cheapest path it finds, so making a crossing more expensive pushes it towards going around instead. cross_cost sets that price, and its default is twice the propagation cost of the crossing length.
Let’s compute that default and raise it in multiples until the crossings disappear:
[11]:
# default cross_cost: twice the default propagation_cost of 1e-5 per um, over the
# port to port length of the crossing itself
crossing_length = crossing["P1"].center[0] - crossing["P0"].center[0]
default_cross_cost = 2 * 1e-5 * crossing_length
print(f"crossing length {crossing_length:.1f} um, "
f"default cross_cost {default_cross_cost:.2e}\n")
crossing_variants = {}
for factor in (1, 4, 7):
routed = pf.parametric.route_auto(
port1=mzi_ports, port2=crossing_ports, collision_layers=["Si"],
cross=crossing, cross_cost=factor * default_cross_cost,
**{**router_options, "max_crossings": -1},
)
r = json.loads(routed.properties.get("route_auto"))
crossing_variants[factor] = routed
print(f"cross_cost {factor} x default: {r['total_crossings']} crossings, "
f"{pf.route_length(routed):7.1f} um, "
f"{sum(net['num_bends_90'] for net in r['routes']):3d} bends")
# at seven times the default price, the nets are routed around each other instead
without_crossings = chip.copy(deep=False)
without_crossings.add_reference(crossing_variants[7])
viewer(without_crossings)
crossing length 9.6 um, default cross_cost 1.92e-04
cross_cost 1 x default: 4 crossings, 1893.4 um, 12 bends
cross_cost 4 x default: 4 crossings, 1893.4 um, 12 bends
cross_cost 7 x default: 0 crossings, 2446.4 um, 14 bends
[11]:
The crossings survive several times their default price and disappear at about seven times it, where the same four nets are routed around each other instead, at a cost of some 550 um of waveguide and two extra bends.
Note that max_crossings=0, or leaving cross out as the rest of this notebook does, forbids crossings directly. Note also that raising the price much further is counterproductive: beyond roughly ten times the default the search starts returning worse layouts, with crossings reappearing.
Parameter reference¶
The table below lists every argument of route_auto. Arguments left at None fall back to the defaults shown here, and port_spec and radius are inherited from pf.config.default_kwargs when they are not given.
The last column marks the arguments we exercise in this notebook. The others keep their defaults throughout and become useful on larger, more congested problems, where the cost weights balance length against bends, the conflict recovery settings decide whether a dense bundle closes at all, and the section and model arguments replace what the router attaches.
Parameter |
Default |
What it controls |
Used here |
|---|---|---|---|
|
required |
the terminals to connect, paired index by index |
yes |
|
from the defaults |
bend and S bend radius |
yes |
|
routed port spec width |
search grid pitch; with |
yes |
|
|
clearance stamped around every route section |
yes |
|
all layers |
layers that block a route and are collected from obstacles |
yes |
|
none |
extra blockers: 2D structures, components, or references |
yes |
|
|
also block on the references that own the routed ports |
no |
|
built from |
component placed at every 90 degree bend |
no |
|
none |
component placed at every 45 degree bend |
yes |
|
|
let the search move diagonally |
yes |
|
|
close a net with an S bend when the goal is offset but aligned |
yes |
|
|
drop the access S bends that turn out to be unnecessary |
no |
|
none |
crossing component; without it no crossing is ever inserted |
yes |
|
|
crossings allowed per net; |
yes |
|
|
no |
|
|
|
price of path length, the unit the other costs are quoted against |
no |
|
|
price of a 90 degree bend, a 5% penalty on the Manhattan distance |
no |
|
|
price of a 45 degree bend |
no |
|
twice the propagation cost of the crossing length |
price of a crossing |
yes |
|
|
price of placing crossings close together |
no |
|
|
price of routed occupancy near a candidate cell |
no |
|
|
how far the congestion term looks |
no |
|
|
let the router choose the order in which nets are routed |
no |
|
number of nets, clipped to [3; 16] |
rip up and reroute rounds after the first pass |
no |
|
|
how strongly a cell that caused a conflict is penalized next round |
no |
|
|
search iterations per net |
no |
|
automatic |
extra area around the routing bounds the search may use |
no |
|
|
return the nets that succeeded instead of raising |
yes |
|
|
store the JSON report in |
yes |
|
|
progress bar, worth keeping on for long routes |
yes |
|
|
model attached to each net sub-component |
no |
|
|
model attached to the returned component |
no |
|
defaults |
technology and name of the returned component |
no |