Skip to content

800G Coherent Chip Layout and Verification

Download notebook (.zip)

This page draws the two chips of the series from the component libraries of the transmitter page and the receiver page, and checks each of them twice: a design rule check (DRC) that the masks can be made, and a layout-versus-schematic (LVS) check that the drawn chip is wired as designed. The link page then simulates the two dies of this page as they are drawn.

  • Transmitter: a dual-polarization IQ modulator on a 20.2 x 5.05 mm LNOI400 die, checked with the LN-CORE rule deck of Luxtelligence [1].
  • Receiver: an 1.6 x 1.3 mm SOI die with two edge couplers on a fiber-array pitch, the PSR, two 90 degree hybrids, eight photodiodes wired as four balanced pairs and twelve pads, checked with a rule deck written for the technology of this series.

Both checks run in KLayout through PhotonForge: drc_check exports the die, runs a rule deck on it and reads the markers back, and lvs_check writes the schematic from the component’s own netlist, exports the layout, lets a runset extract the connectivity the shapes form and compares the two, cell by cell. In photonics the extraction cannot rely on shapes touching alone, as it does for electronic circuits: two waveguides that touch may form a coupler rather than a connection. The optical runset therefore follows the ports: each device is drawn with a pin box at each of its ports, and only the routing waveguides between devices conduct [2].

References

  1. Luxtelligence LNOI400 process design kit, design manual and KLayout rule decks, available from luxtelligence.ai/resources.
  2. Layout verification in the PhotonForge API reference.

The transmitter rule check needs the LN-CORE deck LN_CORE_lnoi400.lydrc from the Luxtelligence website in the misc folder; it is not distributed with this page. The other three rule files are: coherent_rx.drc, the receiver rule deck, photonic.lvs, the optical LVS runset for both chips, and coherent_rx_metal.lvs, the electrical LVS runset of the receiver.

from runpy import run_path
# Coherent_TX_Components.qmd defines what this page builds on. Its shared cells are generated into the
# module below; see docs/scripts/generate_notebook_modules.py.
globals().update(
{k: v for k, v in run_path("misc/coherent_tx_components.py").items() if not k.startswith("_")}
)
# Coherent_RX_Components.qmd defines what this page builds on. Its shared cells are generated into the
# module below; see docs/scripts/generate_notebook_modules.py.
globals().update(
{k: v for k, v in run_path("misc/coherent_rx_components.py").items() if not k.startswith("_")}
)
import xml.etree.ElementTree as ET
import luxtelligence_lnoi400_forge._mzm as lx_mzm
from photonforge.live_viewer import LiveViewer
# A viewer for the two chips of this page; open the printed address in a browser
viewer = LiveViewer()
# Layers the optical LVS draws its markers on before export: an outline of each device's own
# waveguide, a pin box at each port and the port name. They are free in both technologies.
outline_layer, pin_layer, label_layer = (200, 0), (201, 0), (201, 1)
LiveViewer started at http://127.0.0.1:32861. Call its 'stop()' method to free the port.

Each route is a chain of straights and bends, and each section needs a model for the link simulation. The cell below solves the effective and group index of the two routing waveguides. These are the LNOI400 ridge of the transmitter and the silicon strip of the receiver. It then makes an AnalyticWaveguideModel for each. A default model for the routing primitives gives every route drawn after it the same model, as in the quantum chip example.

Each route also gets one time stepper for the whole route. The waveguide stepper delays the field by a whole number of time steps. Each section of a route would round its own delay, so two routes of the same length could arrive a few time steps apart. For the matched photodiode routes of the receiver, that is an I/Q skew that the DSP cannot remove. The stepper of a whole route fits its S matrix to a constant and one delay instead.

