Chip Layout

a054d91fbc354ed495f66f7d90889647

The chip the series arrives at, with the part this notebook works on marked:

b015bb1c777d40359f3d6b6e8c209703

The component library left nine cells that each answer s_matrix on their own. This notebook places them: a transmit bank of eight modulators on one bus, a receive bank of eight coupled-ring filters on another, edge couplers at the facet, and the metal that takes every heater and junction terminal out to its own bond pad.

Two constraints shape the result.

The eight rings cannot be identical. The heater tunes at about 9 GHz per kelvin, measured in Component Library, while the channel grid spans 1400 GHz, so reaching a channel by heat alone would take over a hundred kelvin. The channel spacing has to be built into the geometry instead, and this notebook computes the length each ring needs.

The bond pads set the die size. A ring is 10 um across, a pad is 50 um, and every ring needs at least two pads, so the finished die is mostly metal.

The notebook ends by writing the mask and running design rule checks on it.

[1]:
%%capture

# Run the component notebook, which brings in the cells and every measured number
%run Optical_IO_Components.ipynb
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
Progress: 100%
[2]:
# Open the interactive layout viewer, which redraws as cells are built below
from photonforge.live_viewer import LiveViewer

viewer = LiveViewer()
LiveViewer started at http://127.0.0.1:36003. Call its 'stop()' method to free the port.
[3]:
pad_spacing = 75.0  # Pad centres within one channel, um
channel_pitch = 150.0  # Channel to channel spacing, um
bank_gap = 250.0  # Clear space between the transmit and receive banks, um

Models for the routing waveguides

Routes are drawn by the router, not by hand, so they arrive without a model attached. A circuit simulation then has nothing to work with and falls back to fitting poles to a flat S matrix, which both warns and throws away the propagation loss.

Setting a default model on the routing primitives fixes that once for the whole chip. Every bend, S bend and straight the router creates from here on carries the measured strip index and the measured scattering loss, so the several millimetres of waveguide on this die cost what they should.

The time stepper matters as much as the model. A waveguide left to the default is turned into a handful of poles fitted to its S matrix, and a pole fit cannot represent a long delay: a half millimetre of waveguide can pass its frequency-domain check at 0.1 dB and then lose forty in the time domain. Attaching AnalyticWaveguideTimeStepper propagates through an actual delay line instead, and the two domains then agree at every length.

[4]:
# Build an analytic waveguide model from the measured strip index and loss
route_model = pf.AnalyticWaveguideModel(
    n_eff=n_eff_strip,
    n_group=n_group_strip,
    propagation_loss=scattering_db_cm * 1e-4,  # dB/cm to dB/um
    reference_frequency=f0,
)

# Give it the matching time stepper, since a pole fit cannot hold a delay
route_model.time_stepper = pf.AnalyticWaveguideTimeStepper(
    n_eff=n_eff_strip,
    n_group=n_group_strip,
    propagation_loss=scattering_db_cm * 1e-4,
)

# Apply it to every primitive the router builds routes from
for primitive in ("bend", "s_bend", "straight", "route_s_bend"):
    pf.config.default_kwargs[primitive] = {"model": route_model}

print(f"routing loss  {scattering_db_cm:.1f} dB/cm on n_eff {n_eff_strip:.4f}")
routing loss  2.0 dB/cm on n_eff 2.4452

The channel plan

Every ring so far has been built at one size, and every one of them resonates at the same frequency. The chip needs eight different frequencies, 200 GHz apart, so this section works out how big each ring has to be.

A ring resonates when its round trip contains a whole number of wavelengths:

\[n_{\mathrm{eff}} L = m \lambda\]

Fix the channel and \(\lambda\) is known, so picking the integer \(m\) nearest the ring already being built gives the round-trip length that lands a resonance on that channel.

With one correction, and it is not a small one. The index in that equation is not a constant: it is the effective index at that channel, and it drifts across the band at a rate set by the group index. What the round trip really depends on is the product \(n_{\mathrm{eff}}(f)\,f\), which the model builds as

\[n_{\mathrm{eff}}(f)\,f = n_{\mathrm{eff}}(f_0)\,f_0 + n_{\mathrm{g}}\,(f - f_0)\]

Using the carrier index at every channel instead looks harmless and is not. On this rib waveguide \(n_{\mathrm{g}}\) is half again \(n_{\mathrm{eff}}\), so the mask step comes out a third too small and the eight resonances land on a 131 GHz comb rather than the 200 GHz one they were drawn for. By the last channel that is a quarter of a terahertz of error, far more than any heater should be asked to absorb. The line below is what keeps the comb on the grid.

