Skip to content

Automated Routing of a Ring Filter Bank

Download notebook (.zip)

This example lays out an 8 channel ring filter bank, a wavelength division multiplexing (WDM) demultiplexer [1], and connects it to a fiber array with a single call to route_auto. The Auto Routing guide covers the arguments of the router; here they are used on a complete chip.

Eight add-drop rings sit in a row on one straight bus at a 60 um pitch. The fiber array is centered below them on the standard 127 um pitch, so the eight drop waveguides have to fan out by more than a factor of two. Because the channel order along the array follows the ring order along the bus, the fan-out nests without a single crossing, and every turn on the chip is a 90 degree bend.

After the main route, each section below changes one thing on the same bank to show one more feature of the router:

  • reading the routing report that diagnostics=True attaches to the result,
  • S bends and 45 degree bends,
  • obstacles and the collision_layers that decide what blocks a route,
  • waveguide crossings,
  • what happens when a net cannot be routed, and how to find it,
  • path length matching: equal lengths, one target per net, a mirror-symmetric layout, and the three bends settings,
  • scaling the same call to more channels.

A table at the end lists the remaining arguments of route_auto.

Only the layout is built here. Every number printed below is measured from the geometry.

References

  1. De Heyn, Peter, et al. “Fabrication-tolerant four-channel wavelength-division-multiplexing filter based on collectively tuned Si microrings.” Journal of Lightwave Technology 2013 31 (16), 2785-2792, doi: 10.1109/JLT.2013.2273391.
  2. 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.

The SiEPIC OpenEBL technology [2] is loaded through the siepic_forge module. Every parametric call in this notebook inherits the default arguments below, the routing call included.

import json
import photonforge as pf
import siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every component below
pf.config.default_technology = siepic.ebeam()
# Set the defaults inherited by every parametric call, the routing call included
pf.config.default_kwargs = {
"port_spec": "TE_1550_500", # Port specification: 500 nm Si strip, TE at 1550 nm
"radius": 9.0, # Bend radius (μm)
"euler_fraction": 0.5, # Fraction of each bend drawn as an Euler curve
}
# Leave the labels out of the SVG previews of components
pf.config.svg_labels = False
# Open the live viewer that displays each layout below
viewer = pf.live_viewer.LiveViewer()
LiveViewer started at http://127.0.0.1:39305. Call its 'stop()' method to free the port.

Each channel is an add-drop ring: a ring between two parallel bus waveguides. Light at the resonance of the ring couples from the input bus into the ring and out through the second bus, so the channel leaves through the drop port while every other wavelength continues to the through port.

The component is two ring_coupler halves, the second rotated by 180 degrees so that the two half rings close into a full ring. Note that coupling_distance is the distance between the two waveguide centers, not the gap between their edges: 0.7 um is a 200 nm gap for a 500 nm wide waveguide.

The drop bus ends in a 90 degree bend, with the same radius as the routes, so that the drop port points away from the ring. In the bank below, that makes every drop port face the fiber array.

# Load the TE grating coupler at 1550 nm from the SiEPIC PDK
grating = siepic.component("ebeam_gc_te1550")
# Load the terminator that closes the unused add ports
terminator = siepic.component("ebeam_terminator_te1550")
# Define the add-drop ring as a parametric component
@pf.parametric_component
def ring_filter(*, radius=10.0, coupling_distance=0.7, bus_length=6.0):
# Create the parent component of the ring
c = pf.Component()
# Create one half of the ring together with its straight bus
half = pf.parametric.ring_coupler(
radius=radius, # Ring radius (μm)
coupling_distance=coupling_distance, # Bus to ring center distance (μm)
coupling_length=0.0, # Length of the straight coupling section (μm)
bus_length=bus_length, # Length of the bus on each side of the ring (μm)
)
# Add the bottom half, which carries the input bus
bottom = c.add_reference(half)
# Add the top half, which carries the drop bus
top = c.add_reference(half)
# Rotate the top half so that the two halves close into a full ring
top.rotate(180)
# End the drop bus in a 90 degree bend that points away from the ring
turn = c.add_reference(pf.parametric.bend()).connect("P1", top["P2"])
# Expose the ports of the input bus, below the ring
c.add_port(bottom["P0"], "in") # Input port of the bus
c.add_port(bottom["P2"], "through") # Port for the wavelengths the ring passes
# Expose the ports of the drop bus, above the ring
c.add_port(turn["P0"], "drop") # Port for the wavelength the ring drops
c.add_port(top["P0"], "add") # Add port, closed by a terminator in the bank
# Simulate the ring as a circuit of its two halves and the bend
c.add_model(pf.CircuitModel())
return c
# Create the ring with its default parameters
ring = ring_filter()
# Show the ring in the live viewer
viewer(ring)
Layout
Schematic