# Effective and group index of the two routing waveguides at the design wavelength
ridge_modes, strip_modes = (
pf.port_modes(
spec, [f0], group_index=True, technology=t, verbose=False, show_progress=False
)
for spec, t in ((mmi["P1"].spec, tech), (tech_rx.ports["Strip_TE"], tech_rx))
)
# Waveguide model of a route, with the stepper that delays the field by the group delay
def route_model(modes, loss):
n_eff = float(np.real(modes.data.n_eff.values.ravel()[0]))
n_group = float(modes.data.n_group.values.ravel()[0])
kwargs = dict(
n_eff=n_eff, n_group=n_group, propagation_loss=loss, reference_frequency=f0
)
model = pf.AnalyticWaveguideModel(**kwargs)
model.time_stepper = pf.AnalyticWaveguideTimeStepper(**kwargs)
print(
f"route model: n_eff {n_eff:.4f}, n_group {n_group:.4f}, loss {loss * 1e4:.1f} dB/cm"
)
return model
# The routers build every path from these primitives, so each route they draw gets the model
def use_route_model(model):
for primitive in ("bend", "s_bend", "straight", "route_s_bend", "transition"):
pf.config.default_kwargs[primitive] = {"model": model}
# Propagation loss in dB/um: 0.2 dB/cm for the LN ridge, 2 dB/cm for the silicon strip
ridge_model = route_model(ridge_modes, 2e-5)
strip_model = route_model(strip_modes, 2e-4)
# Give a route one time stepper: its S matrix as a constant and one delay
def whole_route(route):
route.models["Circuit"].time_stepper = pf.SMatrixTimeStepper(
min_poles=0, max_poles=0, feedthrough=True
)
return route
route model: n_eff 1.7915, n_group 2.1898, loss 0.2 dB/cm
route model: n_eff 2.4389, n_group 4.1777, loss 2.0 dB/cm

The transmitter follows the light from left to right:

  • The input edge coupler on the left facet and a 1x2 MMI that splits the laser light between the two polarization branches.
  • Two nested IQ modulators, 2.3 mm apart. Each is a parent 1x2 MMI, two 10 mm travelling-wave MZMs from the PDK (mzm_unbalanced_high_speed, with a bias heater in each arm), a 700 um heater on each MZM output for the 90 degree IQ bias, and a 1x2 MMI used as the combiner.
  • The rotator-combiner of the transmitter page, placed next to the output facet so the two polarizations share only a short two-mode waveguide, and the output edge coupler.

The layout is mirror-symmetric about the centre of each IQ modulator, so the I and Q paths have the same length by construction. The X and Y paths are mirror images about the chip axis up to the two inputs of the rotator-combiner, which are 12.4 um apart: the Y path is that much shorter, a skew of 0.09 ps, on top of the 0.20 ps the rotator-combiner adds itself. The receiver equalizer removes both.

The MMIs and the edge couplers are the cells of the transmitter page, with their FDTD models. The rotator-combiner is the cell with its EME model. The output coupler carries both polarizations. It therefore gets two modes at each port and the two-mode model of the transmitter page. The chip also gets what the link simulation needs:

  • Modulators. Each MZM gets the model of the transmitter page and an electrical port E0 on its signal pad. The four ports become the drive ports of the chip, XI, XQ, YI and YQ.
  • Bias heaters. The heater after each MZM gets a waveguide model. The heater of the Q arm has an index change Δn=λ/(4L)\Delta n = \lambda / (4 L) in its model. This change delays the light by a quarter period, the 90 degree bias between I and Q.
  • Routes. Each route gets one time stepper for the whole route.
  • Chip model. A CircuitModel makes the chip a circuit of its devices and routes.

The PDK’s heated MZM looks up the terminals of its heater by the names T0 and T1, while the heater cell of PDK version 2.1.0 calls them htr_l and htr_r. The cell below adds the expected names to the heater before the MZM is built.