Each device takes that step differently. The modulator is a circle, so its length is set by its radius. The filter is a racetrack, so its length is set by the straight coupling section, which is the better place to put it: changing a radius also changes the bend loss and the coupling gap, while changing a straight changes the path length and nothing else.

[5]:
# Place the eight channel frequencies, centred on the carrier
channel_freqs = f0 + (np.arange(n_channels) - (n_channels - 1) / 2) * channel_grid

# Take the modulator's nominal round-trip length
mod_length = 2 * np.pi * r_target
mod_radius, drop_lc = [], []

# Fix one mode order for the whole bank, taken at the carrier
mod_order = round(b["n_eff"] * mod_length * f0 / pf.C_0)
drop_order = round(n_eff_strip * drop_length * f0 / pf.C_0)

# Work out the length each ring needs to land on its own channel
for f_ch in channel_freqs:
    # Evaluate index times frequency at this channel, which sets the phase
    nf_rib = b["n_eff"] * f0 + b["n_group"] * (f_ch - f0)
    nf_strip = n_eff_strip * f0 + n_group_strip * (f_ch - f0)

    # Take the radius that lands on the channel, snapped to a nanometre
    mod_radius.append(round(mod_order * pf.C_0 / nf_rib / (2 * np.pi), 3))

    # Put the filter's difference into its straight section instead
    exact = (drop_order * pf.C_0 / nf_strip - 2 * np.pi * r_target) / 2

    # Snap to 2 nm, which keeps the bus port on the layout grid
    drop_lc.append(round(exact / 0.002) * 0.002)

# Work out where those lengths actually put each resonance
res_mod = np.array(
    [
        f0 + (mod_order * pf.C_0 / (2 * np.pi * r) - b["n_eff"] * f0) / b["n_group"]
        for r in mod_radius
    ]
)
res_flt = np.array(
    [
        f0
        + (drop_order * pf.C_0 / (2 * np.pi * r_target + 2 * lc) - n_eff_strip * f0)
        / n_group_strip
        for lc in drop_lc
    ]
)

print(f"{'ch':>3s} {'f THz':>9s} {'ring radius um':>15s} {'straight um':>12s}")
for i, f_ch in enumerate(channel_freqs):
    print(f"{i:3d} {f_ch*1e-12:9.3f} {mod_radius[i]:15.5f} {drop_lc[i]:12.5f}")
print()
print(f"radius step   {(mod_radius[1]-mod_radius[0])*1e3:6.1f} nm per channel")
print(f"straight step {(drop_lc[1]-drop_lc[0])*1e3:6.1f} nm per channel")
print()
print(
    f"comb spacing  modulators {np.diff(res_mod).mean()*1e-9:.1f} GHz, "
    f"filters {np.diff(res_flt).mean()*1e-9:.1f} GHz"
)
print(
    f"worst residual after snapping: modulator "
    f"{np.abs(res_mod-channel_freqs).max()*1e-9:.0f} GHz, filter "
    f"{np.abs(res_flt-channel_freqs).max()*1e-9:.0f} GHz"
)

# Convert what the snapping costs into the units the heater has to make up
drop_ghz = 0.001 / abs(drop_lc[1] - drop_lc[0]) * channel_grid * 1e-9
mod_ghz = 0.0005 / r_target * f0 * 1e-9
print(
    f"filter straight snapped to 2 nm: up to {drop_ghz:.0f} GHz, "
    f"{drop_ghz / ghz_per_k_strip:.1f} K on the heater"
)
print(
    f"modulator radius snapped to 1 nm: up to {mod_ghz:.0f} GHz, "
    f"{mod_ghz / ghz_per_k_rib:.1f} K on the heater"
)
 ch     f THz  ring radius um  straight um
  0   192.714         5.00500      5.98000
  1   192.914         4.99700      5.94000
  2   193.114         4.98900      5.90200
  3   193.314         4.98100      5.86400
  4   193.514         4.97400      5.82600
  5   193.714         4.96600      5.78800
  6   193.914         4.95800      5.75000
  7   194.114         4.95000      5.71200

radius step     -8.0 nm per channel
straight step  -40.0 nm per channel

comb spacing  modulators 202.9 GHz, filters 200.6 GHz
worst residual after snapping: modulator 12 GHz, filter 4 GHz
filter straight snapped to 2 nm: up to 5 GHz, 0.6 K on the heater
modulator radius snapped to 1 nm: up to 19 GHz, 2.1 K on the heater

