Skip to content

Automated Routing of a Dual Polarization Coherent Transceiver

Download notebook (.zip)

A coherent transceiver can sit on one chip with three fibers: a laser that feeds both halves, a transmitter output, and a receiver input [1]. This example lays out such a chip for two polarizations, X and Y, with route_auto.

  • The transmitter has an IQ modulator for each polarization and a polarization splitter rotator (PSR) that combines them.
  • The receiver has a PSR that splits the signal, and a 90 degree hybrid [2] with four detectors for each polarization.

The two polarizations must arrive together. A delay between them, a skew, spoils the signal, and one picosecond of skew is about 70 um of silicon waveguide. So every X route has a twin in the Y channel, and each pair is a PathLengthMatching group. The floor plan is symmetric, so most twins are the same length before any matching, and only the detector banks need the matching to add anything.

References

  1. Doerr, Christopher R. “Silicon photonic integration in telecommunications.” Frontiers in Physics 2015 3, 37, doi: 10.3389/fphy.2015.00037.
  2. Seimetz, Matthias, and Carl-Michael Weinert. “Options, feasibility, and availability of 2x4 90 degree hybrids for coherent optical systems.” Journal of Lightwave Technology 2006 24 (3), 1317-1322, doi: 10.1109/JLT.2005.863251.
  3. 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.

We load the SiEPIC OpenEBL technology [3] and set the default arguments that every parametric call inherits.

# Import the JSON module to read the routing reports
import json
# Import PhotonForge and the SiEPIC technology module
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 default arguments 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:42729. Call its 'stop()' method to free the port.

route_auto takes the nets as two lists of (reference, port) pairs and connects them index by index. The arguments that every call shares are set once, as the defaults of route_auto in pf.config.default_kwargs, so each call passes only what differs and can still override any of them. A matching group lists the nets that must be the same length, and the router lengthens the shorter ones with detours. Three arguments control it:

  • offsets: the length that a net has outside its route, such as a heated straight in front of a modulator. The group equalizes offset + routed length.
  • tolerance: the residual that a group accepts. Without it, the matcher closes a gap of a fraction of a micrometer with a small jog, which is a bump in the layout and no gain in skew. The skew_budget is 20 um, or 0.3 ps.
  • bends="equal": every net of a group gets the same number of corners, by building the detour as nested loops.

All bends are 90 degrees. The routing grid is 2.75 um, half the spacing of the two outputs of a Y branch, and every port sits on it, so the router needs no jog to align a port. The exception is the two outer fibers, 127 um from the laser, which start with a jog of 0.5 um. The radius of 10 um and the grid were chosen by a sweep for the fewest corners. The Y branches and the crossing are PDK cells and keep their curved shape.

group_report prints the matching report of a bundle: whether each group was handled, the length of every net, the residual, and the bends that the matching added.

# Set the matching tolerance and the routing grid used by every routing call
skew_budget = 20.0 # Residual that a matching group accepts (μm)
routing_grid = 2.75 # Routing grid size, half the output spacing of a Y branch (μm)
# Set the arguments shared by every routing call as the defaults of route_auto
pf.config.default_kwargs["route_auto"] = dict(
radius=10.0, # Bend radius of the routes (μm)
grid_size=routing_grid, # Routing grid size (μm)
collision_offset=2.0, # Extra clearance around each route (μm)
collision_layers=["Si", "M1_heater", "M2_router"], # 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
allow_partial=True, # Whether to return the nets that were routed on a failure
diagnostics=True, # Whether to attach the routing report to the result
show_progress=False, # Whether to show a progress bar
)
def group_report(bundle):
# Read the matching report of every group of the bundle
reports = json.loads(bundle.properties["path_length_matching"])
# Print one line for each group
for k, group in enumerate(reports):
print(
f"group {k}: handled {group['handled']}, "
f"length {[round(x, 1) for x in group['lengths']]} um, "
f"residual {[round(x, 1) for x in group['residuals']]} um, "
f"bends added {group['added_bends']}"
)

Four cells come from the PDK: a Y branch, a 2 by 2 coupler, a crossing, and a grating coupler. Three more are built here, each in its own cell, and shown as soon as it is built. The first cell loads the PDK cells, sets the sizes of the devices, and defines two helpers: add_step moves a port sideways with two 90 degree bends, and add_heater draws a heater strip along a straight.