The rings are placed in a row along x, 60 um apart, and every ring reference is rotated by 180 degrees. That puts the input bus on top, where the through port of one ring faces the in port of the next, so the bus is one straight line across the bank; and it puts the drop bus underneath, where its bend turns every drop port toward the fiber array below. Light travels along the bus from east to west: ring 0 is the easternmost.

The ten grating couplers form one row centered under the eight drop ports. The two outer channels carry the bus, channel 0 in at the east end and channel 9 out at the west end, and channels 1 to 8 take the drops in ring order. The drop fan is therefore a mirror image of itself about the center of the row. Each drop port sits at the east end of its ring, so the row is 15 um east of the bank’s center, and the two bus ends differ in length by 30 um.

The unused add port of every ring is closed with a terminator from the SiEPIC OpenEBL process design kit (PDK) [2].

channels = 8 # Number of rings in the bank
ring_pitch = 60.0 # Ring to ring distance along the bus (μm)
fiber_pitch = 127.0 # Grating coupler pitch of the fiber array (μm)
row_y = -300.0 # Vertical position of the fiber row, with the bus at y = 0 (μm)
# Place the rings, terminators, and grating couplers, and list the nets between them
def place_bank(n_channels, row_y=row_y):
# Create the parent component of the bank
bank = pf.Component(f"ring_filter_bank_{n_channels}")
# Place the rings in a row centered on x = 0, ring 0 at the east end
rings = [
bank.add_reference(
pf.Reference(
ring,
origin=((0.5 * (n_channels - 1) - i) * ring_pitch, 0),
rotation=180,
)
)
for i in range(n_channels)
]
# Close the add port of every ring with a terminator
for ref in rings:
bank.add_reference(terminator).connect("P0", ref["add"])
# Find the x position of every drop port
drop_x = [ref["drop"].center[0] for ref in rings]
# Center the fiber row on the drop ports
row_center = (min(drop_x) + max(drop_x)) / 2
# Place the grating couplers in one row facing the rings, channel 0 at the east end
fibers = [
bank.add_reference(
pf.Reference(
grating,
origin=(row_center + (0.5 * (n_channels + 1) - c) * fiber_pitch, row_y),
rotation=90,
)
)
for c in range(n_channels + 2)
]
# Add the bus input net, from the first fiber to ring 0
port1 = [(fibers[0], "P0")]
port2 = [(rings[0], "in")]
# Add the hops along the bus, from each through port to the input of the next ring
for i in range(n_channels - 1):
port1.append((rings[i], "through"))
port2.append((rings[i + 1], "in"))
# Add the bus output net, from the last ring to the last fiber
port1.append((rings[-1], "through"))
port2.append((fibers[-1], "P0"))
# Add the drop nets, in ring order, to the fibers between the two bus fibers
for k in range(n_channels):
port1.append((rings[k], "drop"))
port2.append((fibers[k + 1], "P0"))
return bank, rings, fibers, port1, port2
# Place the 8 channel bank
chip, rings, fibers, port1, port2 = place_bank(channels)
# Print the number of nets and the size of the placement
print(f"{len(port1)} nets, placement {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um")
# Show the placement before routing
viewer(chip)
17 nets, placement 1170 x 351 um
Layout

The router takes the nets as two lists and connects them index by index, so port1[i] is wired to port2[i]. Each entry is a tuple of a reference and a port name, which lets the router use the position of the port after placement and the geometry around it.

Seventeen nets describe the whole chip: the bus input, seven hops from ring to ring, the bus output, and eight drops. allow_s_bend=False and allow_diagonals=False restrict every turn to a 90 degree bend, and collision_layers=["Si"] makes only silicon block a route, not the device-recognition boxes of the PDK cells. grid_size=4.5 sets the routing grid to half the bend radius, which is also the default of the router; it is written out here because every call below uses the same value.

