Auto Routing

cd32793ee5c344008072c4827ae2c29e

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

  1. 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]:
../_images/guides_Auto_Routing_4_0.svg

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

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

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_size sets the pitch of the search grid, and grid_size + collision_offset is the center to center distance the router aims for between parallel waveguides.

  • collision_offset sets the clearance stamped around every route section, that is, the minimum edge to edge gap between neighboring waveguides.

  • collision_layers selects 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_obstacles adds 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]:
../_images/guides_Auto_Routing_11_1.svg

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

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

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

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

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

port1, port2

required

the terminals to connect, paired index by index

yes

radius

from the defaults

bend and S bend radius

yes

grid_size

routed port spec width

search grid pitch; with collision_offset, the waveguide pitch aimed for

yes

collision_offset

0

clearance stamped around every route section

yes

collision_layers

all layers

layers that block a route and are collected from obstacles

yes

obstacles

none

extra blockers: 2D structures, components, or references

yes

include_port_references_as_obstacles

True

also block on the references that own the routed ports

no

bend90

built from port_spec and radius

component placed at every 90 degree bend

no

bend45

none

component placed at every 45 degree bend

yes

allow_diagonals

True when bend45 is given

let the search move diagonally

yes

allow_s_bend

False

close a net with an S bend when the goal is offset but aligned

yes

collapse_s_bends

True

drop the access S bends that turn out to be unnecessary

no

cross

none

crossing component; without it no crossing is ever inserted

yes

max_crossings

-1

crossings allowed per net; 0 forbids, negative removes the cap

yes

straight_kwargs, s_bend_kwargs

{}

forwarded to straight and s_bend

no

propagation_cost

1e-5 per um

price of path length, the unit the other costs are quoted against

no

bend90_cost

2.1 * radius * propagation_cost

price of a 90 degree bend, a 5% penalty on the Manhattan distance

no

bend45_cost

0.51 * bend90_cost

price of a 45 degree bend

no

cross_cost

twice the propagation cost of the crossing length

price of a crossing

yes

cross_space_cost

propagation_cost

price of placing crossings close together

no

congestion_cost

propagation_cost

price of routed occupancy near a candidate cell

no

congestion_radius

1 cell

how far the congestion term looks

no

net_reorder

True

let the router choose the order in which nets are routed

no

max_reroute_rounds

number of nets, clipped to [3; 16]

rip up and reroute rounds after the first pass

no

reroute_history_cost

5

how strongly a cell that caused a conflict is penalized next round

no

max_iterations

500000

search iterations per net

no

search_margin

automatic

extra area around the routing bounds the search may use

no

allow_partial

False

return the nets that succeeded instead of raising

yes

diagnostics

False

store the JSON report in component.properties.route_auto

yes

show_progress

True

progress bar, worth keeping on for long routes

yes

route_model

CircuitModel

model attached to each net sub-component

no

bundle_model

CircuitModel

model attached to the returned component

no

technology, name

defaults

technology and name of the returned component

no