The polarization splitter rotator (PSR) is a Y branch in this example: the PDK has no PSR, and a real one adds a path length of its own, which would go to the matcher as an offset.

# Load the Y branch of the PDK
y_branch = siepic.component("ebeam_y_1550")
# Load the 2 by 2 coupler of the PDK
coupler = siepic.component("ebeam_bdc_te1550")
# Load the waveguide crossing of the PDK
crossing = siepic.component("ebeam_crossing4")
# Load the grating coupler of the PDK
grating = siepic.component("ebeam_gc_te1550")
# Measure the spacing of the two output ports of the Y branch
y_pitch = y_branch["P2"].center[1] - y_branch["P1"].center[1]
# Set the sizes that describe the devices
arm_length = 150.0 # Length of each phase shifter arm of a modulator (μm)
arm_separation = 55.5 # Distance between the two arms of a modulator (μm)
bend_radius = 10.0 # Radius of the 90 degree bends inside the devices (μm)
quarter_turn = 90.0 # Angle of the bends inside the devices (degrees)
heater_width = 3.0 # Width of the heater strips (μm)
bias_length = 60.0 # Heated straight that sets the 90 degree phase (μm)
coupler_offset = 44.0 # Distance of each coupler of a hybrid from its axis (μm)
inner_level = 11.0 # Height of the two inner ports of a hybrid (μm)
outer_level = 77.0 # Height of the two outer ports of a hybrid (μm)
absorber_size = (40.0, 10.0) # Length and width of the absorber of a detector (μm)
lead_length = 15.0 # Access waveguide in front of the absorber of a detector (μm)
pad_size = 80.0 # Side of the metal pad of a detector (μm)
pad_gap = 10.0 # Distance from the absorber to the pad of a detector (μm)
def add_step(c, port, offset, side):
# Turn left when the offset is on the left of the light, and right otherwise
turn = quarter_turn if offset * side > 0 else -quarter_turn
# Add the first bend, which turns the light sideways
first = c.add_reference(pf.parametric.bend(angle=turn)).connect("P0", port)
# Add the straight that sets the total offset, since each bend moves it by a radius
run = c.add_reference(pf.parametric.straight(length=abs(offset) - 2 * bend_radius))
run.connect("P0", first["P1"])
# Add the second bend, which turns the light forward again
last = c.add_reference(pf.parametric.bend(angle=-turn)).connect("P0", run["P1"])
# Return the port at the end of the step
return last["P1"]
def add_heater(c, ref):
# Find the extent of the straight
(x0, y0), (x1, y1) = ref.bounds()
# Find the height of its axis
y = (y0 + y1) / 2
# Draw a strip on the heater layer along the straight
c.add(
"M1_heater",
pf.Rectangle((x0, y - heater_width / 2), (x1, y + heater_width / 2)),
)

make_mzm builds a Mach-Zehnder modulator: a Y branch, two arms with a heater each, and a Y branch that joins them.

def make_mzm():
# Create the parent component of the modulator
c = pf.Component("mzm")
# Add the Y branch that splits the light between the two arms
split = c.add_reference(y_branch)
# Create the straight that carries each heater
arm = pf.parametric.straight(length=arm_length)
# Compute how far each arm moves away from the axis
offset = arm_separation / 2 - y_pitch / 2
# Build the upper arm, then the lower arm
ends, arms = [], []
for port, sign in (("P2", 1), ("P1", -1)):
# Move the light away from the axis with two 90 degree bends
start = add_step(c, split[port], sign * offset, 1)
# Add the straight that carries the heater
arms.append(c.add_reference(arm).connect("P0", start))
# Move the light back toward the axis with two 90 degree bends
ends.append(add_step(c, arms[-1]["P1"], -sign * offset, 1))
# Add the Y branch that joins the arms, connecting one arm aligns the other
join = c.add_reference(y_branch).connect("P1", ends[0])
# Draw a heater strip along each arm
for ref in arms:
add_heater(c, ref)
# Expose the input and the output
c.add_port(split["P0"], "in")
c.add_port(join["P0"], "out")
# Return the finished modulator
return c
# Build the modulator and show it
mzm = make_mzm()
viewer(mzm)
Layout