Both steps are tens of nanometres, which a mask holds comfortably and which barely moves the coupling: a few tens of nanometres on a straight of several microns is a fraction of a percent. The heater is left doing what it is good at, pulling out fabrication drift over a few kelvin.

Both are also rounded to the layout grid, and that is worth a moment because it is not obvious that it matters. A GDS file stores coordinates as whole nanometres. A ring whose radius is left exact puts its bus port on a fraction of one, and every shape in that cell then shifts by a fraction of a nanometre when the cell is placed. Rounding is per corner, so the two ends of a long edge can round opposite ways, and an edge that was horizontal comes out a nanometre higher at one end than the other. A right angle stops being a right angle, and design rule checks notice: a spacing rule ignores corners of ninety degrees or more, and 89.997 is not ninety.

Both dimensions are therefore snapped to the grid, and the cell prints the resonance error each snap introduces. Both come out of the heater budget.

The transmit bank

Eight modulators sit on one bus, each with its own resonance, so a comb of eight carriers entering the bus leaves it with each carrier modulated by its own ring.

The electrodes come first, because they decide the geometry. All four terminals of a modulator sit within a few microns of the ring: the anode at its centre, brought out sideways by the access terminal the modulator cell provides, the cathode on the outer plug, and the two heater contacts below. Each modulator is wrapped in a cell that adds four bond pads, junction above and heater below, and a tapered metal trace from each terminal to its pad. Splitting the nets to opposite sides is what keeps any trace from having to cross another, which a single metal layer cannot do.

The modulator is the only rib block on the chip. Its bus meets the strip circuit through the transition measured in Component Library, one at each end of the bank.

Last on the bus, just past the output transition, sits the monitor tap from Component Library and the photodiode it feeds. It goes on the strip side deliberately: a rib tap would bring its slab within a few hundred nanometres of the bus slab, and the slivers that leaves are below the minimum the design rules allow. One tap serves all eight channels: it is not wavelength selective, so its diode carries the whole comb, and the channels are told apart by giving each heater its own dither tone and demodulating at that frequency. It costs the link 0.05 dB. Its fourth port, the one the coupler never feeds, is brought out as TAP_ISO rather than left dangling, so the connectivity check below still has to account for it.

[6]:
# Start from every measured parameter of the modulator built earlier
mod_kwargs = dict(mrm.parametric_kwargs)

# Put the measured index back, since here the radius moves instead
mod_kwargs["n_eff"] = b["n_eff"]

# Rebuild one modulator per channel, changing only the radius
modulators = [
    microring_modulator(**{**mod_kwargs, "ring_radius": radius})
    for radius in mod_radius
]


driver_offset, heater_offset = 150, 100  # Pad row offsets from the bus, um


@pf.parametric_component(name_prefix="MRM_BP")
def mrm_with_bond_pads(
    *,
    modulator=None,
    spacing=pad_spacing,
    driver_offset=driver_offset,
    heater_offset=heater_offset,
):
    mod = mrm if modulator is None else modulator

    # Name the cell after the modulator inside, so the eight stay distinct
    c = pf.Component(f"BP_{mod.name}")

    # Put the junction pads above the ring and the heater pads below
    bp_driver = pf.Reference(
        bp, (-spacing / 2, driver_offset), columns=2, spacing=(spacing, 0)
    )
    bp_heater = pf.Reference(
        bp, (-spacing / 2, -heater_offset), columns=2, spacing=(spacing, 0)
    )
    c.add(bp_driver, bp_heater)

    ref = c.add_reference(mod)

    # Pass the optical bus through, and expose the drive port
    c.add_port([ref["P0"], ref["P1"]])
    c.add_port(ref["E0"], "E0")

    # Draw one tapered trace per net, out to its own pad
    c.add(
        pf.parametric.route_taper(
            terminal1=(ref, "CATHODE"), terminal2=(bp_driver, "T0", 1)
        ),
        # Take the anode out through the access terminal the modulator provides
        pf.parametric.route_taper(
            terminal1=(ref, "ANODE_ACCESS"), terminal2=(bp_driver, "T0", 0)
        ),
        pf.parametric.route_taper(
            terminal1=(ref, "H0"), terminal2=(bp_heater, "T0", 0), layer="M1"
        ),
        pf.parametric.route_taper(
            terminal1=(ref, "H1"), terminal2=(bp_heater, "T0", 1), layer="M1"
        ),
    )

    # Say what each trace was meant to join, which a drawn route does not record
    c.add_virtual_net([(ref, "CATHODE"), (bp_driver, "T0", 1)])
    c.add_virtual_net([(ref, "ANODE_ACCESS"), (bp_driver, "T0", 0)])
    c.add_virtual_net([(ref, "H0"), (bp_heater, "T0", 0)])
    c.add_virtual_net([(ref, "H1"), (bp_heater, "T0", 1)])

    # Add a circuit model, so the cell answers for the modulator inside it
    c.add_model(pf.CircuitModel(), "Circuit")
    return c