pf.config.default_technology = tech
# Every route of the transmitter is an LN ridge
use_route_model(ridge_model)
_heated_straight = lx_mzm.heated_straight
# The PDK heater with its terminals also available under the names the heated MZM expects
def heated_straight_t0_t1(*args, **kwargs):
c = _heated_straight(*args, **kwargs)
if "htr_l" in c.terminals:
c.add_terminal(c.terminals["htr_l"], "T0")
c.add_terminal(c.terminals["htr_r"], "T1")
return c
lx_mzm.heated_straight = heated_straight_t0_t1
bend_radius = 100.0 # LN bends, well above the design manual's typical minimum
x_left = -10050.0 # Inner edge of the usable area of a 20.2 mm die
# IQ modulators at +-y_iq; their two MZMs at +-y_child around them
y_iq, y_child = 1150.0, 300.0
# Waveguide model of a bias heater: the LN ridge with an index change for its phase
def heater_model(dn, length):
ridge = ridge_model.parametric_kwargs
kwargs = dict(
n_group=ridge["n_group"],
propagation_loss=ridge["propagation_loss"],
reference_frequency=f0,
)
model = pf.AnalyticWaveguideModel(n_eff=ridge["n_eff"] + dn, **kwargs)
model.time_stepper = pf.AnalyticWaveguideTimeStepper(
n_eff=ridge["n_eff"] + dn, length=length, **kwargs
)
return model
@pf.parametric_component(name_prefix="DPIQ")
def dpiq(*, modulation_length=10000.0, routed=True):
c = pf.Component()
# The routed chip draws a waveguide for each connection; its schematic, routed=False, declares
# the same connection as a virtual connection between the two device ports
def link(ref1, port1, ref2, port2):
if routed:
c.add(
whole_route(
pf.parametric.route(
port1=ref1[port1], port2=ref2[port2], radius=bend_radius
)
)
)
else:
c.add_virtual_connection(ref1, port1, ref2, port2)
# Input facet on the left die edge, then the 1x2 MMI that splits the laser between X and Y
# The edge coupler and the MMI of the transmitter page, with their FDTD models
ec_in = c.add_reference(ec)
ec_in.translate((x_left, 0))
split = c.add_reference(mmi).connect("P0", ec_in["P1"])
# One PDK modulator cell for all four tributaries: 10 mm T-rail electrode, equal arms (the bias
# comes from the heaters, not from a path difference), heaters in both arms
child = lxc.mzm_unbalanced_high_speed(
modulation_length=modulation_length, length_imbalance=0.0, with_heater=True
)
# Drive port on the signal pad, and the time-domain model of the transmitter page
child.add_port(
pf.Port(
child.terminals["S_in"].center(),
0,
pf.virtual_port_spec(classification="electrical"),
),
"E0",
)
child.add_model(modulator_model, "Time domain")
# Bias heaters of the PDK, 700 um long: no index change for I, a quarter period for Q
heater_length = 700.0
heaters = {
arm: lxc.heated_straight(
length=heater_length, model=heater_model(dn, heater_length)
)
for arm, dn in (("I", 0.0), ("Q", lda0 / (4 * heater_length)))
}
x_mzm = x_left + 1400.0 # Input of the four modulators, 1.4 mm from the facet
combiners = {}
for pol, yc in (("X", y_iq), ("Y", -y_iq)):
# Parent MMI of this IQ modulator, 600 um ahead of the modulators, routed from the splitter
parent = c.add_reference(mmi)
parent.translate((x_mzm - 600.0, yc))
link(split, "P1" if pol == "X" else "P2", parent, "P0")
arm_heaters = []
for arm, dy in (("I", y_child), ("Q", -y_child)):
# The I modulator above the IQ centre and the Q modulator below it, at the same x,
# so the two paths from the parent MMI are mirror images and equal in length
m = c.add_reference(child)
m.translate((x_mzm - m["P0"].center[0], yc + dy - m["P0"].center[1]))
link(parent, "P1" if arm == "I" else "P2", m, "P0")
# The drive port of this modulator is a port of the chip
c.add_port(m["E0"], pol + arm)
# A 700 um heater after each modulator sets the 90 degree offset between I and Q
h = c.add_reference(heaters[arm])
h.translate((m["P1"].center[0] + 200.0, yc + dy))
link(m, "P1", h, "P0")
arm_heaters.append(h)
# The combiner is a 1x2 MMI used backwards: turned by 180 degrees, 700 um after the heaters
comb = c.add_reference(mmi)
comb.rotate(180)
comb.translate(
(
arm_heaters[0]["P1"].center[0] + 700.0 - comb["P0"].center[0],
yc - comb["P0"].center[1],
)
)
# After the rotation the upper combiner input is P1 or P2 depending on the orientation
upper, lower = (
("P1", "P2")
if comb["P1"].center[1] > comb["P2"].center[1]
else ("P2", "P1")
)
for h, port in zip(arm_heaters, (upper, lower)):
link(h, "P1", comb, port)
combiners[pol] = comb
# Rotator-combiner next to the output facet, so TE0 and TM0 share only a short two-mode
# waveguide: turned by 180 degrees, its two-mode port P0 faces the output coupler
ec_length = ec["P1"].center[0] - ec["P0"].center[0]
rc = c.add_reference(prc_cell)
rc.rotate(180)
# Turned by 180 degrees, the rotated arm P2 would sit above the straight arm P1, and the route from
# the Y modulator below would cross the one from the X modulator above. Mirroring the device about
# its axis puts P2 below P1; the device works the same either way.
rc.mirror((1, 0), (0, 0))
rc.translate((-x_left - ec_length - 50.0 - rc["P0"].center[0], -rc["P0"].center[1]))
tail = c.add_reference(
pf.parametric.straight(port_spec=rc["P0"].spec, length=50.0)
).connect("P0", rc["P0"])
# The output coupler carries both polarizations, so both its ports are declared with two modes
ec_two_mode = ec.copy()
ec_two_mode.name = "edge_coupler_two_mode"
p0, p1 = ec.ports["P0"], ec.ports["P1"]
facet_spec = p0.spec.copy()
facet_spec.num_modes = 2
ec_two_mode.add_port(pf.Port(p0.center, p0.input_direction, facet_spec), "P0")
ec_two_mode.add_port(pf.Port(p1.center, p1.input_direction, rc["P0"].spec), "P1")
ec_two_mode.add_model(ec_two_mode_model, "FDTD data")
ec_out = c.add_reference(ec_two_mode).connect("P1", tail["P1"])
# X enters the rotator-combiner straight (stays TE0), Y through the arm that turns it into TM0
link(combiners["X"], "P0", rc, "P1")
link(combiners["Y"], "P0", rc, "P2")
# Chip ports: the two fiber facets
c.add_port(ec_in["P0"], "IN")
c.add_port(ec_out["P0"], "OUT")
# The chip is a circuit of its devices and routes
c.add_model(pf.CircuitModel(), "Circuit")
return c
tx_chip = dpiq()
tx_die = pf.Component("DPIQ_DIE")
tx_die.add_reference(lxc.chip_frame(x_size=20200, y_size=5050))
tx_die.add_reference(tx_chip)
viewer(tx_die)
Layout
Schematic