make_hybrid builds a 90 degree hybrid of two couplers, one for I and one for Q. Their ports are only 4.7 um apart, so pairs of 90 degree bends move the four inputs and the four outputs to 11 um and 77 um above and below the axis. The LO input of the Q coupler has a heated straight, bias_length, that sets the 90 degree phase. The inputs, from top to bottom, are L_a, S_a, L_b, S_b, and the outputs I_p, I_n, Q_p, Q_n.

def make_hybrid():
# Create the parent component of the hybrid
c = pf.Component("hybrid")
# Place the I coupler above the axis and the Q coupler below it
ci = c.add_reference(pf.Reference(coupler, origin=(0, coupler_offset)))
cq = c.add_reference(pf.Reference(coupler, origin=(0, -coupler_offset)))
# Add the heated straight in front of the LO input of the Q coupler
bias = c.add_reference(pf.parametric.straight(length=bias_length))
bias.connect("P1", cq["P1"])
add_heater(c, bias)
# List the ports that become the four inputs and the four outputs, top to bottom
inputs = [(ci, "P1"), (ci, "P0"), (bias, "P0"), (cq, "P0")]
outputs = [(ci, "P3"), (ci, "P2"), (cq, "P3"), (cq, "P2")]
# Set the height of each of the four ports, from the top to the bottom
levels = [outer_level, inner_level, -inner_level, -outer_level]
# Spread the ports of each side: inputs face west, outputs face east
for ports, names, side in (
(inputs, ["L_a", "S_a", "L_b", "S_b"], -1),
(outputs, ["I_p", "I_n", "Q_p", "Q_n"], 1),
):
for (ref, name), label, level in zip(ports, names, levels):
# Compute how far this port must move to reach its height
offset = level - ref[name].center[1]
# Move the port with two 90 degree bends, and expose the end as a port
c.add_port(add_step(c, ref[name], offset, side), label)
# Return the finished hybrid
return c
# Build the hybrid and show it
hybrid = make_hybrid()
viewer(hybrid)
Layout

make_detector builds a detector: an access waveguide, an absorber, and a metal pad. The PDK has no photodetector, so it is a stand-in.

def make_detector():
# Create the parent component of the detector
c = pf.Component("detector")
# Add the access waveguide
lead = c.add_reference(pf.parametric.straight(length=lead_length))
# Draw the absorber after the access waveguide
length, width = absorber_size
c.add(
"Si", pf.Rectangle((lead_length, -width / 2), (lead_length + length, width / 2))
)
# Draw the metal pad after the absorber, and register it as a terminal
x_pad = lead_length + length + pad_gap
pad = pf.Rectangle((x_pad, -pad_size / 2), (x_pad + pad_size, pad_size / 2))
c.add("M2_router", pad)
c.add_terminal(pf.Terminal("M2_router", pad), "T0")
# Expose the input
c.add_port(lead["P0"], "in")
# Return the finished detector
return c
# Build the detector and show it
detector = make_detector()
viewer(detector)
Layout
Schematic

The IQ modulator and the receiver core call route_auto inside. Each is built once and placed twice, once for each polarization, so the chip itself needs only four more calls.

make_iq_modulator builds an IQ modulator: a Y branch, the I and Q modulators, and a Y branch that joins them. The Q modulator has a heated bias straight in front of it, so the one group of the four routes gets an offset. Light flows west.

