Automated Routing of a Ring Filter Bank

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=Trueattaches to the result, - S bends and 45 degree bends,
- obstacles and the
collision_layersthat 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
bendssettings, - 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
- 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.
- 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.
Loading the technology and defaults
Section titled “Loading the technology and defaults”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 pfimport siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every component belowpf.config.default_technology = siepic.ebeam()
# Set the defaults inherited by every parametric call, the routing call includedpf.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 componentspf.config.svg_labels = False
# Open the live viewer that displays each layout belowviewer = pf.live_viewer.LiveViewer()LiveViewer started at http://127.0.0.1:39305. Call its 'stop()' method to free the port.Defining the ring filter
Section titled “Defining the ring filter”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 PDKgrating = siepic.component("ebeam_gc_te1550")
# Load the terminator that closes the unused add portsterminator = siepic.component("ebeam_terminator_te1550")
# Define the add-drop ring as a parametric component@pf.parametric_componentdef 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 parametersring = ring_filter()
# Show the ring in the live viewerviewer(ring)Placing the bank and the fiber array
Section titled “Placing the bank and the fiber array”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 bankring_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 themdef 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 bankchip, rings, fibers, port1, port2 = place_bank(channels)
# Print the number of nets and the size of the placementprint(f"{len(port1)} nets, placement {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um")
# Show the placement before routingviewer(chip)17 nets, placement 1170 x 351 umRouting the bank
Section titled “Routing the bank”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 callroutes = 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 thembank = chip.copy(deep=False)
# Add the routes to the copybank.add_reference(routes)
# Read the routing report from the properties of the resultreport = json.loads(routes.properties.get("route_auto"))
# Count the 90 degree bends of every netbends = [net["num_bends_90"] for net in report["routes"]]
# Measure the length of every routelengths = [pf.route_length(ref.component) for ref in routes.references]
# Print the number of routed nets, the total length, and the total number of bendsprint( 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 endsprint(f"bus in / out: {lengths[0]:.1f} / {lengths[channels]:.1f} um")
# Print the length and the number of bends of every dropprint(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 chipprint(f"chip: {bank.size()[0]:.0f} x {bank.size()[1]:.0f} um")
# Show the routed chipviewer(bank)17 of 17 nets routed, 4928 um of waveguide, 18 bends of 90 degreesbus 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 2chip: 1170 x 351 umThe 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.
Reading the routing report
Section titled “Reading the routing report”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 netssummary = {key: value for key, value in report.items() if key != "routes"}
# Print the summary of the whole callprint(json.dumps(summary, indent=2))
# Select the entry of the bus output, net 8net = report["routes"][channels]
# Print the fields of that entry most worth checkingfor 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: 220Comparing 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=Truelets a net step sideways with one S bend where it would otherwise need two 90 degree bends.allow_diagonals=Trueadds 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 asbend45.
The cell below routes the same 17 nets three ways and compares them.
# Set the bend flags of the three variantsvariants = { "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 tableprint(f"{'':>22}{'length (um)':>13}{'90 deg':>8}{'45 deg':>8}")
# Route the same nets with each variantfor 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 devicesdiagonal = chip.copy(deep=False)
# Add the routes of the last variant, the one with 45 degree bendsdiagonal.add_reference(bundle)
# Show the layout with 45 degree bendsviewer(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 20With 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.
Routing around a probe pad
Section titled “Routing around a probe pad”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 componentprobe_pad = pf.Component("probe_pad")
# Draw the pad as a 75 μm square on the router metal layerprobe_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 buspad = pf.Reference(probe_pad, origin=(rings[3]["drop"].center[0], -130.0))
# Route all nets around the padaround = 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 deviceswith_pad = chip.copy(deep=False)
# Add the pad to the layout, not only to the routerwith_pad.add_reference(pad)
# Add the routes around the padwith_pad.add_reference(around)
# Read the routing reportaround_report = json.loads(around.properties.get("route_auto"))
# Find the overlaps between the routes and the padoverlaps, _ = pf.routing_collisions( around, obstacles=[pad], collision_layers=["Si", "M2_router"])
# Print the routed nets, the bends with and without the pad, and the overlapsprint( 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 padviewer(with_pad)17 of 17 nets routed, 20 bends of 90 degrees (without the pad: 18), 0 overlaps with the padInserting a waveguide crossing
Section titled “Inserting a waveguide crossing”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 PDKcrossing = siepic.component("ebeam_crossing4")
# Copy the list of second portsswapped = list(port2)
# Find the net indices of drops 3 and 4i3, i4 = channels + 1 + 3, channels + 1 + 4
# Exchange the fibers of drops 3 and 4swapped[i3], swapped[i4] = port2[i4], port2[i3]
# Route all nets with the exchanged fiberscrossed = 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 deviceswith_crossing = chip.copy(deep=False)
# Add the routes with the crossingwith_crossing.add_reference(crossed)
# Read the routing reportcrossed_report = json.loads(crossed.properties.get("route_auto"))
# Print the number of routed nets and of inserted crossingsprint( f"{crossed_report['successful_nets']} of {crossed_report['total_nets']} nets routed, " f"{crossed_report['total_crossings']} crossing inserted")
# Show the layout with the crossingviewer(with_crossing)17 of 17 nets routed, 1 crossing insertedFinding a net that cannot be routed
Section titled “Finding a net that cannot be routed”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 5misplaced = pf.Reference(probe_pad, origin=fibers[5]["P0"].center + (0, 10))
# Route all nets, keeping the ones that succeedpartial = 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 reportpartial_report = json.loads(partial.properties.get("route_auto"))
# Print the number of routed netsprint( f"{partial_report['successful_nets']} of {partial_report['total_nets']} nets routed")
# Print the index and the failure message of every net that failedfor net in partial_report["routes"]: if not net["success"]: print(f"net {net['net_key']} failed: {net['failure_message']}")16 of 17 nets routednet 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.Path length matching
Section titled “Path length matching”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.
Routing the bank with matching groups
Section titled “Routing the bank with matching groups”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 7drop_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 belowviewer(route_with_matching([]))fiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 441.74 374.74 307.74 307.74 374.74 441.74 508.74bends 2 2 2 2 2 2 2 2Matching every drop to the longest one
Section titled “Matching every drop to the longest one”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 busviewer(route_with_matching([pf.PathLengthMatching(drop_nets)], row_y=-350.0))matching group handled: Truefiber row 350 um below the bus, chip 1170 x 401 umdrop 0 1 2 3 4 5 6 7length (um) 558.74 558.74 558.74 558.74 558.74 558.74 558.74 558.74bends 2 6 6 6 6 6 6 2added bends 0 4 4 4 4 4 4 0Matching 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 busviewer( route_with_matching([pf.PathLengthMatching(drop_nets, target=700.0)], row_y=-450.0))matching group handled: Truefiber row 450 um below the bus, chip 1170 x 501 umdrop 0 1 2 3 4 5 6 7length (um) 700.00 700.00 700.00 700.00 700.00 700.00 700.00 700.00bends 6 6 6 6 6 6 6 6added bends 4 4 4 4 4 4 4 4Setting one length per drop
Section titled “Setting one length per drop”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 busviewer( route_with_matching([pf.PathLengthMatching(drop_nets, target=ladder)], row_y=-450.0))matching group handled: Truefiber row 450 um below the bus, chip 1170 x 501 umdrop 0 1 2 3 4 5 6 7length (um) 700.00 720.00 740.00 760.00 780.00 800.00 820.00 840.00bends 6 6 6 6 6 6 6 6added bends 4 4 4 4 4 4 4 4Keeping the matched layout symmetric
Section titled “Keeping the matched layout symmetric”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 3west_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 busviewer(route_with_matching([east_half, west_half]))matching group handled: Truematching group handled: Truefiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 508.74 508.74 508.74 508.74 508.74 508.74 508.74bends 2 6 6 6 6 6 6 2added bends 0 4 4 4 4 4 4 0Choosing which bends the matching adds
Section titled “Choosing which bends the matching adds”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.
Before matching
Section titled “Before matching”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 groupviewer(route_with_matching([], obstacles=[pad]))fiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 441.74 374.74 335.93 307.74 374.74 441.74 508.74bends 2 2 2 4 2 2 2 2bends="free"
Section titled “bends="free"”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 needsviewer( route_with_matching( [pf.PathLengthMatching(middle_drops, bends="free")], obstacles=[pad] ))matching group handled: Truefiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 441.74 374.74 374.74 374.74 374.74 441.74 508.74bends 2 2 2 8 6 2 2 2added bends - - 0 4 4 0 - -bends="equal_added"
Section titled “bends="equal_added"”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 eachviewer( route_with_matching( [pf.PathLengthMatching(middle_drops, bends="equal_added")], obstacles=[pad] ))matching group handled: Truefiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 441.74 398.63 398.63 398.63 398.63 441.74 508.74bends 2 2 6 8 6 6 2 2added bends - - 4 4 4 4 - -bends="equal"
Section titled “bends="equal"”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 bendsviewer( route_with_matching( [pf.PathLengthMatching(middle_drops, bends="equal")], obstacles=[pad] ))matching group handled: Truefiber row 300 um below the bus, chip 1170 x 351 umdrop 0 1 2 3 4 5 6 7length (um) 508.74 441.74 428.62 428.62 428.62 428.62 441.74 508.74bends 2 2 8 8 8 8 2 2added bends - - 6 4 6 6 - -Scaling the bank to 64 channels
Section titled “Scaling the bank to 64 channels”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 tableprint( 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 argumentsfor 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 bankviewer(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 351Checking and exporting the layout
Section titled “Checking and exporting the 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 devicesoverlaps, _ = 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 reachesdisconnected = bank.disconnected_reference_ports()
# Select the fiber ports of the grating couplers among themfiber_ports = [port for port in disconnected if isinstance(port, pf.GaussianPort)]
# Print the number of disconnected ports and how many of them are fiber portsprint(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 requiresbank.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 filepf.write_layout("ring_filter_bank.gds", bank)0 overlaps with the placed devices10 disconnected ports, 10 of them fiber portsOther arguments
Section titled “Other arguments”The arguments below were left at their defaults in this example.
| argument | what it does |
|---|---|
port1, port2 | a 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_kwargs | the sections every route is built from |
collision_offset | extra clearance around each route, in whole grid cells; widens the spacing between parallel waveguides |
include_port_references_as_obstacles | whether the devices being connected also block the other nets (on by default) |
propagation_cost, bend_cost, bend45_cost | relative cost of length and of each bend, which decides between a longer path and more bends |
congestion_cost, congestion_radius, cross_space_cost | penalties that keep routes and crossings from packing tightly |
net_reorder | whether the router may change the order in which it routes the nets |
search_margin | how far outside the ports the search may go |
max_reroute_rounds, reroute_history_cost | how hard the router works to resolve conflicts between nets |
collapse_s_bends | removes access S bends that turn out to be unnecessary after routing |
route_model, bundle_model | the circuit models attached to each route and to the whole bundle, for simulation |
technology, name | technology 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.
Summary
Section titled “Summary”- An 8 channel ring filter bank, its bus, and its fiber array are wired by one
route_autocall 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=Truereports every net’s bends, crossings, and failures;allow_partial=Truereturns what could be routed and names the net that could not.allow_diagonals=Trueshortens the fan-out by nearly a fifth;obstaclesandcollision_layerskeep routes away from other geometry;crossinserts crossings where the net order requires them.PathLengthMatchingsets the lengths of the drops inside the routing call: all equal to the longest or to an absolutetarget, or one target per drop. Matching the two mirror halves as two groups keeps the layout symmetric, with no extra room.bendsdecides 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.