Automated Routing of a Dual Polarization Coherent Transceiver

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
- Doerr, Christopher R. “Silicon photonic integration in telecommunications.” Frontiers in Physics 2015 3, 37, doi: 10.3389/fphy.2015.00037.
- 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.
- Chrostowski, Lukas, et al. “Silicon photonic circuit design using rapid prototyping foundry process design kits.” IEEE Journal of Selected Topics in Quantum Electronics 2019 25 (5), 8201326, doi: 10.1109/JSTQE.2019.2917501.
Setting up
Section titled “Setting up”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 reportsimport json
# Import PhotonForge and the SiEPIC technology moduleimport photonforge as pfimport siepic_forge as siepic
# Use the SiEPIC OpenEBL technology for every componentpf.config.default_technology = siepic.ebeam()
# Set the default arguments inherited by every parametric callpf.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 viewspf.config.svg_labels = False
# Start the live viewer that shows every layout belowviewer = pf.live_viewer.LiveViewer()LiveViewer started at http://127.0.0.1:42729. Call its 'stop()' method to free the port.Routing and matching arguments
Section titled “Routing and matching arguments”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 equalizesoffset + 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. Theskew_budgetis 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 callskew_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_autopf.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']}" )The devices
Section titled “The devices”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 PDKy_branch = siepic.component("ebeam_y_1550")
# Load the 2 by 2 coupler of the PDKcoupler = siepic.component("ebeam_bdc_te1550")
# Load the waveguide crossing of the PDKcrossing = siepic.component("ebeam_crossing4")
# Load the grating coupler of the PDKgrating = siepic.component("ebeam_gc_te1550")
# Measure the spacing of the two output ports of the Y branchy_pitch = y_branch["P2"].center[1] - y_branch["P1"].center[1]
# Set the sizes that describe the devicesarm_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 itmzm = make_mzm()viewer(mzm)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 ithybrid = make_hybrid()viewer(hybrid)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 itdetector = make_detector()viewer(detector)Cells that route themselves
Section titled “Cells that route themselves”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 cellsmodulator_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 modulatorbias_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 itiq_modulator = make_iq_modulator()viewer(iq_modulator)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 crossingreceiver_core = make_receiver_core()viewer(receiver_core)The crossing is where the two inner legs swap places. Both cells matched their routes with no bend added.
Placing the chip
Section titled “Placing the chip”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 splitterfiber_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 transmittertx_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 receiverrx_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 chipchip = 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 lasertx_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 modulatorscarrier_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 transmitteriq_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 modulatorspsr_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 otherlo_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 receivercore_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 receiverfirst_detector = rx_axis + (detector_count - 1) * detector_pitch / 2detectors = [ place(detector, (detector_x, first_detector - k * detector_pitch)) for k in range(detector_count)]
# Collect every placed reference, which the routes must avoiddevices = [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 portsnets = { "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 placementprint(f"placement {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um")viewer(chip)placement 1435 x 1274 umRouting the transmitter
Section titled “Routing the transmitter”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 liststx_port1, tx_port2 = ends(nets["tx"])
# Route the transmitter with one group of twins for the inputs and one for the outputstx = 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 reportgroup_report(tx)
# Copy the placement and add the transmitter routes to the copystage = chip.copy(deep=False)stage.add_reference(tx)
# Show the chip with the transmitter routedviewer(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]Both groups are handled with no bend added: the mirror-image floor plan already made the twins equal, and the groups confirm it.
Routing the receiver inputs
Section titled “Routing the receiver inputs”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 listsrx_port1, rx_port2 = ends(nets["rx_in"])
# Route the receiver inputs with one group of twins for the LO and one for the signalrx_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 crossingsreport = json.loads(rx_in.properties["route_auto"])print(f"{report['total_crossings']} crossing inserted")
# Print the matching reportgroup_report(rx_in)
# Add the receiver input routes to the copy of the chipstage.add_reference(rx_in)
# Show the chip with the receiver inputs routedviewer(stage)1 crossing insertedgroup 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]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.
Routing the detector banks
Section titled “Routing the detector banks”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 delaygroup_index = 4.2 # Group index of the strip waveguide, assumedlight_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 listsbank_port1, bank_port2 = ends(nets["bank"])
# Create a dictionary for the routed banksbanks = {}
# Route the banks without matching, then with one group of four for each bankfor 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 copyunmatched_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 psmatched: X bank [878, 878, 878, 878] um Y bank [878, 878, 878, 878] um skew of the eight 0.00 psWithout 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 banksviewer(unmatched_view)The matched banks: the X bank loops above the detectors and the Y bank below them.
# Add the matched banks to the chip so farstage.add_reference(banks["matched"])
# Show the chip with the matched banksviewer(stage)Routing the fibers
Section titled “Routing the fibers”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 avoidbank = banks["matched"]
# Split the nets of the fibers into the two port listsfiber_port1, fiber_port2 = ends(nets["fibers"])
# Route the fiber nets around the other bundlesfibers = 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 chipstage.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 chipviewer(stage)die 1.43 x 1.48 mm