# Set the distances inside the two cells
modulator_gap = 100.0 # Distance from the Y branches to the modulators (μm)
modulator_offset = 52.25 # Distance of each modulator from the axis (μm)
splitter_offset = 33.0 # Distance of each receiver splitter from the axis (μm)
hybrid_x = 400.0 # Position of the hybrid along the receiver core (μm)
turn_around = 180.0 # Rotation that makes a Y branch face the other way (degrees)
# List the bias straight as the offset of the second net, in front of the Q modulator
bias_offsets = [0.0, bias_length, 0.0, 0.0]
def make_iq_modulator():
# Create the parent component of the IQ modulator
c = pf.Component("iq_modulator")
# Make the modulator that both branches use
mzm = make_mzm()
# Place the Y branch that splits the carrier, facing west
split = c.add_reference(pf.Reference(y_branch, rotation=turn_around))
# Find where the modulators start and end along the chip
start_x = split["P1"].center[0] - modulator_gap
mod_x = start_x + mzm["in"].center[0]
end_x = mod_x - mzm["out"].center[0]
# Place the I and Q modulators as mirror images about the axis
mod_i = c.add_reference(
pf.Reference(mzm, origin=(mod_x, modulator_offset), rotation=turn_around)
)
mod_q = c.add_reference(
pf.Reference(mzm, origin=(mod_x, -modulator_offset), rotation=turn_around)
)
# Place the heated bias straight in front of the Q modulator
bias = c.add_reference(pf.parametric.straight(length=bias_length))
bias.connect("P1", mod_q["in"])
add_heater(c, bias)
# Place the Y branch that joins the two modulators
join_x = end_x - modulator_gap - y_branch["P1"].center[0]
join = c.add_reference(pf.Reference(y_branch, origin=(join_x, 0.0)))
# Route the two hops into the modulators and the two out of them, as one group
routes = pf.parametric.route_auto(
port1=[(split, "P1"), (split, "P2"), (mod_i, "out"), (mod_q, "out")],
port2=[(mod_i, "in"), (bias, "P0"), (join, "P2"), (join, "P1")],
obstacles=[split, mod_i, mod_q, bias, join], # Geometry to avoid
matching=[
pf.PathLengthMatching([], offsets=bias_offsets, tolerance=skew_budget)
],
name="iq_routes", # Name of the returned bundle
)
# Add the routes to the cell
c.add_reference(routes)
# Expose the input on the east and the output on the west
c.add_port(split["P0"], "in")
c.add_port(join["P0"], "out")
# Return the finished cell
return c
# Build the IQ modulator and show it
iq_modulator = make_iq_modulator()
viewer(iq_modulator)
Layout
Schematic

make_receiver_core builds a receiver core: the LO and signal splitters, the hybrid, and the four legs between them. The hybrid takes LO, signal, LO, signal and the splitters deliver LO, LO, signal, signal, so the two inner legs must cross: cross gives the router a crossing, and max_crossings=1 allows one per net. Each splitter sits halfway between the two ports it feeds, so its legs are equal. A crossing is about 10 um wide and a bend needs about 10 um of straight on each side, so the inner ports are at 11 um, which puts the crossing in the middle of a straight stretch and leaves every leg two corners.

def make_receiver_core():
# Create the parent component of the receiver core
c = pf.Component("receiver_core")
# Place the LO splitter above the axis and the signal splitter below it
lo = c.add_reference(pf.Reference(y_branch, origin=(0.0, splitter_offset)))
sig = c.add_reference(pf.Reference(y_branch, origin=(0.0, -splitter_offset)))
# Place the hybrid to the east of the splitters
hybrid = c.add_reference(pf.Reference(make_hybrid(), origin=(hybrid_x, 0.0)))
# Route the four legs, with one crossing, as one group
legs = pf.parametric.route_auto(
port1=[(lo, "P2"), (lo, "P1"), (sig, "P2"), (sig, "P1")],
port2=[(hybrid, "L_a"), (hybrid, "L_b"), (hybrid, "S_a"), (hybrid, "S_b")],
obstacles=[lo, sig, hybrid], # Geometry to avoid
cross=crossing, # Crossing that the router may insert
max_crossings=1, # Largest number of crossings per net
matching=[
pf.PathLengthMatching([], offsets=bias_offsets, tolerance=skew_budget)
],
name="legs_routes", # Name of the returned bundle
)
# Add the legs to the cell
c.add_reference(legs)
# Expose the two inputs and the four outputs
c.add_port(lo["P0"], "lo")
c.add_port(sig["P0"], "sig")
for name in ["I_p", "I_n", "Q_p", "Q_n"]:
c.add_port(hybrid[name], name)
# Return the finished cell
return c
# Build the receiver core and show it, with its four legs and the one crossing
receiver_core = make_receiver_core()
viewer(receiver_core)
Layout
Schematic
Netlist

The crossing is where the two inner legs swap places. Both cells matched their routes with no bend added.