# Route all 17 nets in one call
routes = pf.parametric.route_auto(
port1=port1, # First port of every net
port2=port2, # Second port of every net
grid_size=4.5, # Routing grid size (μm)
collision_layers=["Si"], # Layers that block a route
allow_s_bend=False, # Whether the router may use S bends
allow_diagonals=False, # Whether the router may use 45 degree bends
diagnostics=True, # Whether to attach the routing report to the result
show_progress=False, # Whether to show a progress bar
)
# Copy the placement, sharing its devices instead of duplicating them
bank = chip.copy(deep=False)
# Add the routes to the copy
bank.add_reference(routes)
# Read the routing report from the properties of the result
report = json.loads(routes.properties.get("route_auto"))
# Count the 90 degree bends of every net
bends = [net["num_bends_90"] for net in report["routes"]]
# Measure the length of every route
lengths = [pf.route_length(ref.component) for ref in routes.references]
# Print the number of routed nets, the total length, and the total number of bends
print(
f"{report['successful_nets']} of {report['total_nets']} nets routed, "
f"{pf.route_length(routes):.0f} um of waveguide, {sum(bends)} bends of 90 degrees"
)
# Print the lengths of the two bus ends
print(f"bus in / out: {lengths[0]:.1f} / {lengths[channels]:.1f} um")
# Print the length and the number of bends of every drop
print(f"{'drop':>6}{'length (um)':>13}{'bends':>7}")
for k in range(channels):
print(f"{k:>6}{lengths[channels + 1 + k]:>13.1f}{bends[channels + 1 + k]:>7}")
# Print the size of the routed chip
print(f"chip: {bank.size()[0]:.0f} x {bank.size()[1]:.0f} um")
# Show the routed chip
viewer(bank)
17 of 17 nets routed, 4928 um of waveguide, 18 bends of 90 degrees
bus in / out: 678.2 / 648.2 um
drop length (um) bends
0 508.7 2
1 441.7 2
2 374.7 2
3 307.7 2
4 307.7 2
5 374.7 2
6 441.7 2
7 508.7 2
chip: 1170 x 351 um
Layout

The hops along the bus need no bends at all, because those ports already face each other, and each bus end turns once toward its fiber. Every drop descends from its ring, jogs sideways once toward its channel, and turns down into it: two bends per drop. The drops nearest the center of the array are the shortest, the length grows toward both ends, and mirrored drops have exactly the same length.

With diagnostics=True the router stores a JSON report in the route_auto property of the result. The top level summarizes the whole call; routes holds one entry per net, in the order of port1 and port2. The fields most worth checking are success and failure_message, the bend and crossing counts, and iterations, the number of search steps the net took.

# Collect the top level of the report, leaving out the entries of the nets
summary = {key: value for key, value in report.items() if key != "routes"}
# Print the summary of the whole call
print(json.dumps(summary, indent=2))
# Select the entry of the bus output, net 8
net = report["routes"][channels]
# Print the fields of that entry most worth checking
for key in (
"net_key",
"success",
"failure_message",
"num_bends_90",
"num_bends_45",
"num_crossings",
"iterations",
):
print(f"{key:>16}: {net[key]!r}")
{
"total_nets": 17,
"successful_nets": 17,
"failed_nets": 0,
"total_crossings": 0,
"total_cost": 0.046061999999999936,
"worst_net_cost": 0.006533999999999989,
"route_all_time_us": 75703,
"prepare_time_us": 0,
"initial_route_time_us": 74617,
"reroute_time_us": 2,
"cleanup_time_us": 0
}
net_key: 8
success: True
failure_message: ''
num_bends_90: 1
num_bends_45: 0
num_crossings: 0
iterations: 220

Comparing 90 degree, S, and 45 degree bends

Section titled “Comparing 90 degree, S, and 45 degree bends”

By default, and in every call above, the router turns with 90 degree bends only. Two flags add other shapes:

  • allow_s_bend=True lets a net step sideways with one S bend where it would otherwise need two 90 degree bends.
  • allow_diagonals=True adds 45 degree bends. The 45 degree bend is derived from the 90 degree one, so it uses the same radius and waveguide; a custom one can be passed as bend45.

The cell below routes the same 17 nets three ways and compares them.