# Build one padded channel per modulator
tx_cells = [mrm_with_bond_pads(modulator=m) for m in modulators]


@pf.parametric_component(name_prefix="TAPMON")
def tap_with_monitor(*, pad_top=driver_offset, pad_bot=heater_offset, pad_x=80):
    """The output tap, its monitor diode, and the two pads the diode needs."""
    c = pf.Component()
    tap_ref = c.add_reference(tap)

    # Place the diode on the end of the tap arm, so no route is needed
    det = c.add_reference(pd)
    det.connect("P0", tap_ref["P3"])

    # Put one pad above and one below, clear of the last modulator's column
    x_pad = tap_ref["P3"].center[0] + pad_x
    bp_top = pf.Reference(bp, (x_pad, pad_top))
    bp_bot = pf.Reference(bp, (x_pad, -pad_bot))
    c.add(bp_top, bp_bot)
    c.add(
        pf.parametric.route_taper(terminal1=(det, "CATHODE"), terminal2=bp_top["T0"]),
        pf.parametric.route_taper(terminal1=(det, "ANODE"), terminal2=bp_bot["T0"]),
    )
    c.add_virtual_net([(det, "CATHODE"), (bp_top, "T0")])
    c.add_virtual_net([(det, "ANODE"), (bp_bot, "T0")])

    # Expose the bus on IN and OUT, and the port the tap never feeds on ISO
    c.add_port(tap_ref["P0"], "IN")
    c.add_port(tap_ref["P2"], "OUT")
    c.add_port(tap_ref["P1"], "ISO")
    c.add_port(det["E0"], "E0")
    c.add_model(pf.CircuitModel(), "Circuit")
    return c


tap_mon = tap_with_monitor()


def build_tx_bank(cells):
    """Place the padded modulators on one bus, with the taps and tapers."""
    # Shift each cell by its own offset, so every bus lands on one line
    instances = {
        f"mod{i}": {
            "component": c,
            "origin": (i * channel_pitch, -c.ports["P0"].center[1]),
        }
        for i, c in enumerate(cells)
    }

    # Add a transition at each end, so the facet sees strip waveguide
    instances["in_taper"] = {"component": rib_to_strip}
    instances["out_taper"] = {"component": rib_to_strip}

    # Put the tap after the output transition, where it sees every channel
    instances["tapmon"] = {"component": tap_mon}

    # The channels no longer butt together, so the bus between them is drawn
    routes = [
        ((f"mod{i}", "P1"), (f"mod{i+1}", "P0"), {"radius": 5.0})
        for i in range(n_channels - 1)
    ]

    bank = pf.component_from_netlist(
        {
            "name": "tx_bank",
            "instances": instances,
            "connections": [
                (("in_taper", "P0"), ("mod0", "P0")),
                (("out_taper", "P0"), (f"mod{n_channels-1}", "P1")),
                (("tapmon", "IN"), ("out_taper", "P1")),
            ],
            "routes": routes,
            "ports": [
                ("in_taper", "P1", "IN"),
                ("tapmon", "OUT", "OUT"),
                ("tapmon", "ISO", "TAP_ISO"),
                ("tapmon", "E0", "TAP_MON"),
            ]
            + [(f"mod{i}", "E0", f"DRIVE{i}") for i in range(n_channels)],
            "models": [(pf.CircuitModel(), "Circuit")],
        }
    )

    # Count the route cells that appeared, since a failed link only warns
    drawn = sum(
        1
        for r in bank.references
        if "route" in str(r.component.parametric_function or "")
    )
    assert drawn == len(routes), "a bus link failed to build"
    return bank, drawn, len(routes)


# Build the nominal receive bank, every ring at its drawn size and reference temperature
tx_bank, drawn, tx_links = build_tx_bank(tx_cells)

print("transmit bank")
print(
    f"  {n_channels} modulators with 4 pads each, plus one monitor tap, "
    f"footprint {tx_bank.size()[0]:.1f} x {tx_bank.size()[1]:.1f} um"
)
print(f"  bus links drawn: {drawn} of {tx_links}")
print(f"  unconnected ports: {len(tx_bank.disconnected_reference_ports())}")
viewer(tx_bank)
transmit bank
  8 modulators with 4 pads each, plus one monitor tap, footprint 1259.5 x 305.6 um
  bus links drawn: 7 of 7
  unconnected ports: 0