The LN-CORE deck checks the ridge and slab etches, the metal layers and the chip frame against the Luxtelligence design rules. It is distributed as a KLayout macro that reads the layout open in the KLayout editor, so the cell below takes its rule script out of the macro and adds the two lines a batch run needs: the layout to read and the report to write, under the names drc_check passes.

# The LN-CORE macro holds its rule script as text; point it at the exported layout and the report
deck = ET.parse("misc/LN_CORE_lnoi400.lydrc").getroot().find("text").text
deck = deck.replace(
'report("LN-CORE lnoi400 DRC")',
'source($gdsfile)\nreport("LN-CORE lnoi400 DRC", $resultsfile)',
)
with open("misc/ln_core_batch.drc", "w", encoding="utf-8") as f:
f.write(deck)
# drc_check exports the die, runs the deck in KLayout and reads the markers back
tx_drc = pf.drc_check(tx_die, "misc/ln_core_batch.drc", show_progress=False)
print(f"transmitter DRC clean: {tx_drc.is_clean}")
print(tx_drc)
transmitter DRC clean: True
DRC results summary
--------------------------------
Total violations: 0
Violations by category:
CHIP_OUTER size violation: 0
CHIP_OUTER not centered at (0,0): 0
CHIP_OUTER - CHIP_INNER enclosure distance violation: 0
RIB min. feature size violation (0.25um): 0
RIB min. gap violation (0.3um): 0
SLAB min. feature size violation (0.25um): 0
SLAB_NEG min. gap violation (0.25um): 0
RIB_MARKERS min. feature size violation (0.25um): 0
RIB_MARKERS min. gap violation (0.3um): 0
RIB_MARKERS - RIDGE separation (15.0um): 0
M1 min. feature size violation (0.8um): 0
M1 min. gap violation (1.0um): 0
HT min. feature size violation (0.8um): 0
HT min. gap violation (1.0um): 0
RIDGE outside CHIP_INNER: 0
RIB_MARKERS outside CHIP_INNER: 0
M1 outside CHIP_INNER: 0
HT outside CHIP_INNER: 0

The schematic of the transmitter is the design intent: which device port connects to which. dpiq(routed=False) builds exactly that, the same devices at the same places with a virtual connection for each route, and lvs_check writes it as a SPICE netlist with one subcircuit per cell. The layout side is extracted by the runset photonic.lvs from markers that layout_pins draws on the exported cells: an outline over each device’s own waveguide, so that it cannot join its own ports, a pin box at each port and the port name. The routes are the only waveguides that conduct, so two device ports end up on one net only if a route really joins them.

The optical check compares waveguide connections only: terminals, and ports with no waveguide on the core layer, such as the fiber facets on the slab and the modulators’ microwave inputs, are left out of both netlists. The pin boxes are 20 nm long, shorter than the shortest straight on either chip, so the two pins of a short section never touch each other.