The fibers are one column on the west edge, 127 um apart, the standard pitch of a fiber array: the transmitter output, the laser, and the receiver input. A Y branch splits the laser into the carrier and the LO.

  • Transmitter, above the laser: the carrier splits in two, one IQ modulator takes each polarization, and a PSR combines them next to the fiber. The modulators are mirror images about the axis of the transmitter.
  • Receiver, below the laser: a PSR splits the signal and the LO splits in two, and each core takes one copy of each. The cores are mirror images about the axis of the receiver, and the LO splitter and the PSR sit halfway between the inputs that they feed.
  • Detectors, in one column on the east edge, four for each core.

The wiring is written down once, as the dictionary nets, and ends splits one list of nets into the port1 and port2 arguments of a call.

# Set the positions of the fibers and of the laser splitter
fiber_pitch = 127.0 # Spacing of the grating couplers of the fiber array (μm)
splitter_x = 150.0 # Position of the laser splitter along the chip (μm)
# Set the positions in the transmitter
tx_axis = 330.0 # Height of the axis of the transmitter (μm)
tx_pitch = 107.25 # Distance of each modulator from the transmitter axis (μm)
tx_x = 800.0 # Position of the modulators along the chip (μm)
carrier_gap = 100.0 # Distance from the carrier splitter to the modulators (μm)
psr_gap = 140.0 # Distance from the modulators to the PSR (μm)
# Set the positions in the receiver
rx_axis = -330.0 # Height of the axis of the receiver (μm)
rx_pitch = 107.25 # Distance of each core from the receiver axis (μm)
rx_x = 600.0 # Position of the cores along the chip (μm)
rx_splitter_x = 300.0 # Position of the LO splitter and of the PSR (μm)
rx_splitter_offset = 33.0 # Distance of each of the two from the receiver axis (μm)
detector_x = 1250.0 # Position of the detectors along the chip (μm)
detector_pitch = 110.0 # Spacing of the detectors (μm)
detector_count = 8 # Number of detectors, four for each polarization
# Create the parent component of the chip
chip = pf.Component("dp_transceiver")
def place(cell, origin, rotation=0.0):
# Add a reference to the chip and return it
return chip.add_reference(pf.Reference(cell, origin=origin, rotation=rotation))
# Place the three fibers, facing east, and the splitter of the laser
tx_fiber = place(grating, (0.0, fiber_pitch))
laser = place(grating, (0.0, 0.0))
rx_fiber = place(grating, (0.0, -fiber_pitch))
split = place(y_branch, (splitter_x, 0.0))
# Place the carrier splitter, facing west, to the east of the modulators
carrier_x = tx_x + iq_modulator["in"].center[0] + carrier_gap + y_branch["P1"].center[0]
carrier = place(y_branch, (carrier_x, tx_axis), turn_around)
# Place the two IQ modulators as mirror images about the axis of the transmitter
iq_x = place(iq_modulator, (tx_x, tx_axis + tx_pitch))
iq_y = place(iq_modulator, (tx_x, tx_axis - tx_pitch))
# Place the PSR of the transmitter, to the west of the modulators
psr_x = tx_x + iq_modulator["out"].center[0] - psr_gap - y_branch["P2"].center[0]
psr_tx = place(y_branch, (psr_x, tx_axis))
# Place the LO splitter and the PSR of the receiver, one above the other
lo_split = place(y_branch, (rx_splitter_x, rx_axis + rx_splitter_offset))
psr_rx = place(y_branch, (rx_splitter_x, rx_axis - rx_splitter_offset))
# Place the two receiver cores as mirror images about the axis of the receiver
core_x = place(receiver_core, (rx_x, rx_axis + rx_pitch))
core_y = place(receiver_core, (rx_x, rx_axis - rx_pitch))
# Place the eight detectors in one column, centered on the axis of the receiver
first_detector = rx_axis + (detector_count - 1) * detector_pitch / 2
detectors = [
place(detector, (detector_x, first_detector - k * detector_pitch))
for k in range(detector_count)
]
# Collect every placed reference, which the routes must avoid
devices = [tx_fiber, laser, rx_fiber, split, carrier, iq_x, iq_y, psr_tx]
devices += [lo_split, psr_rx, core_x, core_y] + detectors
# List the nets of each routing call, as pairs of ports
nets = {
"tx": [
((carrier, "P1"), (iq_x, "in")),
((carrier, "P2"), (iq_y, "in")),
((iq_x, "out"), (psr_tx, "P2")),
((iq_y, "out"), (psr_tx, "P1")),
],
"rx_in": [
((lo_split, "P2"), (core_x, "lo")),
((lo_split, "P1"), (core_y, "lo")),
((psr_rx, "P2"), (core_x, "sig")),
((psr_rx, "P1"), (core_y, "sig")),
],
"bank": [
((core, name), (det, "in"))
for core, group in ((core_x, detectors[:4]), (core_y, detectors[4:]))
for name, det in zip(["I_p", "I_n", "Q_p", "Q_n"], group)
],
"fibers": [
((laser, "P0"), (split, "P0")),
((split, "P2"), (carrier, "P0")),
((split, "P1"), (lo_split, "P0")),
((rx_fiber, "P0"), (psr_rx, "P0")),
((psr_tx, "P0"), (tx_fiber, "P0")),
],
}
def ends(group):
# Split a list of nets into the port1 and port2 arguments of a routing call
return [first for first, _ in group], [second for _, second in group]
# Print the size of the placement
print(f"placement {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um")
viewer(chip)
placement 1435 x 1274 um
Layout
Schematic