[6]:
Layout

The receive bank

Each channel is dropped by a pair of coupled rings, because one ring cannot both carry 100 Gb/s and reject a neighbour 200 GHz away. The eight filters share the receive bus: what one drops leaves the bus, and what it passes goes on to the next. Each drop feeds a photodiode, and both are strip, so nothing has to taper between them.

A receive channel carries six nets against the transmitter’s four: four heaters, one per ring, and the detector’s two. Six pads in a row would make the channel twice as wide as a modulator and stretch the bus for nothing, so they go three above and three below instead. That split is the one the filter asks for anyway: its two heater feeds leave on opposite sides, one above the upper ring and one below the lower, so each pair reaches the nearer row. The channel then has the same 125 um footprint as a transmit one.

[7]:
# Build one coupled pair per channel, changing only the straight length
def channel_filter(lc):
    return two_ring_filter(
        **{
            **dict(second_order.parametric_kwargs),
            "coupling_length": lc,
            "n_eff": n_eff_strip,
        }
    )


filters = [channel_filter(lc) for lc in drop_lc]


@pf.parametric_component(name_prefix="RX_BP")
def filter_with_bond_pads(
    *,
    filter_cell=None,
    spacing=pad_spacing,
    heater_offset=200,
    detector_offset=200,
    detector=True,
):
    flt = second_order if filter_cell is None else filter_cell

    # Give the two variants different names, or the second is served from the cache
    c = pf.Component(f"BP_{flt.name}" + ("" if detector else "_probe"))

    # Put three pads above, for the detector cathode and the upper ring's heater
    bp_top = pf.Reference(
        bp, (-spacing, heater_offset), columns=3, spacing=(spacing, 0)
    )

    # Put three below, for the detector anode and the lower ring's heater
    bp_bot = pf.Reference(
        bp, (-spacing, -detector_offset), columns=3, spacing=(spacing, 0)
    )
    c.add(bp_top, bp_bot)

    # Place the coupled pair this channel uses
    ref = c.add_reference(flt)

    # Expose the filter's optical ports, adding the drop when no detector is fitted
    for name in ("in", "thru", "add"):
        c.add_port(ref[name], name)

    if detector:
        # Butt the detector straight onto the drop port, since both are strip
        det = c.add_reference(germanium_photodiode())
        det.connect("P0", ref["drop"])

        # Expose the detector's electrical output, which the link notebook reads
        c.add_port(det["E0"], "OUT")
    else:
        c.add_port(ref["drop"], "OUT")

    c.add(
        # Route each ring's heater to the pads on the side its feed leaves on
        pf.parametric.route_taper(
            terminal1=(ref, "H0B"), terminal2=(bp_top, "T0", 0), layer="M1"
        ),
        pf.parametric.route_taper(
            terminal1=(ref, "H1B"), terminal2=(bp_top, "T0", 1), layer="M1"
        ),
        pf.parametric.route_taper(
            terminal1=(ref, "H0A"), terminal2=(bp_bot, "T0", 0), layer="M1"
        ),
        pf.parametric.route_taper(
            terminal1=(ref, "H1A"), terminal2=(bp_bot, "T0", 1), layer="M1"
        ),
    )
    for terminal, pads, index in (
        ("H0B", bp_top, 0),
        ("H1B", bp_top, 1),
        ("H0A", bp_bot, 0),
        ("H1A", bp_bot, 1),
    ):
        c.add_virtual_net([(ref, terminal), (pads, "T0", index)])
    if detector:
        c.add(
            # Route the detector to the outer pad of each row, so nothing crosses
            pf.parametric.route_taper(
                terminal1=(det, "CATHODE"), terminal2=(bp_top, "T0", 2)
            ),
            pf.parametric.route_taper(
                terminal1=(det, "ANODE"), terminal2=(bp_bot, "T0", 2)
            ),
        )
        c.add_virtual_net([(det, "CATHODE"), (bp_top, "T0", 2)])
        c.add_virtual_net([(det, "ANODE"), (bp_bot, "T0", 2)])
    c.add_model(pf.CircuitModel(), "Circuit")
    return c


rx_cells = [filter_with_bond_pads(filter_cell=f) for f in filters]