# The design intent: the same devices, joined by virtual connections instead of routes. It carries
# the chip's name, so the comparison pairs the two top cells.
tx_schematic = dpiq(routed=False)
tx_schematic.name = tx_chip.name
# Markers for the runset, drawn on every exported cell according to its role in the hierarchy
tx_pins = pf.verification.layout_pins(
tx_chip,
core_layer=tech.layers["LN_RIDGE"].layer,
outline_layer=outline_layer,
pin_layer=pin_layer,
label_layer=label_layer,
pin_length=0.02,
)
# The optical check compares ports only: terminals, and ports with no waveguide on the core layer
# (the fiber facets, the electrical inputs), are left out of both netlists
def optical_only(pins, core):
def mark(cell):
pins(cell)
for name in list(cell.terminals):
cell.remove_terminal(name)
for name, port in list(cell.ports.items()):
if not isinstance(port, pf.Port) or all(
layer != core for *_, layer in port.spec.path_profiles_list()
):
cell.remove_port(name)
return mark
# The runset reads the four layers from these variables, as "layer/datatype"
optical_layers = lambda core: {
"core": f"{core[0]}/{core[1]}",
"outline": f"{outline_layer[0]}/{outline_layer[1]}",
"pin": f"{pin_layer[0]}/{pin_layer[1]}",
"label": f"{label_layer[0]}/{label_layer[1]}",
}
tx_lvs = pf.lvs_check(
tx_chip,
"misc/photonic.lvs",
schematic=tx_schematic,
pre_export_function=optical_only(tx_pins, tech.layers["LN_RIDGE"].layer),
args=optical_layers(tech.layers["LN_RIDGE"].layer),
show_progress=False,
)
print(tx_lvs)
LVS verdict: match
Circuits: 20 compared: 20 match
Exit code: 0

The transmitter matches its schematic. The first run of this check did not: it reported the two inputs of the rotator-combiner, P1 and P2, on one net. Turned by 180 degrees to face the output facet, the rotator-combiner had its rotated arm P2 above its straight arm P1, so the route from the Y modulator below had to climb past the route from the X modulator above, and the two waveguides crossed. The design rule check could not see it, because two overlapping ridges merge into one shape with no width or gap to violate. The fix is the mirror in dpiq, which puts P2 below P1.

The receiver die has its two fiber inputs on the left facet, 635 um apart, a pitch of five channels of a 127 um fiber array. The devices are placed by hand and the waveguides between them are drawn by route_auto, the automatic router, which finds a path around everything already on the die and never lets two routes cross.

  • Signal: the SWG edge coupler, a short transition to the PSR input, and the PSR. Its two outputs reach the two hybrids, which are turned by 180 degrees so their signal inputs face the PSR.
  • LO: the second edge coupler and the Y splitter between the hybrids, which feeds both LO inputs from the right. The splitter’s input faces away from the fiber, so the router takes the LO along the bottom of the die and around the right side of the hybrids.
  • Photodiodes: the I outputs of each hybrid leave downward and the Q outputs upward, 0.9 um apart. The photodiodes stand in one column, 60 um apart in each pair. One route_auto call draws all eight routes: the router separates each pair with short S bends where it leaves the hybrid, so the two waveguides do not couple, and its matching argument, a PathLengthMatching group, adds meanders until all eight routes have the same length, so the four tributaries reach their photodiodes at the same time.
  • Wiring: the two photodiodes of each balanced pair are in series between a positive and a negative bias line, and their middle node is the output, so the output current is the difference of the two photocurrents. With the photodiode nearer the hybrid as the positive one, the I output is Ip−InI_p - I_n and the Q output is Qn−QpQ_n - Q_p, as the receiver circuit of the link page takes them. Each pair has three pads, on the top edge for X and on the bottom edge for Y.

The order of the routing calls matters. The LO goes first, with only the devices at its own ends as obstacles: the Y splitter is 2 um long, and with the whole die as an obstacle its input port would be walled in. Each later call then avoids the routes already drawn.

The die is built by one function with a routed flag. With routed=False it places the same devices and joins them with virtual connections instead of routes: that version is the design intent, and the LVS check below compares the routed die with it.

