Automated Routing and Path Length Matching of a Photonic Mesh¶
A programmable photonic mesh is a lattice of 2 by 2 Mach-Zehnder interferometers (MZIs) with thermo-optic phase shifters. A mesh of \(n(n-1)/2\) such elements applies an arbitrary \(n\) by \(n\) unitary to \(n\) optical modes [1]. This example lays out a seven mode Clements mesh with all fourteen inputs and outputs on one grating coupler array underneath it, wires the chip with route_auto, and sets the lengths of the routes with PathLengthMatching.
The mesh is an interferometer, so its wiring must have no crossings, the input and output routes of every mode should be mirror images of each other, and their lengths must be controlled.
Each section shows one feature:
Routing: a grating coupler array as one array reference, two routing calls where the first bundle is an obstacle for the second, heater metal as a blocking layer, the routing report, the routing grid, the congestion cost, 45 degree bends, and an inserted crossing.
Path length matching: mirror pairs, all routes to the longest one, one target per net with
min_gapandstrict=True,offsets, and thebendssetting.
References
Clements, William R., et al. “Optimal design for universal multiport interferometers.” Optica 2016 3 (12), 1460-1465, doi: 10.1364/OPTICA.3.001460.
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¶
The SiEPIC OpenEBL technology [2] is loaded through the siepic_forge module. Every parametric call below inherits the default arguments, the routing calls included.
[1]:
import json
import photonforge as pf
import siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every component
pf.config.default_technology = siepic.ebeam()
# Set the defaults inherited by every parametric call
pf.config.default_kwargs = {
"port_spec": "TE_1550_500", # 500 nm silicon strip, single mode TE at 1550 nm
"radius": 10.0, # Bend radius (μm)
"euler_fraction": 0.5, # Fraction of each bend drawn as an Euler curve
}
# Hide the port labels in the layout views
pf.config.svg_labels = False
# Start the live viewer that shows every layout below
viewer = pf.live_viewer.LiveViewer()
LiveViewer started at http://127.0.0.1:35223. Call its 'stop()' method to free the port.
Defining the mesh element¶
The element is an MZI made of two broadband directional couplers from the process design kit (PDK) [2], with a heater strip on each input and on each arm. The coupler leaves its two ports only 4.7 um apart, closer than two routed waveguides can sit, so the element ends in an access fan of four S bends that spreads its ports to port_pitch. The next section sets port_pitch to the distance between the mode lines of the mesh, which puts every port of the finished mesh on a mode line.
[2]:
# Load the directional coupler of the PDK
coupler = siepic.component("ebeam_bdc_te1550")
# Load the grating coupler of the PDK
grating = siepic.component("ebeam_gc_te1550")
# Measure the distance between the two ports on each side of the coupler
coupler_pitch = coupler["P3"].center[1] - coupler["P2"].center[1]
@pf.parametric_component
def mzi(
*,
port_pitch=100.0, # Distance between the two ports on each side (μm)
arm_length=60.0, # Length of the straights that carry the heaters (μm)
arm_gap=30.0, # Distance between the two arms (μm)
bend_length=25.0, # Length of the S bends that open and close the arms (μm)
fan_length=67.9, # Length of the S bends of the access fan (μm)
heater_width=3.0, # Width of the heater strips (μm)
):
"""MZI with a heater on each input and arm, ports fanned out to port_pitch."""
# Create the parent component of the element
c = pf.Component()
# Create the straight that carries each heater
straight = pf.parametric.straight(length=arm_length)
# Place the two input straights one coupler pitch apart
inputs = [
c.add_reference(pf.Reference(straight, origin=(0, y)))
for y in (0, coupler_pitch)
]
# Connect the first coupler after the inputs
left = c.add_reference(coupler).connect("P0", inputs[0]["P1"])
# Compute the S bend offset that opens the arms to arm_gap
offset = (arm_gap - coupler_pitch) / 2
# Build the upper arm (sign +1) and the lower arm (sign -1)
arms, ends = [], []
for port, sign in (("P3", 1), ("P2", -1)):
# Create the S bend that opens the arm and the one that closes it
out = pf.parametric.s_bend(length=bend_length, offset=sign * offset)
back = pf.parametric.s_bend(length=bend_length, offset=-sign * offset)
# Chain the opening S bend, the straight, and the closing S bend
chain = pf.reference_chain(
{"open": out, "arm": straight, "close": back}, "open - arm - close", start=left[port]
)
c.add(*chain)
arms.append(chain[1])
ends.append(chain[2])
# Connect the second coupler to the end of the upper arm
right = c.add_reference(coupler).connect("P1", ends[0]["P1"])
# Draw a heater strip along every input and arm straight
for ref in inputs + arms:
# Find the extent and the center line of the straight
(x0, y0), (x1, y1) = ref.bounds()
y = (y0 + y1) / 2
# Draw the strip on the heater layer
strip = pf.Rectangle((x0, y - heater_width / 2), (x1, y + heater_width / 2))
c.add("M1_heater", strip)
# Compute how far each port must move away from the axis of the element
rise = (port_pitch - coupler_pitch) / 2
# List the four inner ports in the order P0, P1, P2, P3
inner = [inputs[0]["P0"], inputs[1]["P0"], right["P2"], right["P3"]]
# Create one fan S bend per port, bending away from the axis
fans = [
pf.parametric.s_bend(length=fan_length, offset=s * rise) for s in (1, -1, -1, 1)
]
# Connect the fans and expose their ends as the ports of the element
c.add_port([c.add_reference(f).connect("P0", p)["P1"] for f, p in zip(fans, inner)])
# Attach a circuit model built from the sub-components
c.add_model(pf.CircuitModel(), "Circuit")
return c
# Build the element with the default arguments
element = mzi()
# Print the size of the element
print(f"element {element.size()[0]:.1f} x {element.size()[1]:.1f} um")
viewer(element)
element 447.3 x 101.5 um
[2]:
The element is 447.3 um long and 101.5 um tall, with its ports 100 um apart on each side.
Placing the mesh and the grating coupler array¶
A Clements mesh is a list of (column, mode) pairs: the element in that column couples mode lines mode and mode + 1, and the columns alternate between even and odd mode pairs. For an odd number of modes the arrangement is its own mirror image.
place_mesh places the elements on the mode lattice, so that every hop between columns joins two ports at the same height, and walks the circuit column by column to list those hops. The top mode line has no element in the first or last column, so a straight lead-in and bypass carry it to the edge of the mesh. The input ports and the output ports then form two identical vertical stacks, and the grating couplers sit below the mesh, centered on the mirror line between them.
The fourteen grating couplers are a single array reference: one Reference with columns=14 and a spacing of one coupler pitch.
[3]:
n_modes = 7 # Number of modes of the mesh
mode_pitch = 100.0 # Distance between neighboring mode lines (μm)
column_gap = 180.0 # Clear space between neighboring columns of elements (μm)
gc_pitch = 127.0 # Distance between neighboring grating couplers (μm)
gc_drop = 460.0 # Distance from the bottom of the mesh down to the couplers (μm)
stub_tolerance = 1.0 # Shortest gap that gets a lead-in or bypass straight (μm)
def clements(n):
"""List the (column, mode) pairs of an n mode Clements mesh."""
# Alternate every column between even and odd mode pairs
return [(col, mode) for col in range(n) for mode in range(col % 2, n - 1, 2)]
def place_mesh(n=n_modes):
"""Place an n mode mesh and its grating couplers, and list its hops."""
# Build the element with its ports on the mode pitch
cell = mzi(port_pitch=mode_pitch)
# Compute the column pitch from the element length and the gap
pitch = cell.size()[0] + column_gap
# Create the parent component of the chip
chip = pf.Component(f"clements_mesh_{n}")
# Place one element per (column, mode) on the two mode lines it couples
refs = {}
for col, mode in clements(n):
y = (mode + 0.5) * mode_pitch - coupler_pitch / 2
origin = (pf.snap_to_grid(col * pitch), pf.snap_to_grid(y))
refs[col, mode] = chip.add_reference(pf.Reference(cell, origin=origin))
# Walk the mesh column by column, keeping the current output port of every mode
source, entry, hops = [None] * n, [None] * n, ([], [])
for col, mode in sorted(refs):
ref = refs[col, mode]
# Connect the lower (P0) and upper (P1) inputs of this element
for line, name in ((mode, "P0"), (mode + 1, "P1")):
if source[line] is None:
# Record the first port of a mode as its input
entry[line] = (ref, name)
else:
# Add a hop from the previous element of this mode
hops[0].append(source[line])
hops[1].append((ref, name))
# Make the outputs P2 and P3 the new output ports of the two modes
source[mode] = (ref, "P2")
source[mode + 1] = (ref, "P3")
# Find the west and east edges of the mesh
x_min = min(r.x_min for r in refs.values())
x_max = max(r.x_max for r in refs.values())
def extend(ports, x_edge, near, far):
"""Extend every port that stops short of x_edge with a straight."""
stubs, flush = [], []
for ref, name in ports:
# Measure how far the port is from the edge
run = abs(x_edge - ref[name].center[0])
if run > stub_tolerance:
# Add a straight from the port to the edge, and use its far end
stub = pf.parametric.straight(length=pf.snap_to_grid(run))
stubs.append(chip.add_reference(stub).connect(near, ref[name]))
flush.append((stubs[-1], far))
else:
# Keep a port that is already on the edge
flush.append((ref, name))
return stubs, flush
# Add the lead-in on the west edge and the bypass on the east edge
lead_ins, entry = extend(entry, x_min, "P1", "P0")
bypasses, exits = extend(source, x_max, "P0", "P1")
# Put the mirror line halfway between the input and output ports
first_in, first_out = entry[0][0][entry[0][1]], exits[0][0][exits[0][1]]
axis = 0.5 * (first_in.center[0] + first_out.center[0])
# Put the row of grating couplers gc_drop below the mesh
y_row = pf.snap_to_grid(min(r.y_min for r in refs.values()) - gc_drop)
# Place the 2n grating couplers as one array reference, centered on the mirror line
gc = chip.add_reference(
pf.Reference(
grating,
origin=(pf.snap_to_grid(axis + (0.5 - n) * gc_pitch), y_row),
rotation=90, # Rotation of every coupler, to face north (degrees)
columns=2 * n, # Number of repetitions along x
spacing=(gc_pitch, 0), # Displacement between repetitions (μm)
)
)
# Return the chip, its nets, and every device a route must avoid
devices = list(refs.values()) + [gc] + lead_ins + bypasses
return dict(chip=chip, gc=gc, entry=entry, exits=exits, hops=hops, devices=devices)
# Place the seven mode mesh
mesh = place_mesh()
n = n_modes
# Print the number of hops and grating couplers
print(f"{len(mesh['hops'][0])} hops between columns, {2 * n} grating couplers")
viewer(mesh["chip"])
35 hops between columns, 14 grating couplers
[3]:
The mesh has 21 elements joined by 35 hops, and the array holds one grating coupler for every input and output.
Routing the mesh¶
The chip is routed in two route_auto calls, because the two jobs need different settings. The interconnect joins ports that already face each other on the same mode line, so a large bend radius costs nothing. The grating coupler routes turn a corner from the ends of the mesh down to the array, so they use a smaller radius and a fine grid.
Both calls share the settings in common:
collision_layerskeeps routes off silicon and heater metal.max_crossings=0forbids crossings. The mesh is planar, so this is a check: if the nets did not allow a planar layout, the call would fail instead of inserting a crossing.net_reorder=Falseroutes the nets in the given order, so that the routes of the result keep the order ofport1andport2.allow_s_bend=Falseandallow_diagonals=Falseallow 90 degree bends only.
summary prints the census of a bundle from the routing report that diagnostics=True attaches to it, including the routing time the report records, and returns the length of every route.
[4]:
inside_radius = 60.0 # Bend radius of the interconnect (μm)
inside_grid = 10.0 # Routing grid size of the interconnect (μm)
outside_radius = 20.0 # Bend radius of the grating coupler routes (μm)
outside_grid = 5.0 # Routing grid size of the grating coupler routes (μm)
# Set the routing arguments shared by both calls
common = dict(
collision_layers=["Si", "M1_heater"], # Layers that block a route
collision_offset=5.0, # Extra clearance around each route (μm)
max_crossings=0, # Largest number of crossings per net
net_reorder=False, # Whether the router may change the order of the nets
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
)
# Add the bend radius and routing grid of the interconnect call
inside = dict(common, radius=inside_radius, grid_size=inside_grid)
# Add the bend radius and routing grid of the grating coupler call
outside = dict(common, radius=outside_radius, grid_size=outside_grid)
def summary(label, routes):
"""Print the census of a bundle from its routing report and return its lengths."""
# Read the routing report from the properties of the bundle
report = json.loads(routes.properties["route_auto"])
# Count the bends of each shape over every net
bends90 = sum(net["num_bends_90"] for net in report["routes"])
bends45 = sum(net["num_bends_45"] for net in report["routes"])
# Measure the length of every route
lengths = [pf.route_length(ref.component) for ref in routes.references]
# Print one summary line, with the routing time the report records
print(
f"{label}: {report['successful_nets']}/{report['total_nets']} nets, "
f"{sum(lengths) / 1e3:.3f} mm, "
f"{bends90} bends of 90 and {bends45} of 45 degrees, "
f"{report['total_crossings']} crossings, "
f"routed in {report['route_all_time_us'] / 1e6:.1f} s"
)
return lengths
The interconnect¶
The first call routes the hops between columns. The placed devices are passed as obstacles, since the router otherwise sees only the devices of the ports it connects.
[5]:
# Route every hop between columns in one call
interconnect = pf.parametric.route_auto(
port1=mesh["hops"][0], # First port of every net
port2=mesh["hops"][1], # Second port of every net
obstacles=mesh["devices"], # Geometry every route must avoid
name="interconnect", # Name of the returned bundle
**inside,
)
# Add the interconnect to the chip
mesh["chip"].add_reference(interconnect)
# Print the census of the interconnect
hop_lengths = summary("interconnect", interconnect)
# Print the distinct hop lengths
print(f"distinct hop lengths: {sorted({round(x, 3) for x in hop_lengths})} um")
interconnect: 35/35 nets, 9.437 mm, 0 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 0.0 s
distinct hop lengths: [180.0, 807.3] um
Every hop is a straight with no bends, in only two lengths: one column gap, and the double gap a mode takes where it skips a column. The routing time in the report counts only the path search, which a straight between two facing ports barely needs; the rest of the call prepares the obstacles.
The grating coupler routes¶
The second call routes all fourteen grating coupler routes. A grating coupler port is a tuple of three: the array reference, the port name, and the repetition index of the coupler in the array. The interconnect is passed as an obstacle with the devices, because a bundle routed in an earlier call is invisible to the router otherwise.
Two choices make the input and output routes mirror images:
Boundary order. Routes that turn a corner nest inside each other, so the top mode, the one furthest from the array, takes the outermost coupler: coupler
n - 1 - mon the input side andn + mon the output side.Grating coupler first. Every net has its coupler in
port1and its mesh port inport2, since the router does not treat the two ends of a net the same way.
[6]:
def gc_nets(layout, k=n_modes):
"""List the grating coupler and mesh port of every route, inputs then outputs."""
# Give mode m coupler k - 1 - m on the input side and k + m on the output side
couplers = [(layout["gc"], "P0", k - 1 - m) for m in range(k)]
couplers += [(layout["gc"], "P0", k + m) for m in range(k)]
# Pair them with the input ports, then the output ports, of the mesh
return couplers, layout["entry"] + layout["exits"]
def worst_pair(lengths, k=n_modes):
"""Largest length difference between the input and output routes of a mode (μm)."""
# Compare route m with route k + m for every mode
return max(abs(lengths[m] - lengths[k + m]) for m in range(k))
# List the fourteen grating coupler routes
couplers, mesh_ports = gc_nets(mesh)
# Route all fourteen routes in one call, with the interconnect as an obstacle
gc_routes = pf.parametric.route_auto(
port1=couplers,
port2=mesh_ports,
obstacles=[interconnect] + mesh["devices"],
name="gc_routes",
**outside,
)
# Add the grating coupler routes to the chip
mesh["chip"].add_reference(gc_routes)
# Print the census of the grating coupler routes
lengths = summary("grating coupler routes", gc_routes)
# Print the length of every input and output route
print(f"input (um): {[round(x, 2) for x in lengths[:n]]}")
print(f"output (um): {[round(x, 2) for x in lengths[n:]]}")
# Print the worst difference between the two routes of a mode
print(f"worst input to output difference: {worst_pair(lengths):.3f} um")
viewer(mesh["chip"])
grating coupler routes: 14/14 nets, 35.070 mm, 42 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 12.1 s
input (um): [2527.22, 2517.39, 2512.17, 2507.34, 2497.26, 2492.22, 2482.39]
output (um): [2526.96, 2517.04, 2511.87, 2507.09, 2496.92, 2491.96, 2482.04]
worst input to output difference: 0.359 um
[6]:
Every route has three bends and none crosses another. The input and output routes of each mode agree to better than a micron over about 2.5 mm, while the routes of different modes differ by tens of micrometers, since the outer routes of a nest are longer than the inner ones.
Reading the routing report¶
The routing report is a JSON string in the route_auto property of the result: a summary of the whole call, and one entry per net in routes. iterations is the number of search steps a net took; a net that uses up max_iterations fails, and failure_message says why.
[7]:
# Read the routing report of the grating coupler routes
report = json.loads(gc_routes.properties["route_auto"])
# Print three fields of the entry of the input route of the top mode
top_input = report["routes"][n - 1]
print({key: top_input[key] for key in ("success", "num_bends_90", "iterations")})
{'success': True, 'num_bends_90': 3, 'iterations': 122835}
The input route of the top mode was routed with three bends after 117 236 search steps.
Choosing the routing grid¶
grid_size is the pitch of the tracks the router searches on. A grid that divides the 100 um port pitch puts every mesh port on a track, so the input and output routes are the same search, reflected. The cell below repeats the routes on a 10 um grid.
[8]:
coarse_grid = 10.0 # Routing grid size of the comparison (μm)
# Route the grating coupler routes on the coarser grid
coarse = pf.parametric.route_auto(
port1=couplers,
port2=mesh_ports,
obstacles=[interconnect] + mesh["devices"],
name="gc_routes_coarse",
**dict(outside, grid_size=coarse_grid),
)
# Print the census and the worst input to output difference
gap = worst_pair(summary("10 um grid", coarse))
print(f"worst input to output difference: {gap:.3f} um")
10 um grid: 14/14 nets, 35.978 mm, 42 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 0.8 s
worst input to output difference: 14.896 um
The coarse grid is faster to search, but the two sides land on different tracks, and a mode’s input and output routes now differ by about 15 um instead of a fraction of a micron. The same sensitivity applies to the placement: the fan length of the element, 67.9 um, puts the ports of both sides at equivalent positions on the 5 um grid, and a fan a few micrometers longer or shorter gives a worse match.
Spreading the bundle¶
By default the router packs parallel routes as close as collision_offset allows. congestion_cost is the cost of passing near another route, and congestion_radius is how far that cost reaches, in grid cells. Raising them spreads the bundle, which lowers the crosstalk between neighboring waveguides at the cost of some length.
[9]:
spread_cost = 1e-3 # Cost of passing near another route
spread_radius = 3 # Reach of the congestion cost (grid cells)
# Route the grating coupler routes with the higher congestion cost
spread = pf.parametric.route_auto(
port1=couplers,
port2=mesh_ports,
obstacles=[interconnect] + mesh["devices"],
name="gc_routes_spread",
**dict(
outside,
congestion_cost=spread_cost, # Cost of passing near another route
congestion_radius=spread_radius, # Reach of that cost (grid cells)
),
)
# Print the census of the spread bundle
spread_lengths = summary("spread bundle", spread)
# Print the spread of the route lengths across modes, before and after
before = max(lengths) - min(lengths)
after = max(spread_lengths) - min(spread_lengths)
print(f"spread across modes: {before:.1f} um before, {after:.1f} um after")
# Place a fresh mesh and add the interconnect and the spread bundle to it
spread_chip = place_mesh()["chip"]
spread_chip.add_reference(interconnect)
spread_chip.add_reference(spread)
viewer(spread_chip)
spread bundle: 14/14 nets, 35.910 mm, 42 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 6.5 s
spread across modes: 45.2 um before, 75.4 um after
[9]:
The bends stay the same; the tracks move apart, so the outer routes of the nest grow and the spread between the shortest and the longest route widens.
45 degree bends¶
allow_diagonals=True adds 45 degree bends, made from the 90 degree bend of the route; a custom one can be passed as bend45. (allow_s_bend=True similarly adds S bends, which change almost nothing on this chip.)
[10]:
# Route the grating coupler routes with 45 degree bends allowed
diagonal = pf.parametric.route_auto(
port1=couplers,
port2=mesh_ports,
obstacles=[interconnect] + mesh["devices"],
name="gc_routes_diagonal",
**dict(outside, allow_diagonals=True),
)
# Print the census of the diagonal routes
summary("with 45 degree bends", diagonal)
# Place a fresh mesh and add the interconnect and the diagonal routes to it
diagonal_chip = place_mesh()["chip"]
diagonal_chip.add_reference(interconnect)
diagonal_chip.add_reference(diagonal)
viewer(diagonal_chip)
with 45 degree bends: 14/14 nets, 31.872 mm, 16 bends of 90 and 52 of 45 degrees, 0 crossings, routed in 18.3 s
[10]:
The routes cut the corners between the mesh and the array: fewer right angles and about 3 mm less waveguide. The rest of the notebook keeps 90 degree bends only.
Inserting a crossing¶
Suppose the package exchanges the fibers of three pairs of outputs: modes 0 and 1, 2 and 3, and 4 and 5. The two routes of each pair then have to cross. A crossing component passed as cross lets the router insert one where two nets meet, and max_crossings=1 allows one per net. (With crossings forbidden, some nets would have no path; allow_partial=True makes the router return the others instead of raising an error.)
[11]:
swapped_pairs = [(0, 1), (2, 3), (4, 5)] # Modes whose output couplers are exchanged
# Load the waveguide crossing of the PDK
crossing = siepic.component("ebeam_crossing4")
# Exchange the output grating couplers of every swapped pair of modes
swapped = list(couplers)
for a, b in swapped_pairs:
swapped[n + a], swapped[n + b] = couplers[n + b], couplers[n + a]
# Route the swapped order with one crossing allowed per net
crossed = pf.parametric.route_auto(
port1=swapped,
port2=mesh_ports,
obstacles=[interconnect] + mesh["devices"],
name="gc_routes_crossed",
**dict(
outside,
cross=crossing, # Crossing the router may insert
max_crossings=1, # Largest number of crossings per net
),
)
# Print the census of the crossed routes
summary("swapped outputs", crossed)
# Place a fresh mesh and add the interconnect and the crossed routes to it
crossed_chip = place_mesh()["chip"]
crossed_chip.add_reference(interconnect)
crossed_chip.add_reference(crossed)
viewer(crossed_chip)
swapped outputs: 14/14 nets, 36.693 mm, 42 bends of 90 and 0 of 45 degrees, 3 crossings, routed in 8.2 s
[11]:
The router inserted three crossings, one for each exchanged pair, and every route still has three bends.
Path length matching¶
route_auto can also set the lengths of the routes it draws. Each group of nets to control is a PathLengthMatching object, passed in a list as matching. The router lengthens the shorter nets of a group by splicing meanders into them, and never draws colliding geometry: a group it cannot match comes back unmatched, reported as handled: False in the path_length_matching property of the result.
route_matched places a fresh mesh, adds the interconnect, and routes the grating coupler routes with the given groups. It prints how many groups were handled, the census, and for every mode the input and output lengths and the bends the matching added to each.
[12]:
def route_matched(groups, label):
"""Route a fresh mesh with matching groups, print the lengths, return the chip."""
# Place a fresh mesh and list its grating coupler routes
layout = place_mesh()
couplers, ports = gc_nets(layout)
# Route the grating coupler routes with the matching groups
routes = pf.parametric.route_auto(
port1=couplers,
port2=ports,
obstacles=[interconnect] + layout["devices"],
matching=groups, # Groups of nets whose lengths are set
name=f"{label}_gc_routes",
**outside,
)
# Add the interconnect and the new routes to the chip
layout["chip"].add_reference(interconnect)
layout["chip"].add_reference(routes)
# Read the matching report, one entry per group
reports = json.loads(routes.properties["path_length_matching"])
# Collect the bends each group added to each of its nets
added = {}
for group, group_report in zip(groups, reports):
added.update(zip(group.nets, group_report["added_bends"]))
# Print how many groups were handled
print(f"{sum(r['handled'] for r in reports)} of {len(groups)} groups handled")
# Print the census of the routes
per_net = summary(label, routes)
# Print the length of every input and output route
print(f"input (um): {[round(x, 2) for x in per_net[:n]]}")
print(f"output (um): {[round(x, 2) for x in per_net[n:]]}")
# Print the bends added to the input and output route of every mode
added_bends = [f"{added[m]}/{added[n + m]}" for m in range(n)]
print(f"added bends, input/output: {added_bends}")
return layout["chip"]
# List the net indices of the input routes and of the output routes
input_nets = list(range(n))
output_nets = list(range(n, 2 * n))
Matching the input and output routes of each mode¶
On a mesh the pair that matters is a mode’s input route and its own output route, on opposite sides of the chip. Each of the seven groups below holds one such pair, net m and net n + m, and is matched to the longer of its two nets, the default target=None.
[13]:
# Group the input route of every mode with its own output route
pairs = [pf.PathLengthMatching([m, n + m]) for m in range(n)]
# Route the mesh with the seven groups
paired_chip = route_matched(pairs, "mirror pairs")
viewer(paired_chip)
7 of 7 groups handled
mirror pairs: 14/14 nets, 35.072 mm, 42 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 4.5 s
input (um): [2527.22, 2517.39, 2512.17, 2507.34, 2497.26, 2492.22, 2482.39]
output (um): [2527.22, 2517.39, 2512.17, 2507.34, 2497.26, 2492.22, 2482.39]
added bends, input/output: ['0/0', '0/0', '0/0', '0/0', '0/0', '0/0', '0/0']
[13]:
The two routes of every mode now have the same length, and the matching added no bends: the gaps were small enough to absorb in straights the routes already have. This is the layout on the cover.
Matching every route to the longest one¶
A single group of all fourteen routes, with the default target=None, matches every route to the longest one.
[14]:
# Group all fourteen routes
everything = input_nets + output_nets
# Match all fourteen routes to the longest one
viewer(route_matched([pf.PathLengthMatching(everything)], "to the longest"))
1 of 1 groups handled
to the longest: 14/14 nets, 35.381 mm, 42 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 4.8 s
input (um): [2527.22, 2527.22, 2527.22, 2527.22, 2527.22, 2527.22, 2527.22]
output (um): [2527.22, 2527.22, 2527.22, 2527.22, 2527.22, 2527.22, 2527.22]
added bends, input/output: ['0/0', '0/0', '0/0', '0/0', '0/0', '0/0', '0/0']
[14]:
Every route now has the length of the longest, and the report shows no added bends.
Setting one length per mode¶
target sets absolute lengths: a single value gives the whole group one length, and a sequence gives one length per net. Here mode 0 gets a length 200 um above the longest route, each mode above it 20 um more, and both routes of a mode the same length: a fixed delay step between modes.
Two more arguments constrain the result. min_gap is the smallest spacing the matcher must keep between the members of the group, and strict=True makes the call raise an error if a group cannot be matched, instead of returning it unmatched with handled: False.
[15]:
headroom = 200.0 # Length of mode 0 above the longest unmatched route (μm)
delay_step = 20.0 # Length added from one mode to the next (μm)
min_gap = 25.0 # Smallest spacing between the members of the group (μm)
# Give both routes of mode m the length max(lengths) + headroom + m * delay_step
per_mode = [max(lengths) + headroom + m * delay_step for m in range(n)]
# Match all fourteen routes to their own targets, with a minimum spacing
ladder = pf.PathLengthMatching(
everything,
target=per_mode + per_mode, # One length per net, inputs then outputs (μm)
min_gap=min_gap, # Smallest spacing between the members of the group (μm)
strict=True, # Whether to raise an error if the group cannot be matched
)
viewer(route_matched([ladder], "one length per mode"))
1 of 1 groups handled
one length per mode: 14/14 nets, 39.021 mm, 98 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 4.5 s
input (um): [2727.22, 2747.22, 2767.22, 2787.22, 2807.22, 2827.22, 2847.22]
output (um): [2727.22, 2747.22, 2767.22, 2787.22, 2807.22, 2827.22, 2847.22]
added bends, input/output: ['4/4', '4/4', '4/4', '4/4', '4/4', '4/4', '4/4']
[15]:
Every mode is now exactly 20 um longer than the one below it. Reaching those lengths takes a meander of four bends in every route, and the call returns normally, so both the spacing and the lengths were met.
Counting length outside the route¶
The route is not the whole path: the top mode also runs through its lead-in and bypass straights, which belong to the placement. offsets gives that length to the matcher, which then equalizes offset + routed length for every net.
[16]:
# Measure the lead-in and the bypass of the top mode
lead_in = mesh["entry"][n - 1][0].component.size()[0]
bypass = mesh["exits"][n - 1][0].component.size()[0]
print(f"lead-in {lead_in:.1f} um, bypass {bypass:.1f} um")
# Give the lead-in to the input route of the top mode and the bypass to its output route
offsets = [0.0] * (n - 1) + [lead_in] + [0.0] * (n - 1) + [bypass]
# Match every path, route plus offset, to the longest one
group = pf.PathLengthMatching(
everything,
offsets=offsets, # Length each net already has outside its route (μm)
)
viewer(route_matched([group], "with offsets"))
lead-in 627.3 um, bypass 627.3 um
1 of 1 groups handled
with offsets: 14/14 nets, 42.281 mm, 90 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 5.5 s
input (um): [3109.69, 3109.69, 3109.69, 3109.7, 3109.69, 3109.69, 2482.39]
output (um): [3109.69, 3109.7, 3109.69, 3109.69, 3109.69, 3109.69, 2482.39]
added bends, input/output: ['4/4', '4/4', '4/4', '4/4', '4/4', '4/4', '0/0']
[16]:
The six lower modes grow by about the length of the lead-in, so that every mode now has the same path from its grating coupler to the mesh and back. The top mode, already the longest with its offset, is left as it is.
Choosing which bends the matching adds¶
bends decides how added bends are shared in a group. "free", used above, gives each net only the bends its length needs; "equal_added" adds the same number to every net, so the matching adds the same bend loss everywhere; "equal" makes every net end with the same number of bends.
[17]:
# Group the mirror pairs again, adding the same number of bends to every net
pairs = [
pf.PathLengthMatching(
[m, n + m],
bends="equal_added", # How the added bends are shared among the nets
)
for m in range(n)
]
# Route the mesh with the seven groups
viewer(route_matched(pairs, "mirror pairs, equal added bends"))
7 of 7 groups handled
mirror pairs, equal added bends: 14/14 nets, 35.815 mm, 98 bends of 90 and 0 of 45 degrees, 0 crossings, routed in 4.5 s
input (um): [2580.31, 2570.48, 2565.26, 2560.43, 2550.35, 2545.31, 2535.48]
output (um): [2580.31, 2570.48, 2565.26, 2560.43, 2550.35, 2545.31, 2535.48]
added bends, input/output: ['4/4', '4/4', '4/4', '4/4', '4/4', '4/4', '4/4']
[17]:
The number of added bends is not an input: the matcher chooses it, gives the same number to every net of the group, and reports it per net in the added_bends field of the matching report, which is what the last printed line shows. Here every route gets one meander of four bends, including the route of each pair that was already the longer one. The shortest four-bend meander at the 20 um bend radius adds about 53 um, so every route grows by that much, where "free" added no bends and
almost no length.
Checking and exporting the layout¶
routing_collisions tests each bundle against the placed devices; overlaps below 1e-3 um² are the junctions where a route meets its port. The only ports left unconnected must be the fourteen fiber ports of the grating couplers. A floor plan rectangle, which the design rule deck of the PDK expects around every drawn feature, is added before the chips are written to GDSII.
[18]:
margin = 20.0 # Floor plan margin around the drawn features (μm)
min_overlap = 1e-3 # Smallest overlap that counts as a collision (μm²)
# Test each bundle against the devices, ignoring the port junctions
for label, bundle in (
("interconnect", interconnect),
("grating coupler routes", gc_routes),
):
# Find the overlaps of the bundle with the devices on the blocking layers
layers = common["collision_layers"]
overlaps, _ = pf.routing_collisions(
bundle, obstacles=mesh["devices"], collision_layers=layers
)
# Count and print the overlaps larger than a port junction
count = sum(p.area() > min_overlap for p in overlaps)
print(f"{label}: {count} overlaps with the devices")
# Print the number of ports that no route reached
print(f"{len(mesh['chip'].disconnected_reference_ports())} unconnected ports")
# Add a floor plan rectangle to each chip and write it to GDSII
for chip, filename in (
(mesh["chip"], "symmetric_mesh.gds"),
(paired_chip, "symmetric_mesh_mirror_pairs.gds"),
):
# Draw the floor plan around the bounds of the chip
(x0, y0), (x1, y1) = chip.bounds()
floor_plan = pf.Rectangle((x0 - margin, y0 - margin), (x1 + margin, y1 + margin))
chip.add("Floor Plan-Not Fabricated", floor_plan)
# Write the chip to GDSII
pf.write_layout(filename, chip)
interconnect: 0 overlaps with the devices
grating coupler routes: 0 overlaps with the devices
14 unconnected ports
No route overlaps a device, and the fourteen unconnected ports are exactly the fiber ports of the grating couplers.
Other arguments¶
These arguments were not used here:
argument |
what it does |
|---|---|
|
return the nets that could be routed instead of raising, and the search budget per net |
|
custom sections for the routes |
|
whether the devices being connected also block other nets |
|
relative costs of length, bends, and crossings |
|
how widely and how hard the router searches |
|
removes access S bends that turn out to be unnecessary |
|
circuit models attached to each route and to the bundle |
PathLengthMatching also takes tolerance (the accepted residual), and objective="phase" or "group_delay" with an index or frequency, to match optical rather than geometric length. Routes that are already drawn can be matched afterwards with match_path_lengths.
Summary¶
A seven mode Clements mesh, 35 interconnect hops and 14 grating coupler routes, is wired by two
route_autocalls with no crossings, no waypoints, and no hand-drawn waveguides.The grating couplers are one array reference; the third element of a port tuple picks one coupler.
A bundle routed in an earlier call must be passed as an obstacle;
collision_layerskeeps routes off the heaters;max_crossings=0checks that the layout is planar;net_reorder=Falsekeeps the routes in the order of the nets.Boundary order, grating coupler first, and a grid that divides the port pitch make the input and output routes mirror images.
congestion_costspreads a bundle,allow_diagonalscuts its corners, andcrossinserts crossings where the net order needs them.PathLengthMatchingmatches mirror pairs, or all routes to the longest one, with no added bends; a sequence of targets sets one length per mode;offsetscount length outside the route; andbendsdecides how the added bends are shared.