def build_rx_bank(cells, name="rx_bank"):
    """Place the padded filters and their detectors on one bus."""
    # Lay the channels out like the transmit bank, on the wider receive pitch
    instances = {
        f"flt{i}": {
            "component": c,
            "origin": (i * 2.5 * channel_pitch, -c.ports["in"].center[1]),
        }
        for i, c in enumerate(cells)
    }

    # Link each channel's through port to the next channel's input
    routes = [
        ((f"flt{i}", "thru"), (f"flt{i+1}", "in"), {"radius": 5.0})
        for i in range(n_channels - 1)
    ]

    bank = pf.component_from_netlist(
        {
            "name": name,
            "instances": instances,
            "routes": routes,
            "ports": [("flt0", "in", "IN"), (f"flt{n_channels-1}", "thru", "THRU")]
            + [(f"flt{i}", "add", f"ADD{i}") for i in range(n_channels)]
            + [(f"flt{i}", "OUT", f"RX{i}") for i in range(n_channels)],
            "models": [(pf.CircuitModel(), "Circuit")],
        }
    )
    drawn = sum(
        1
        for r in bank.references
        if "route" in str(r.component.parametric_function or "")
    )
    assert drawn == len(routes), "a bus link failed to build"
    return bank, drawn, len(routes)


# Build the nominal receive bank
rx_bank, drawn, rx_links = build_rx_bank(rx_cells)

print("receive bank")
print(
    f"  {n_channels} coupled-ring filters and detectors with 6 pads each, footprint "
    f"{rx_bank.size()[0]:.1f} x {rx_bank.size()[1]:.1f} um"
)
print(f"  bus links drawn: {drawn} of {rx_links}")
print(f"  unconnected ports: {len(rx_bank.disconnected_reference_ports())}")
viewer(rx_bank)
receive bank
  8 coupled-ring filters and detectors with 6 pads each, footprint 2825.0 x 450.0 um
  bus links drawn: 7 of 7
  unconnected ports: 0
[7]:
Layout

The chip

Both banks on one die, the transmit bank above and the receive bank below it, far enough apart that neither one’s pads reach the other’s.

Three things join them into a chip rather than two banks on the same substrate.

An edge coupler at the facet brings the light in. It is the inverse taper from Component Library, and it is what a lensed fiber or a fiber array actually lands on: the 180 nm tip expands the mode until it matches the fiber, at the cost of the 1.5 dB the link budget carries for it.

A link between the banks carries the modulated comb from the transmit output back to the receive input. The two banks face the same way, so the light leaves the transmit bank at its right end and has to come back the full length of the chip to enter the receive bank at its left. That is a millimetre of waveguide the link notebook has to account for, and drawing it here is what makes it countable rather than assumed. Routing it through the gap between the banks keeps it clear of both.

A floor plan marks where the design is allowed to be. It goes on a drawing layer that reaches no mask, and it does two jobs: the design rule check uses it to catch anything that has escaped the chip area, and the dicing street around it reserves the width the saw needs to separate this die from its neighbours.

[8]:
# Assemble the die, with the receive bank below the transmit one
def assemble_chip(tx, rx, name="optical_io_chip"):
    """Put both banks and the facet on one die and route between them."""
    # Round the drop to a nanometre, which is the grid GDS stores vertices on
    rx_drop = round(tx.size()[1] / 2 + rx.size()[1] / 2 + bank_gap, 3)

    # Run the link along the middle of the gap, clear of both banks
    link_y = round(-tx.size()[1] / 2 - bank_gap / 2, 3)

    die = pf.component_from_netlist(
        {
            "name": name,
            "instances": {
                "tx": {"component": tx, "origin": (0, 0)},
                "rx": {"component": rx, "origin": (0, -rx_drop)},
                "facet": {"component": edge_coupler()},
            },
            # Butt the coupler's wide end onto the transmit input, which places it
            "connections": [(("facet", "P1"), ("tx", "IN"))],
            # Route out of the transmit bank, back along the gap, into the receive bank
            "routes": [
                (
                    ("tx", "OUT"),
                    ("rx", "IN"),
                    {
                        "radius": 20.0,
                        "waypoints": [
                            (tx.size()[0] + 60, 0),
                            (tx.size()[0] + 60, link_y),
                            (-70, link_y),
                            (-70, -rx_drop),
                        ],
                    },
                )
            ],
            "ports": [
                ("facet", "P0", "FIBER"),
                ("rx", "THRU", "RX_THRU"),
                ("tx", "TAP_ISO", "TAP_ISO"),
                ("tx", "TAP_MON", "TAP_MON"),
            ]
            + [("rx", f"ADD{i}", f"ADD{i}") for i in range(n_channels)]
            + [("tx", f"DRIVE{i}", f"DRIVE{i}") for i in range(n_channels)]
            + [("rx", f"RX{i}", f"RX{i}") for i in range(n_channels)],
            "models": [(pf.CircuitModel(), "Circuit")],
        }
    )

    margin, street = 50.0, 60.0  # Floor-plan margin and dicing street width, um
    (x0, y0), (x1, y1) = die.bounds()
    die.add(
        "FloorPlan",
        pf.Rectangle((x0 - margin, y0 - margin), (x1 + margin, y1 + margin)),
    )
    die.add(
        "Dicing",
        *pf.boolean(
            pf.Rectangle(
                (x0 - margin - street, y0 - margin - street),
                (x1 + margin + street, y1 + margin + street),
            ),
            pf.Rectangle((x0 - margin, y0 - margin), (x1 + margin, y1 + margin)),
            "-",
        ),
    )
    return die