pf.config.default_technology = tech_rx
strip = tech_rx.ports["Strip_TE"]
radius = 5.0 # Silicon strip bends (um)
y_hybrid = 300.0 # Hybrids at +-y_hybrid
x_pd = 800.0 # Optical input of every photodiode
pad, pad_open = 80.0, 70.0 # Pad metal and its passivation opening (um)
y_pad = 570.0 # Pad rows at +-y_pad
w_line = 1.0 # Bias line width (um)
# Router settings: a grid of one bend radius keeps parallel routes a radius apart
router = dict(radius=radius, grid_size=radius, show_progress=False)
# Every route of the receiver is a silicon strip
use_route_model(strip_model)
# The receiver die; the photodiode bandwidths and the noise seed only change the models
def coherent_rx_die(routed=True, f_pd=67e9, f_tia=80e9, seed=0):
die = pf.Component("COHERENT_RX_DIE")
# Each group of nets is one route_auto call on the routed die, or virtual connections on the schematic
def link(port1, port2, **kwargs):
if routed:
return die.add_reference(
whole_route(
pf.parametric.route_auto(
port1=port1, port2=port2, **(router | kwargs)
)
)
)
for (ref1, name1), (ref2, name2) in zip(port1, port2):
die.add_virtual_connection(ref1, name1, ref2, name2)
# Signal: edge coupler, transition to the PSR input, PSR, and a transition from its wide arm to the strip
ec_sig = die.add_reference(ec_swg)
to_psr = pf.parametric.transition(
port_spec1=ec_swg.ports["P1"].spec,
port_spec2=psr_cell.ports["P0"].spec,
length=10,
)
t_sig = die.add_reference(to_psr).connect("P0", ec_sig["P1"])
psr_ref = die.add_reference(psr_cell).connect("P0", t_sig["P1"])
from_psr = pf.parametric.transition(
port_spec1=psr_cell.ports["P1"].spec, port_spec2=strip, length=10
)
t_x = die.add_reference(from_psr).connect("P0", psr_ref["P1"])
# Hybrids turned by 180 degrees: signal input on the left, LO input on the right
hybrids = {}
for pol, sign in (("X", 1), ("Y", -1)):
h = die.add_reference(hybrid)
h.rotate(180)
h.translate((1000 - h["S"].center[0], sign * y_hybrid - h["S"].center[1]))
hybrids[pol] = h
# LO: edge coupler at the fiber-array pitch, single-mode transition, and the splitter between the hybrids
ec_lo = die.add_reference(ec_swg)
ec_lo.translate((0, -635))
to_te = pf.parametric.transition(
port_spec1=ec_swg.ports["P1"].spec, port_spec2=strip, length=2
)
t_lo = die.add_reference(to_te).connect("P0", ec_lo["P1"])
lo_split = die.add_reference(y_splitter)
lo_split.rotate(180)
lo_split.translate((1150 - lo_split["P0"].center[0], -lo_split["P0"].center[1]))
upper, lower = sorted(("P1", "P2"), key=lambda p: -lo_split[p].center[1])
# Photodiodes in one column, turned so their input faces the hybrids. In each pair the output
# nearer the hybrid is the positive diode: I = Ip - In and Q = Qn - Qp.
# pair name: (hybrid, positive port, negative port, y of positive, y of negative)
pd_pairs = {}
for pol in ("X", "Y"):
h = hybrids[pol]
# I ports leave downward, Q ports upward
y_i, y_q = h["Ip"].center[1], h["Qp"].center[1]
pd_pairs[f"{pol}I"] = (h, "Ip", "In", y_i - 50, y_i - 110)
pd_pairs[f"{pol}Q"] = (h, "Qn", "Qp", y_q + 110, y_q + 50)
pd_refs = {}
for name, (h, pos, neg, y_pos, y_neg) in pd_pairs.items():
for role, y, port in (("pos", y_pos, pos), ("neg", y_neg, neg)):
# Each photodiode is its own cell, with its own noise seed
r = die.add_reference(
ge_photodiode(f_pd=f_pd, f_tia=f_tia, seed=8 * seed + len(pd_refs))
)
r.rotate(180)
r.translate((x_pd - r["P0"].center[0], y - r["P0"].center[1]))
pd_refs[name, role] = r
# The output of the front end is a port of the die, named after its hybrid output
die.add_port(r["E0"], name[0] + port)
# 1) LO, with only its own end devices as obstacles: fiber to splitter, then splitter to hybrids
lo_trunk = link([(t_lo, "P1")], [(lo_split, "P0")], grid_size=2 * radius)
lo_branches = link(
[(lo_split, upper), (lo_split, lower)],
[(hybrids["X"], "LO"), (hybrids["Y"], "LO")],
)
drawn = [r for r in (lo_trunk, lo_branches) if r is not None]
# 2) The eight photodiode routes in one call, matched in length, around the LO routes
port1 = [(h, port) for h, pos, neg, *_ in pd_pairs.values() for port in (pos, neg)]
port2 = [
(pd_refs[name, role], "P0") for name in pd_pairs for role in ("pos", "neg")
]
drawn.append(
link(port1, port2, obstacles=drawn, matching=[pf.PathLengthMatching()])
)
# 3) Signal from the PSR to the two hybrids, around everything drawn so far
link(
[(t_x, "P1"), (psr_ref, "P2")],
[(hybrids["X"], "S"), (hybrids["Y"], "S")],
obstacles=[r for r in drawn if r],
)
# Balanced-pair wiring on M1, and the pads. The photodiode terminals are its two M1 strips.
x_end = x_pd - 10 - 14.2 # far end of every photodiode, where its terminals end
for pol, edge in (("X", 1), ("Y", -1)):
lines = []
for name in (f"{pol}Q", f"{pol}I"):
h, pos, neg, y_pos, y_neg = pd_pairs[name]
# The positive photodiode is above: its anode strip faces the negative one's cathode strip
die.add(
"M1", pf.Rectangle((x_end - 4, y_neg + 1.2), (x_end + 2, y_pos - 1.2))
)
# Each line carries the photodiode terminals its pad must reach, which the electrical LVS checks
p_ref, n_ref = pd_refs[name, "pos"], pd_refs[name, "neg"]
lines += [
(f"{name}_Vp", y_pos + 1.7, x_end + 2, w_line, [(p_ref, "CATHODE")]),
(
f"{name}_OUT",
(y_pos + y_neg) / 2,
x_end - 4,
2.0,
[(p_ref, "ANODE"), (n_ref, "CATHODE")],
),
(f"{name}_Vn", y_neg - 1.7, x_end + 2, w_line, [(n_ref, "ANODE")]),
]
# The line nearest the pad row turns toward it last, so no two lines cross
lines.sort(key=lambda line: -edge * line[1])
for k, (net, y, x_start, width, members) in enumerate(lines):
x_turn = x_pd - 60 - 100 * k
y_row = edge * y_pad
die.add(
"M1",
pf.Path((x_start, y), width)
.segment((x_turn, y))
.segment((x_turn, y_row)),
)
pad_shape = pf.Rectangle(
(x_turn - pad / 2, y_row - pad / 2), (x_turn + pad / 2, y_row + pad / 2)
)
die.add("M1", pad_shape)
die.add(
"Pad Open",
pf.Rectangle(
(x_turn - pad_open / 2, y_row - pad_open / 2),
(x_turn + pad_open / 2, y_row + pad_open / 2),
),
)
die.add_terminal(pf.Terminal("M1", pad_shape), net)
# The intended net: this pad and the photodiode terminals of its line
die.add_virtual_net(members + [net])
die.add("FloorPlan", pf.Rectangle((0, -660), (1300, 660)))
die.add_port(ec_sig["P0"], "SIG")
die.add_port(ec_lo["P0"], "LO")
# The die is a circuit of its devices and routes
die.add_model(pf.CircuitModel(), "Circuit")
return die
rx_die = coherent_rx_die()
viewer(rx_die)
Layout
# The photodiode bundle, the route with sixteen ports (two per net), reports the lengths it matched
photodiode_bundle = next(
r.component for r in rx_die.references if len(r.component.ports) == 16
)
report = json.loads(photodiode_bundle.properties.path_length_matching)[0]
print(
f"photodiode routes: {min(report['lengths']):.3f} to {max(report['lengths']):.3f} um"
)
photodiode routes: 329.366 to 329.368 um