Four nets: the carrier to each modulator, and each modulator to the PSR. The twins are the two input hops and the two output hops.

# Split the nets of the transmitter into the two port lists
tx_port1, tx_port2 = ends(nets["tx"])
# Route the transmitter with one group of twins for the inputs and one for the outputs
tx = pf.parametric.route_auto(
port1=tx_port1, # First port of every net
port2=tx_port2, # Second port of every net
obstacles=devices, # Geometry that every route must avoid
matching=[
# Match the two input hops to each other
pf.PathLengthMatching([0, 1], tolerance=skew_budget),
# Match the two output hops to each other
pf.PathLengthMatching([2, 3], tolerance=skew_budget),
],
name="tx", # Name of the returned bundle
)
# Print the matching report
group_report(tx)
# Copy the placement and add the transmitter routes to the copy
stage = chip.copy(deep=False)
stage.add_reference(tx)
# Show the chip with the transmitter routed
viewer(stage)
group 0: handled True, length [197.8, 197.8] um, residual [0.0, 0.0] um, bends added [0, 0]
group 1: handled True, length [237.8, 237.8] um, residual [0.0, 0.0] um, bends added [0, 0]
Layout
Schematic
Netlist

Both groups are handled with no bend added: the mirror-image floor plan already made the twins equal, and the groups confirm it.

Four nets: the LO to each core and the signal to each core, one group of twins for each. The cores take LO, signal, LO, signal and the splitters deliver LO, LO, signal, signal, so one LO net and one signal net cross, as inside the core.

# Split the nets of the receiver inputs into the two port lists
rx_port1, rx_port2 = ends(nets["rx_in"])
# Route the receiver inputs with one group of twins for the LO and one for the signal
rx_in = pf.parametric.route_auto(
port1=rx_port1, # First port of every net
port2=rx_port2, # Second port of every net
obstacles=devices + [tx], # The transmitter is an obstacle too
cross=crossing, # Crossing that the router may insert
max_crossings=1, # Largest number of crossings per net
matching=[
# Match the two LO nets to each other
pf.PathLengthMatching([0, 1], tolerance=skew_budget),
# Match the two signal nets to each other
pf.PathLengthMatching([2, 3], tolerance=skew_budget),
],
name="rx_in",
)
# Read the routing report to count the crossings
report = json.loads(rx_in.properties["route_auto"])
print(f"{report['total_crossings']} crossing inserted")
# Print the matching report
group_report(rx_in)
# Add the receiver input routes to the copy of the chip
stage.add_reference(rx_in)
# Show the chip with the receiver inputs routed
viewer(stage)
1 crossing inserted
group 0: handled True, length [383.0, 383.0] um, residual [0.0, 0.0] um, bends added [0, 0]
group 1: handled True, length [376.2, 383.0] um, residual [6.7, 0.0] um, bends added [0, 0]
Layout