# Set the bend flags of the three variants
variants = {
"90 degree bends only": {"allow_s_bend": False, "allow_diagonals": False},
"with S bends": {"allow_s_bend": True, "allow_diagonals": False},
"with 45 degree bends": {"allow_s_bend": False, "allow_diagonals": True},
}
# Print the header of the comparison table
print(f"{'':>22}{'length (um)':>13}{'90 deg':>8}{'45 deg':>8}")
# Route the same nets with each variant
for label, flags in variants.items():
# Route all nets with the bend flags of this variant
bundle = pf.parametric.route_auto(
port1=port1,
port2=port2,
grid_size=4.5,
collision_layers=["Si"],
diagnostics=True,
show_progress=False,
**flags,
)
# Read the routing report of this variant
bundle_report = json.loads(bundle.properties.get("route_auto"))
# Print the total length and the numbers of 90 and 45 degree bends
print(
f"{label:>22}{pf.route_length(bundle):>13.0f}"
f"{sum(net['num_bends_90'] for net in bundle_report['routes']):>8}"
f"{sum(net['num_bends_45'] for net in bundle_report['routes']):>8}"
)
# Copy the placement, sharing its devices
diagonal = chip.copy(deep=False)
# Add the routes of the last variant, the one with 45 degree bends
diagonal.add_reference(bundle)
# Show the layout with 45 degree bends
viewer(diagonal)
length (um) 90 deg 45 deg
90 degree bends only 4928 18 0
with S bends 4922 18 0
with 45 degree bends 4000 0 20
Layout

With 45 degree bends the fan-out runs diagonally from the 60 um ring pitch to the 127 um fiber pitch, which removes every right angle and cuts the waveguide length by nearly a fifth. The rest of this notebook keeps 90 degree bends only, so that each section changes one thing.

The router sees the devices it connects, but nothing else on the chip. Any other geometry it must avoid is passed through obstacles, which takes 2D structures, components, and references, several at once.

Here a 75 um probe pad on the M2_router metal layer is placed on the way down of drop 3. collision_layers sets which layers block a route: with only "Si", the router would draw the silicon waveguide straight under the metal, so "M2_router" is added to it. The pad is also added to the layout, not only to the router.

# Create the probe pad component
probe_pad = pf.Component("probe_pad")
# Draw the pad as a 75 μm square on the router metal layer
probe_pad.add("M2_router", pf.Rectangle(center=(0, 0), size=(75.0, 75.0)))
# Place the pad on the way down of drop 3, 130 μm below the bus
pad = pf.Reference(probe_pad, origin=(rings[3]["drop"].center[0], -130.0))
# Route all nets around the pad
around = pf.parametric.route_auto(
port1=port1,
port2=port2,
grid_size=4.5,
obstacles=[pad], # Geometry the routes must avoid
collision_layers=["Si", "M2_router"], # Layers that block a route, metal included
allow_s_bend=False,
allow_diagonals=False,
diagnostics=True,
show_progress=False,
)
# Copy the placement, sharing its devices
with_pad = chip.copy(deep=False)
# Add the pad to the layout, not only to the router
with_pad.add_reference(pad)
# Add the routes around the pad
with_pad.add_reference(around)
# Read the routing report
around_report = json.loads(around.properties.get("route_auto"))
# Find the overlaps between the routes and the pad
overlaps, _ = pf.routing_collisions(
around, obstacles=[pad], collision_layers=["Si", "M2_router"]
)
# Print the routed nets, the bends with and without the pad, and the overlaps
print(
f"{around_report['successful_nets']} of {around_report['total_nets']} nets routed, "
f"{sum(net['num_bends_90'] for net in around_report['routes'])} bends of 90 degrees "
f"(without the pad: {sum(bends)}), "
f"{sum(polygon.area() > 1e-3 for polygon in overlaps)} overlaps with the pad"
)
# Show the layout with the pad
viewer(with_pad)
17 of 17 nets routed, 20 bends of 90 degrees (without the pad: 18), 0 overlaps with the pad
Layout

The channel order used so far lets every net reach its fiber without crossing another. Suppose instead that the package fixes the fiber order so that channels 4 and 5 are exchanged. Drops 3 and 4 then have to cross.

A crossing component is passed through cross, and the router inserts it where two nets meet. cross_cost sets how much a crossing costs against extra length and bends, and max_crossings limits the crossings per net (0 forbids them).

# Load the waveguide crossing from the SiEPIC PDK
crossing = siepic.component("ebeam_crossing4")
# Copy the list of second ports
swapped = list(port2)
# Find the net indices of drops 3 and 4
i3, i4 = channels + 1 + 3, channels + 1 + 4
# Exchange the fibers of drops 3 and 4
swapped[i3], swapped[i4] = port2[i4], port2[i3]
# Route all nets with the exchanged fibers
crossed = pf.parametric.route_auto(
port1=port1,
port2=swapped, # Second ports, with the fibers of drops 3 and 4 exchanged
grid_size=4.5,
collision_layers=["Si"],
cross=crossing, # Crossing component inserted where two nets meet
allow_s_bend=False,
allow_diagonals=False,
diagnostics=True,
show_progress=False,
)
# Copy the placement, sharing its devices
with_crossing = chip.copy(deep=False)
# Add the routes with the crossing
with_crossing.add_reference(crossed)
# Read the routing report
crossed_report = json.loads(crossed.properties.get("route_auto"))
# Print the number of routed nets and of inserted crossings
print(
f"{crossed_report['successful_nets']} of {crossed_report['total_nets']} nets routed, "
f"{crossed_report['total_crossings']} crossing inserted"
)
# Show the layout with the crossing
viewer(with_crossing)
17 of 17 nets routed, 1 crossing inserted
Layout