# Build the nominal chip, which is what the mask and the checks below use
chip = assemble_chip(tx_bank, rx_bank)

# Pick out the one route drawn at this level, between the two banks
link = [
    r for r in chip.references if "route" in str(r.component.parametric_function or "")
]
print(
    f"link from the transmit bank to the receive bank: "
    f"{sum(pf.route_length(r.component) for r in link):.0f} um"
)
print(
    f"chip footprint {chip.size()[0]:.0f} x {chip.size()[1]:.0f} um "
    f"= {chip.size()[0] * chip.size()[1] * 1e-6:.3f} mm^2"
)
viewer(chip)
link from the transmit bank to the receive bank: 2352 um
chip footprint 3113 x 1248 um = 3.884 mm^2
[8]:
Layout
Schematic
Netlist

Writing it out

The layout goes to GDS as it stands. Every cell in it carries its own model, so the same file is both the mask description and the thing the link notebook simulates.

[9]:
# Write the finished die to GDS, which is what the design rule check reads
pf.write_layout("optical_io_chip.gds", chip)
print("wrote optical_io_chip.gds")
print(f"cells in the hierarchy: {len(chip.dependencies())}")
wrote optical_io_chip.gds
cells in the hierarchy: 140

Design rule check

A layout plot shows whether the arrangement is correct, not whether the mask can be made. A design rule check measures every drawn feature against the process limits, and is the last step before a mask is released.

PhotonForge runs one through KLayout. drc_check takes the component, writes it out, runs the runset and reads the markers back, so the layout that was just assembled is the layout that is checked. The rules live in a separate runset file, optical_io.drc, written in KLayout’s own syntax. It follows the SiEPIC EBeam deck that ships with the DRC guide, so the structure and the idioms are ones you will meet again, with this technology’s own numbers in place of theirs: 150 nm silicon lines, a 120 nm silicon gap so the 123 nm modulator coupler passes, 300 nm on the slab and the heater, and micron-scale metal.

Two conventions in that deck are worth taking rather than reinventing.

The first is tol, a nanometre subtracted from every rule value. A designer aims at exactly the minimum feature, and a curve drawn to exactly the minimum lands a few picometres under it once it is polygonised, so a rule written at the bare number reports the whole layout.

The second is angle_limit. The distance between two edges of a polygon falls to zero at the corner they share, so a width or spacing check with no angle limit reports every sharp corner in the design. The SiEPIC deck answers this with angle_limit(80) on silicon and angle_limit(70) on metal, and those are the values used here. They exclude corners without blinding the rule: a 200 nm gap between two slabs and a 200 nm metal line are both still caught.

This needs the KLayout application installed and on the system path.

[10]:
# The check exports the die to GDS itself, runs the runset on it and reads the
# markers back, so it takes the component rather than a file
results = pf.drc_check(chip, "optical_io.drc", show_progress=False)

print(f"DRC clean: {results.is_clean}")
print(results)
DRC clean: True
DRC results summary
--------------------------------
Total violations: 0

Violations by category:
Boundary: 0
Boundary_M1: 0
Si_width: 0
Si_space: 0
Slab_width: 0
Slab_space: 0
Ge_width: 0
Ge_space: 0
P_width: 0
N_width: 0
Ppp_width: 0
Npp_width: 0
Heater_width: 0
Heater_space: 0
M1_width: 0
M1_space: 0
Contact_width: 0
Contact_space: 0
Via_width: 0
Via_space: 0
PadOpen_width: 0
PadOpen_space: 0
M1_PadOpen_enclosure: 0
Contact_on_Si: 0
Contact_on_ohmic: 0
Via_in_M1: 0