One crossing was inserted, on the LO net to the Y core, and both groups are handled with no bend added. The signal net to the X core has two more corners than its twin, because it steps up twice around the crossing. That leaves it 6.7 um short, 0.1 ps, which is inside the skew_budget.

The eight core outputs go to the eight detectors. The outputs are at 11 um and 77 um from each axis and the detectors 110 um apart, so the routes of a bank differ in length, and the floor plan does not help. Each bank is a group of four with bends="equal": the group is rebuilt as nested loops, and every route ends with the same number of corners.

The cell routes the banks twice, to compare. skew_ps turns the worst length difference into picoseconds, with a group index of 4.2 that is assumed here and not computed.

# Set the constants that turn a length into a delay
group_index = 4.2 # Group index of the strip waveguide, assumed
light_speed = 299.792458 # Speed of light (μm/ps)
def skew_ps(lengths):
# Convert the length difference of the longest and shortest route to a delay
return (max(lengths) - min(lengths)) * group_index / light_speed
# Split the nets of the banks into the two port lists
bank_port1, bank_port2 = ends(nets["bank"])
# Create a dictionary for the routed banks
banks = {}
# Route the banks without matching, then with one group of four for each bank
for label, matching in [
("no matching", None),
(
"matched",
[
# Match the four routes of the X bank, with the same number of corners
pf.PathLengthMatching([0, 1, 2, 3], bends="equal", tolerance=skew_budget),
# Match the four routes of the Y bank in the same way
pf.PathLengthMatching([4, 5, 6, 7], bends="equal", tolerance=skew_budget),
],
),
]:
# Route the eight nets
bank = pf.parametric.route_auto(
port1=bank_port1, # First port of every net
port2=bank_port2, # Second port of every net
obstacles=devices + [tx, rx_in],
matching=matching, # Matching groups, or None for none
name="bank",
)
# Keep the routed bank
banks[label] = bank
# Measure the length of every route
lengths = [pf.route_length(ref.component) for ref in bank.references]
# Print the lengths of the two banks and the skew
print(f"{label}: X bank {[round(x) for x in lengths[:4]]} um")
print(f"{' ' * len(label)} Y bank {[round(x) for x in lengths[4:]]} um")
print(f"{' ' * len(label)} skew of the eight {skew_ps(lengths):.2f} ps")
# Copy the chip so far and add the unmatched banks to the copy
unmatched_view = stage.copy(deep=False)
unmatched_ref = unmatched_view.add_reference(banks["no matching"])
no matching: X bank [389, 345, 257, 213] um
Y bank [213, 257, 345, 389] um
skew of the eight 2.47 ps
matched: X bank [878, 878, 878, 878] um
Y bank [878, 878, 878, 878] um
skew of the eight 0.00 ps

Without matching, the routes span 176 um, 2.5 ps. With it, all eight are 878 um long and the two banks are mirror images, so the skew is zero. The price is length: the nested loops use the same shape for every route.

The unmatched banks: each route is as short as it can be.

# Show the chip with the unmatched banks
viewer(unmatched_view)
Layout

The matched banks: the X bank loops above the detectors and the Y bank below them.

# Add the matched banks to the chip so far
stage.add_reference(banks["matched"])
# Show the chip with the matched banks
viewer(stage)
Layout

The last call routes the five nets that remain, which have no length to match, around the other bundles.

# Keep the matched bank, which the fibers must avoid
bank = banks["matched"]
# Split the nets of the fibers into the two port lists
fiber_port1, fiber_port2 = ends(nets["fibers"])
# Route the fiber nets around the other bundles
fibers = pf.parametric.route_auto(
port1=fiber_port1, # First port of every net
port2=fiber_port2, # Second port of every net
obstacles=devices + [tx, rx_in, bank],
name="fibers",
)
# Add the bundle to the chip view and the four bundles to the chip
stage.add_reference(fibers)
for bundle in (tx, rx_in, bank, fibers):
chip.add_reference(bundle)
# Print the size of the finished chip
(x_min, y_min), (x_max, y_max) = chip.bounds()
print(f"die {(x_max - x_min) / 1e3:.2f} x {(y_max - y_min) / 1e3:.2f} mm")
# Show the finished chip
viewer(stage)
die 1.43 x 1.48 mm
Layout
Schematic