By default a net that fails makes route_auto raise an error, after the search has used up max_iterations steps (500 000 by default, which can take minutes). With allow_partial=True the router returns every net it could route instead, and the report says which net failed and why.

Below, a pad is placed by mistake on top of the grating coupler of channel 5, so drop 4 has nowhere to land. A lower max_iterations makes the search give up quickly. With allow_partial=True the failure is also printed as a warning rather than raised.

# Place a pad by mistake on top of the grating coupler of channel 5
misplaced = pf.Reference(probe_pad, origin=fibers[5]["P0"].center + (0, 10))
# Route all nets, keeping the ones that succeed
partial = pf.parametric.route_auto(
port1=port1,
port2=port2,
grid_size=4.5,
obstacles=[misplaced], # Pad covering the grating coupler of channel 5
collision_layers=["Si", "M2_router"],
allow_s_bend=False,
allow_diagonals=False,
allow_partial=True, # Whether to return the routed nets instead of raising an error
max_iterations=20000, # Maximum number of search iterations per route
diagnostics=True,
show_progress=False,
)
# Read the routing report
partial_report = json.loads(partial.properties.get("route_auto"))
# Print the number of routed nets
print(
f"{partial_report['successful_nets']} of {partial_report['total_nets']} nets routed"
)
# Print the index and the failure message of every net that failed
for net in partial_report["routes"]:
if not net["success"]:
print(f"net {net['net_key']} failed: {net['failure_message']}")
16 of 17 nets routed
net 13 failed: No path found after 20000 iterations. The target port near (-49.5, -292.5) appears to be inside or too close to an obstacle. Check endpoint placement, set 'collision_layers' to only the layers intended to block routing, or set 'include_port_references_as_obstacles' to false.

The drops above differ in length by a few hundred micrometers, set only by where each ring sits relative to its channel. When the channels must arrive with the same optical delay or the same propagation loss, route_auto can set their lengths in the same call. Each group of nets to control is a PathLengthMatching object. The router lengthens nets by splicing meanders into them, and it never draws colliding geometry: a group it cannot match is reported as handled: False and its routes come back unmatched (pass strict=True to raise an error instead).

The cells below show every drop matched to the same length, first to the longest drop and then to one absolute length; one length per drop; a matched layout that stays symmetric; and the three settings of bends.

The helper below, route_with_matching, does what every example in this section needs: it places the bank with the fiber row at row_y, routes all 17 nets in one route_auto call with the given list of matching groups, and prints whether each group was handled, followed by the length and the number of 90 degree bends of every drop and, for the drops in a group, how many of those bends the matching added. It returns the layout.

Called with an empty list, it routes without matching. That layout, the same as the one in “Routing the bank”, is the reference for the examples below.

# Net indices of drops 0 to 7
drop_nets = list(range(channels + 1, 2 * channels + 1))
def route_with_matching(matching, row_y=row_y, obstacles=()):
"""Route the bank with a list of matching groups and print each drop's length and bends."""
# Place a new bank with the fiber row at row_y
layout, _, _, a, b = place_bank(channels, row_y=row_y)
# Route all nets with the matching groups in one call
bundle = pf.parametric.route_auto(
port1=a,
port2=b,
grid_size=4.5,
obstacles=list(obstacles),
collision_layers=["Si", "M2_router"],
allow_s_bend=False,
allow_diagonals=False,
matching=matching,
diagnostics=True,
show_progress=False,
)
# Add the obstacles to the layout
for obstacle in obstacles:
layout.add_reference(obstacle)
# Add the routes to the layout
layout.add_reference(bundle)
added = {} # Bends added by the matching, keyed by net index
# Read the matching report, one entry per group
reports = json.loads(bundle.properties.get("path_length_matching", "[]"))
# Print whether each group was handled and collect the bends it added
for group, group_report in zip(matching, reports):
print(f"matching group handled: {group_report['handled']}")
added.update(zip(group.nets, group_report["added_bends"]))
# Read the routing report
report = json.loads(bundle.properties.get("route_auto"))
# Count the 90 degree bends of every net, keyed by net index
bends = {net["net_key"]: net["num_bends_90"] for net in report["routes"]}
# Measure the length of every route
lengths = [pf.route_length(ref.component) for ref in bundle.references]
# Measure the size of the layout
width, height = layout.size()
# Print the position of the fiber row and the size of the chip
print(
f"fiber row {-row_y:.0f} um below the bus, chip {width:.0f} x {height:.0f} um"
)
# Print the length and the bends of every drop, then the bends the matching added
print("drop " + "".join(f"{k:>9}" for k in range(channels)))
print("length (um) " + "".join(f"{lengths[net]:>9.2f}" for net in drop_nets))
print("bends " + "".join(f"{bends[net]:>9}" for net in drop_nets))
if added:
print(
"added bends "
+ "".join(f"{added.get(net, '-'):>9}" for net in drop_nets)
)
return layout
# Route without matching, as the reference for the examples below
viewer(route_with_matching([]))
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 441.74 374.74 307.74 307.74 374.74 441.74 508.74
bends 2 2 2 2 2 2 2 2
Layout