The check comes back clean. Every layer, every rule: no line under its minimum width, no gap under its minimum space, every contact on silicon and on heavy implant, every via covered by metal, every pad opening enclosed.

It did not start that way. The first run of this deck reported 474 violations, and working through them is most of what this section is really about, because the list turned out to have three quite different kinds of entry in it.

Some were the rules, not the layout. A width or spacing check measures the distance between two edges, and that distance falls to zero wherever two edges meet at a corner, so a deck without an angle_limit reports every sharp corner in the design. The SiEPIC deck this one follows answers that with angle_limit(80) on silicon and angle_limit(70) on metal, and its tol of one nanometre takes care of a feature drawn at exactly its minimum. Neither is a fudge: with both in place, a 200 nm gap between two slabs and a 200 nm metal line are still caught.

Some were real, and invisible. Three cross-layer rules found metal sitting on oxide: the modulator’s inner contact plug was drawn across a hole in its slab, its outer plug hung past the slab’s edge, and the photodiode had no slab at all beneath the strip its implant and contact were drawn on. Nothing in a layout viewer shows that at any zoom a person would use. One line comparing two layers does.

And most were the same mistake in different places: a taper still tapering where it meets something else. The heater feed flared right up to its pads and right into its ring, the slab was drawn as a disc meeting a strip so a circle crossed a straight edge, and this chapter’s own transition tapered its slab to a mathematical point. Every one of them made a corner too sharp for a rule to accept, and every one was fixed the same way: finish the taper clear of whatever it joins, and end it square.

Two more were worth the trouble on their own account. The heater terminals sat close enough that the two traces leaving them ran within a micron of each other for their whole length, which is a real clearance problem rather than a corner. And the last four came from a single nanometre: a ring radius left un-rounded put its bus port on a fraction of a nanometre, GDS rounded each corner of the cell separately, and a right angle came out at 89.997 degrees.

A design rule check is useful for this reason: each violation identifies a specific assumption the process does not support.

Layout versus schematic

A clean design rule check says the shapes can be made. It says nothing about whether the metal joins what it was meant to join. Every heater and every junction on this die reaches its own pad through a tapered trace, and a trace that lands on the wrong pad, or fails to land at all, draws perfectly legal shapes.

Nothing simulated so far would notice. The link notebook reads its connectivity from the netlist each cell carries, not from the drawn metal, so a mis-routed heater would still tune its ring in simulation and only fail on the bench.

A layout-versus-schematic check compares the two. KLayout extracts the nets from the conducting layers, PhotonForge writes the schematic from the connectivity the component itself reports, and the two netlists are matched cell by cell.

Three choices make the comparison mean something.

Terminal names become labels on the metal. Extracted nets are anonymous otherwise, and the comparison can only match them by shape. Each terminal is labelled on M1 in a function applied to the copy of every cell written out, so a net carries the name of the pin it reaches. Without it the check still passes, but only as a match with warnings.

Devices are left unconnected. The runset connects the routing metal, the vias and the contact plugs, and deliberately leaves out the heater film, the silicon and the germanium. A heater is then a resistor between its two vias rather than one shorted node, which is what makes a trace accidentally bridging its two ends visible as two nets merging into one.

Optical ports are dropped. A port is reported as a net on the schematic side, and the metal extraction never sees a waveguide, so each one would appear as a net with nothing to match. The check tolerates them, but the counts printed below would then be half optical and mean nothing.

[11]:
def label_terminals(component):
    """Applied to the copy of every cell written out for the check: each terminal name becomes a
    text label on the metal, so the extracted nets carry the names of the pins they reach. The
    optical ports go at the same time, so the count below is the electrical side only.
    """
    # The anode reaches two terminals through the modulator's own metal, so the
    # schematic has to offer the single pin the extraction finds
    if {"ANODE", "ANODE_ACCESS"} <= set(component.terminals):
        component.remove_terminal("ANODE")
    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)


results = pf.lvs_check(
    chip, "optical_io.lvs", pre_export_function=label_terminals, show_progress=False
)
print(f"LVS verdict: {results.verdict}")
print(
    f"{sum(c.status == 'match' for c in results.comparisons)} of "
    f"{len(results.comparisons)} cells match"
    + (f", {len(results.top.layout.nets)} nets on the die" if results.top else "")
)
for comparison in results.comparisons:
    if comparison.status != "match":
        print(f"  {comparison.circuit}: {comparison.status}")
for message in results.messages:
    if message.severity == "error":
        print(message.text)
LVS verdict: match
35 of 35 cells match