The receiver rule deck, coherent_rx.drc, is written for the technology of this series. It checks the minimum widths and spaces of silicon, slab, germanium, the implants and the metal, that germanium and polysilicon sit on silicon, that every contact lands on full-thickness silicon and a contact implant under M1, that the pad openings sit inside their pads, and that everything lies inside the chip area. The smallest silicon features are the 100 nm bridges of the edge coupler. The deck reads the layout and writes its report under the names drc_check passes, $gdsfile and $resultsfile.

rx_drc = pf.drc_check(rx_die, "misc/coherent_rx.drc", show_progress=False)
print(f"receiver DRC clean: {rx_drc.is_clean}")
print(rx_drc)
receiver DRC clean: True
DRC results summary
--------------------------------
Total violations: 0
Violations by category:
Si.W.1: 0
Si.S.1: 0
Slab.W.1: 0
Slab.S.1: 0
Ge.W.1: 0
Ge.E.1: 0
Poly.E.1: 0
Imp.W.1: 0
Imp.W.2: 0
Imp.X.1: 0
Ct.E.1: 0
Ct.E.2: 0
Ct.E.3: 0
Ct.E.4: 0
Ct.E.5: 0
Ht.W.1: 0
Via.E.1: 0
Via.E.2: 0
M1.W.1: 0
M1.S.1: 0
Pad.E.1: 0
Pad.E.2: 0
Pad.W.1: 0
FP.1: 0

The receiver gets two checks. The optical one uses the same runset as the transmitter, on the silicon core layer, against coherent_rx_die(routed=False) as the schematic: the same devices with a virtual connection for every net the routes are meant to form. The schematic is built separately rather than taken from the routed die itself, because a route_auto call that draws several nets is one component, and a schematic written from the routed die would join all of its ports into a single net. The unused second input of each hybrid, X, is a pin of the hybrid that nothing reaches, in the layout and in the schematic alike.