With only the net indices, target=None: every drop is matched to the longest one in the group. Drops 0 and 7, the two outermost, are the longest, so they are left as they are and the other six each gain one meander. The meanders go wherever each drop has space, so the layout is no longer a mirror image.

The meanders need room between the bus and the fiber row. With the row 300 um below the bus, as in the reference, there is none: the group comes back handled: False and the drops stay unmatched. The row is therefore placed 350 um below the bus, the closest it can be, in 50 um steps, for this group to be handled.

# Match every drop to the longest one, with the fiber row 350 μm below the bus
viewer(route_with_matching([pf.PathLengthMatching(drop_nets)], row_y=-350.0))
matching group handled: True
fiber row 350 um below the bus, chip 1170 x 401 um
drop 0 1 2 3 4 5 6 7
length (um) 558.74 558.74 558.74 558.74 558.74 558.74 558.74 558.74
bends 2 6 6 6 6 6 6 2
added bends 0 4 4 4 4 4 4 0
Layout

Matching every drop to one absolute length

Section titled “Matching every drop to one absolute length”

A single target value is one absolute length for the whole group, for example a length fixed by another block or by the package. It has to be longer than every drop already is, since a route can be lengthened but not shortened. Here every drop is routed to exactly 700 um. The two outermost drops now need meanders too, and the fiber row has to be 450 um below the bus to leave room for them all.

# Match every drop to 700 μm, with the fiber row 450 μm below the bus
viewer(
route_with_matching([pf.PathLengthMatching(drop_nets, target=700.0)], row_y=-450.0)
)
matching group handled: True
fiber row 450 um below the bus, chip 1170 x 501 um
drop 0 1 2 3 4 5 6 7
length (um) 700.00 700.00 700.00 700.00 700.00 700.00 700.00 700.00
bends 6 6 6 6 6 6 6 6
added bends 4 4 4 4 4 4 4 4
Layout

A sequence gives one target per net. Here each drop is 20 um longer than the one before it, starting at 700 um for drop 0: a delay ladder with a specified step. Like the absolute target, it needs the fiber row 450 um below the bus.

ladder = [700.0 + 20.0 * k for k in range(channels)] # Target length of every drop (μm)
# Match every drop to its own target, with the fiber row 450 μm below the bus
viewer(
route_with_matching([pf.PathLengthMatching(drop_nets, target=ladder)], row_y=-450.0)
)
matching group handled: True
fiber row 450 um below the bus, chip 1170 x 501 um
drop 0 1 2 3 4 5 6 7
length (um) 700.00 720.00 740.00 760.00 780.00 800.00 820.00 840.00
bends 6 6 6 6 6 6 6 6
added bends 4 4 4 4 4 4 4 4
Layout

The unmatched drop fan is a mirror image of itself, but a single group ignores that: in the first matched layout above, the meanders of the east and west halves are placed independently. Matching the two halves as two groups keeps the symmetry. Each group is matched to its own longest drop, drops 0 and 7, which have the same length, and the router draws the same meanders mirrored. This also needs the least room: the fiber row stays 300 um below the bus, as in the reference.

east_half = pf.PathLengthMatching(drop_nets[:4]) # Matching group of drops 0 to 3
west_half = pf.PathLengthMatching(drop_nets[4:]) # Matching group of drops 4 to 7
# Match each half to its own longest drop, with the fiber row 300 μm below the bus
viewer(route_with_matching([east_half, west_half]))
matching group handled: True
matching group handled: True
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 508.74 508.74 508.74 508.74 508.74 508.74 508.74
bends 2 6 6 6 6 6 6 2
added bends 0 4 4 4 4 4 4 0
Layout