The electrical check compares the metal with the nets declared while the wiring was drawn: each add_virtual_net call names a pad and the photodiode terminals its line must reach. The runset coherent_rx_metal.lvs extracts the nets that M1 and the tungsten plugs form; the silicon and the germanium do not conduct in it, so each photodiode shows as two separate pins, and a trace that bridged a photodiode would merge two declared nets into one. For this check the terminal names are written as labels on the metal and the optical ports are left out, so both netlists hold the electrical side only.

# Optical: the runset on the silicon core, against the die with virtual connections
rx_schematic = coherent_rx_die(routed=False)
rx_pins = pf.verification.layout_pins(
rx_die,
core_layer=tech_rx.layers["Si"].layer,
outline_layer=outline_layer,
pin_layer=pin_layer,
label_layer=label_layer,
pin_length=0.02,
)
rx_optical = pf.lvs_check(
rx_die,
"misc/photonic.lvs",
schematic=rx_schematic,
pre_export_function=optical_only(rx_pins, tech_rx.layers["Si"].layer),
args=optical_layers(tech_rx.layers["Si"].layer),
show_progress=False,
)
print(rx_optical)
# Electrical: the terminal names become labels on M1, and the optical ports are removed, in the
# copy of every cell written out for the check
def electrical_only(component):
for name, terminal in component.terminals.items():
component.add("M1", pf.Label(name, terminal.center()))
for name in list(component.ports):
component.remove_port(name)
rx_electrical = pf.lvs_check(
rx_die,
"misc/coherent_rx_metal.lvs",
pre_export_function=electrical_only,
show_progress=False,
)
print(rx_electrical)
LVS verdict: match
Circuits: 19 compared: 19 match
Exit code: 0
LVS verdict: match
Circuits: 9 compared: 9 match
Exit code: 0

LVS is only useful if it fails when the layout is wrong. The cell below builds the receiver again and removes the route from the LO edge coupler to the Y splitter, as a forgotten route would leave it, then checks it against the same schematic.

broken = coherent_rx_die()
# The LO trunk is the only route that carries a single net, so its component has two ports
trunk = next(
r
for r in broken.references
if (r.component.parametric_function or "").endswith("route_auto")
and len(r.component.ports) == 2
)
broken.remove(trunk)
broken_pins = pf.verification.layout_pins(
broken,
core_layer=tech_rx.layers["Si"].layer,
outline_layer=outline_layer,
pin_layer=pin_layer,
label_layer=label_layer,
pin_length=0.02,
)
broken_lvs = pf.lvs_check(
broken,
"misc/photonic.lvs",
schematic=rx_schematic,
pre_export_function=optical_only(broken_pins, tech_rx.layers["Si"].layer),
args=optical_layers(tech_rx.layers["Si"].layer),
show_progress=False,
)
print(broken_lvs)
LVS verdict: mismatch
Circuits: 19 compared: 18 match, 1 nomatch (COHERENT_RX_DIE)
Mismatches in COHERENT_RX_DIE:
net $21 <-> (none): mismatch
layout: transition__OXWXCGSMU5IKMI43NO7JGVFEIQTNVDNOALRY6DIXTTCIBFTTL5DA $6 P1
net $24 <-> N19: mismatch
layout: Y_SPLITTER $12 P0
schematic: TRANSITION__OXWXCGSMU5IKMI43NO7JGVFEIQTNVDNOALRY6DIXTTCIBFTTL5DA 7 P1, Y_SPLITTER 8 P0
subcircuit (none) <-> 7: mismatch (schematic: TRANSITION__OXWXCGSMU5IKMI43NO7JGVFEIQTNVDNOALRY6DIXTTCIBFTTL5DA at (81.213, -635))
subcircuit $6 <-> (none): mismatch (layout: transition__OXWXCGSMU5IKMI43NO7JGVFEIQTNVDNOALRY6DIXTTCIBFTTL5DA at (81.213, -635))
Messages:
error in COHERENT_RX_DIE: Net $24 is not matching any net from reference netlist
error in COHERENT_RX_DIE: Net $21 is not matching any net from reference netlist
info in COHERENT_RX_DIE: Connecting nets $24 and $21 is making a better match to net N19 from reference netlist (fuzziness 0 nodes)
Exit code: 0

The verdict turns into a mismatch, and the report names the net that the missing route was meant to form: the transition after the LO edge coupler and the input of the Y splitter.

Both chips are drawn, pass their design rules and match their schematics. Each device and each route carries a model. The link page simulates the two dies together over 80 km of fiber.