bends decides how the bends added by the meanders are shared among the nets of a group. The difference only shows when the nets start with different bend counts, so the cells below use the probe pad from the obstacle section again, on the way down of drop 3. Drop 3 detours around it with two extra bends, so the four middle drops, 2, 3, 4, and 5, start with 2, 4, 2, and 2 bends. Those four drops form the group in the three cells after the next one, with the fiber row 300 um below the bus; look at the drops next to the pad.

The bank with the probe pad and no matching group: drop 3 detours around the pad with four bends, and every other drop has two.

# Route the bank around the probe pad, with no matching group
viewer(route_with_matching([], obstacles=[pad]))
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 441.74 374.74 335.93 307.74 374.74 441.74 508.74
bends 2 2 2 4 2 2 2 2
Layout

Each net takes only the bends its length needs. Drops 2 and 5 are already the longest, so they get no meander; drops 3 and 4 get one each.

middle_drops = [drop_nets[k] for k in (2, 3, 4, 5)] # Net indices of drops 2 to 5
# Match the middle drops, adding to each only the bends its length needs
viewer(
route_with_matching(
[pf.PathLengthMatching(middle_drops, bends="free")], obstacles=[pad]
)
)
matching group handled: True
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 441.74 374.74 374.74 374.74 374.74 441.74 508.74
bends 2 2 2 8 6 2 2 2
added bends - - 0 4 4 0 - -
Layout

Every net gets the same number of added bends, four, so that the matching adds the same bend loss to every channel. The longest drops now get a meander too, and the common length grows. Drop 3 keeps its two extra bends from the detour, so it still ends with two more than the others.

# Match the middle drops, adding the same number of bends to each
viewer(
route_with_matching(
[pf.PathLengthMatching(middle_drops, bends="equal_added")], obstacles=[pad]
)
)
matching group handled: True
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 441.74 398.63 398.63 398.63 398.63 441.74 508.74
bends 2 2 6 8 6 6 2 2
added bends - - 4 4 4 4 - -
Layout

Every net ends with the same total number of bends, for a broadband match with equal bend loss. Drop 3 already has four, so it gains four; the other three gain six each, and all four end with eight.

# Match the middle drops, so that every drop ends with the same number of bends
viewer(
route_with_matching(
[pf.PathLengthMatching(middle_drops, bends="equal")], obstacles=[pad]
)
)
matching group handled: True
fiber row 300 um below the bus, chip 1170 x 351 um
drop 0 1 2 3 4 5 6 7
length (um) 508.74 441.74 428.62 428.62 428.62 428.62 441.74 508.74
bends 2 2 8 8 8 8 2 2
added bends - - 6 4 6 6 - -
Layout

The same placement function and the same routing arguments are used from 4 to 64 channels. Only the number of rings changes.

# Print the header of the scaling table
print(
f"{'channels':>8}{'nets':>6}{'routed':>8}{'length (um)':>13}{'bends':>7}{'chip (um)':>15}"
)
# Place and route banks of 4 to 64 channels with the same arguments
for n in (4, 8, 16, 32, 64):
# Place a bank of n channels
scaled, _, _, a, b = place_bank(n)
# Route all nets of the bank
bundle = pf.parametric.route_auto(
port1=a,
port2=b,
grid_size=4.5,
collision_layers=["Si"],
allow_s_bend=False,
allow_diagonals=False,
diagnostics=True,
show_progress=False,
)
# Add the routes to the bank
scaled.add_reference(bundle)
# Read the routing report
scaled_report = json.loads(bundle.properties.get("route_auto"))
# Measure the size of the routed bank
width, height = scaled.size()
# Print the nets, the routed nets, the total length, the bends, and the chip size
print(
f"{n:>8}{len(a):>6}{scaled_report['successful_nets']:>8}"
f"{pf.route_length(bundle):>13.0f}"
f"{sum(net['num_bends_90'] for net in scaled_report['routes']):>7}"
f"{f'{width:.0f} x {height:.0f}':>15}"
)
# Show the 64 channel bank
viewer(scaled)
channels nets routed length (um) bends chip (um)
4 9 9 2567 10 662 x 351
8 17 17 4928 18 1170 x 351
16 33 33 11258 34 2186 x 351
32 65 65 30350 66 4218 x 351
64 129 129 94183 156 8282 x 351
Layout

routing_collisions tests the routed bundle against the placed devices. Each route touches the port it connects to, and those junctions come back as slivers of zero area, so only polygons above 1e-3 um² count as real overlaps. disconnected_reference_ports then lists the ports no route reached, which must be exactly the ten fiber ports of the grating couplers.

A floor plan rectangle is added last, since the design rule deck of the PDK checks that every drawn feature sits inside it, and the chip is written to GDSII.

# Find the overlaps between the routes and the placed devices
overlaps, _ = pf.routing_collisions(
routes, obstacles=rings + fibers, collision_layers=["Si"]
)
# Print the number of overlaps, counting only polygons above 1e-3 μm²
print(
f"{sum(polygon.area() > 1e-3 for polygon in overlaps)} overlaps with the placed devices"
)
# List the ports that no route reaches
disconnected = bank.disconnected_reference_ports()
# Select the fiber ports of the grating couplers among them
fiber_ports = [port for port in disconnected if isinstance(port, pf.GaussianPort)]
# Print the number of disconnected ports and how many of them are fiber ports
print(f"{len(disconnected)} disconnected ports, {len(fiber_ports)} of them fiber ports")
# Find the bounds of the chip
(x_min, y_min), (x_max, y_max) = bank.bounds()
margin = 20.0 # Margin between the chip and the floor plan (μm)
# Add the floor plan rectangle that the design rule deck of the PDK requires
bank.add(
"Floor Plan-Not Fabricated",
pf.Rectangle(
(pf.snap_to_grid(x_min - margin), pf.snap_to_grid(y_min - margin)),
(pf.snap_to_grid(x_max + margin), pf.snap_to_grid(y_max + margin)),
),
)
# Write the chip to a GDSII file
pf.write_layout("ring_filter_bank.gds", bank)
0 overlaps with the placed devices
10 disconnected ports, 10 of them fiber ports

The arguments below were left at their defaults in this example.

argumentwhat it does
port1, port2a single (reference, port) tuple routes one net; a third tuple element selects one copy of an array reference
radius, bend, bend45, straight_kwargs, s_bend_kwargsthe sections every route is built from
collision_offsetextra clearance around each route, in whole grid cells; widens the spacing between parallel waveguides
include_port_references_as_obstacleswhether the devices being connected also block the other nets (on by default)
propagation_cost, bend_cost, bend45_costrelative cost of length and of each bend, which decides between a longer path and more bends
congestion_cost, congestion_radius, cross_space_costpenalties that keep routes and crossings from packing tightly
net_reorderwhether the router may change the order in which it routes the nets
search_marginhow far outside the ports the search may go
max_reroute_rounds, reroute_history_costhow hard the router works to resolve conflicts between nets
collapse_s_bendsremoves access S bends that turn out to be unnecessary after routing
route_model, bundle_modelthe circuit models attached to each route and to the whole bundle, for simulation
technology, nametechnology and name of the returned component

PathLengthMatching also takes offsets (length a net already has outside the route), min_gap (the smallest spacing between members of a group), objective="phase" or "group_delay" to match optical rather than geometric length (this needs the modal index, from index or from a mode solve at frequency), and tolerance, the accepted residual. Routes that are already drawn can be matched afterwards with match_path_lengths.

  • An 8 channel ring filter bank, its bus, and its fiber array are wired by one route_auto call over 17 nets, with no waypoints and no hand-drawn waveguides.
  • Rotating every ring by 180 degrees puts the bus on top as one straight line, and a bend at the end of each drop bus turns every drop port toward the fiber array. With the array centered on the drop ports, the drop fan is a mirror image of itself.
  • Keeping the channel order along the array the same as the ring order along the bus makes the fan-out from the 60 um ring pitch to the 127 um fiber pitch nest without crossings, with 90 degree bends only.
  • diagnostics=True reports every net’s bends, crossings, and failures; allow_partial=True returns what could be routed and names the net that could not.
  • allow_diagonals=True shortens the fan-out by nearly a fifth; obstacles and collision_layers keep routes away from other geometry; cross inserts crossings where the net order requires them.
  • PathLengthMatching sets the lengths of the drops inside the routing call: all equal to the longest or to an absolute target, or one target per drop. Matching the two mirror halves as two groups keeps the layout symmetric, with no extra room. bends decides whether each net takes only the bends it needs ("free"), every net gains the same number ("equal_added"), or every net ends with the same number ("equal").
  • The same placement and routing arguments scale from 4 to 64 channels (129 nets) with no other change.