LIDAR

In this notebook, we build a basic LIDAR chip inspired by the design in the works of Wang et al. [1] using the SiEPIC OpenEBL PDK.

We start by importing the usual modules and starting the LiveViewer.

References

  1. Wang, P., et al. “Large scanning range optical phased array with a compact and simple optical antenna.” Microelectronic Engineering, 224, 111237 (2020), doi: 10.1016/j.mee.2020.111237.

[1]:
import numpy as np
import photonforge as pf
import siepic_forge as siepic
import tidy3d as td
from photonforge.live_viewer import LiveViewer

viewer = LiveViewer()
Starting live viewer at http://localhost:5001

Next, we can define a few default parameters that will be used throughout the design, as well as the global number of channels.

[2]:
pf.config.svg_labels = False

tech = siepic.ebeam(sidewall_angle=0)
pf.config.default_technology = tech

port_spec = tech.ports["TE_1550_500"]
pf.config.default_kwargs["port_spec"] = port_spec

pf.config.default_kwargs["radius"] = 30
pf.config.default_kwargs["euler_fraction"] = 0.5

num_channels = 32

Sub-Components

We split the chip layout into several sub-components to make it easier to design, simulate and assemble.

For the input section, we will need a grating coupler, already available in the PDK:

[3]:
gc = siepic.component("ebeam_gc_te1550")
viewer(gc)
[3]:
../_images/examples_Lidar_5_0.svg

We can create a helper function to generate the input grating array with a loopback link already included. The function is created as a parametric component to allow us to update and query parametric arguments later, if we need to.

[4]:
@pf.parametric_component
def grating_array(*, count=5, fiber_spacing=127, loopback_length=100):
    c = pf.Component()

    ref = pf.Reference(
        gc, (0, -0.5 * (count - 1)), rows=count, spacing=(0, fiber_spacing)
    )
    c.add(ref)

    test_gcs = []
    if count > 2:
        x0 = ref["P0"][0].center[0] + loopback_length
        x1 = (
            min(ref.x_max - pf.config.default_kwargs["radius"], ref.x_min)
            - gc.ports["P0"].spec.width
        )
        y0 = ref["P0"][0].center[1]
        y1 = ref["P0"][-1].center[1]

        c.add(
            pf.parametric.route(
                port1=(ref, "P0", 0),
                port2=(ref, "P0", count - 1),
                waypoints=[(x0, y0, 0), (x1, y0, 90), (x1, y1, 90), (x0, y1, 180)],
            )
        )
        if count > 4:
            straight = pf.parametric.straight(length=loopback_length)
            for i in (1, count - 2):
                spacer = c.add_reference(straight).connect("P0", ref["P0"][i])
                test_gcs.append(c.add_reference(gc).connect("P0", spacer["P1"]))
            c.add_port(ref.get_ports("P0")[2:-2])
        else:
            c.add_port(ref.get_ports("P0")[1:-1])
    else:
        c.add_port(ref.get_ports("P0"))

    c.add_port(ref.get_ports("P1"))
    for test_gc in test_gcs:
        c.add_port(test_gc["P1"])

    c.add_model(pf.CircuitModel())

    return c


gratings = grating_array()
viewer(gratings)
[4]:
../_images/examples_Lidar_7_0.svg

The next section is a splitter tree that divides the single input into 32 channels. The required 1×2 MMI is not available in the PDK, so we will create our own design, again, as a parametric component. The design itself is not presented in this notebook for brevity.

[5]:
@pf.parametric_component
def mmi1x2(*, length=13.8, width=4, tapered_width=1.2, port_length=5, clad_margin=1):
    port_spec = pf.config.default_kwargs["port_spec"]
    port_width, _ = port_spec.path_profile_for("Si")

    c = pf.Component()
    core = pf.stencil.mmi(
        length,
        width,
        (1, 2),
        port_length=port_length,
        port_width=port_width,
        tapered_width=tapered_width,
    )
    c.add("Si", *core)

    bounds = pf.envelope(c, clad_margin, trim_x_min=True, trim_x_max=True, use_box=True)
    c.add("DevRec", bounds)

    c.add_port(c.detect_ports(["TE_1550_500"]))

    # MMIs have some resonant frequencies we need to account for in the simulation run time
    c.add_model(pf.Tidy3DModel(run_time=td.RunTimeSpec(quality_factor=10)), "Tidy3D")

    return c


mmi = mmi1x2()
viewer(mmi)
[5]:
../_images/examples_Lidar_9_0.svg

Before building the splitter tree, we have to design the next sub-component: the electro-optic phase shifter, because that will have an impact on how close together the last stage of spliters can be placed.

We use the available layers in the technology to design the phase shifter:

[6]:
@pf.parametric_component
def phase_shifter(
    *,
    length=1500,
    rib_width=5,
    taper_length=10,
    i_width=0.15,
    p_width=1.75,
    pp_width=1.7,
    p_contact=1.0,
    conductor_width=15,
    conductor_gap=7.5,
):
    strip = pf.config.default_kwargs["port_spec"]

    # Port specification for the rib section
    rib = strip.copy()
    rib.description = "Phase shifter internal"
    rib.width = rib_width - 2 * pf.config.tolerance
    rib.path_profiles = rib.path_profiles + [(rib_width, 0, "Si slab")]

    c = pf.Component()

    taper = pf.parametric.transition(
        port_spec1=strip, port_spec2=rib, length=taper_length
    )
    taper_in = c.add_reference(taper)

    straight = pf.parametric.straight(port_spec=rib, length=length)
    wg = c.add_reference(straight).connect("P0", taper_in["P1"])

    taper_out = c.add_reference(taper).connect("P1", wg["P1"])

    c.add_port([taper_in["P0"], taper_out["P0"]])

    doping_regions = [
        # start y, width, layer
        (rib_width / 2 - p_contact, p_contact, "Si"),
        (i_width / 2, p_width, "Si N"),
        (i_width / 2 + p_width, pp_width, "Si N++"),
    ]
    for y, width, layer in doping_regions:
        straight.add(
            layer,
            pf.Rectangle(corner1=(0, y), size=(length, width)),
            pf.Rectangle(corner2=(length, -y), size=(length, width)),
        )

    # Conductors
    conductor = pf.Rectangle(
        (0, -conductor_gap / 2 - conductor_width),
        (length + 2 * taper_length, -conductor_gap / 2),
    )
    c.add("M2_router", conductor, conductor.copy().mirror())

    # Terminals
    terminal0 = pf.Terminal(
        "M2_router",
        pf.Rectangle(
            corner1=(0, conductor_gap / 2), size=(conductor_width, conductor_width)
        ),
    )
    terminal1 = pf.Terminal(
        "M2_router",
        pf.Rectangle(
            corner2=(length + 2 * taper_length, -conductor_gap / 2),
            size=(conductor_width, conductor_width),
        ),
    )
    c.add_terminal([terminal0, terminal1])

    return c


ps = phase_shifter()
viewer(ps)
[6]:
../_images/examples_Lidar_11_0.svg
[7]:
@pf.parametric_component
def splitter_tree(*, splitter):
    layers = int(np.log2(num_channels))
    if 2**layers != num_channels:
        raise RuntimeError("Number of channels must be a power of 2.")

    c = pf.Component()

    ref = c.add_reference(splitter)
    c.add_port(ref["P0"])

    offset = ps.size()[1] + ps.parametric_kwargs["conductor_width"]
    full_width = (2 ** (layers - 1) - 1) * offset
    radius = pf.config.default_kwargs["radius"]

    for i in range(1, layers):
        # Number of MMIs in this layer
        n = 2**i

        # Spacing between MMIs
        dy = 2 ** (layers - i) * offset

        # Ideal S bend length (if possible) for most compact tree respecting the default radius
        s_offset = dy / 2 - mmi.ports["P2"].center[1]
        dx = pf.s_bend_length(s_offset, meander=False)

        x = ref.get_ports("P1")[0].center[0] - mmi.ports["P0"].center[0] + dx
        new_ref = pf.Reference(
            splitter, origin=(x, -dy * (n - 1) / 2), rows=n, spacing=(0, dy)
        )
        c.add(new_ref)

        c.add(
            pf.parametric.route(
                port1=(ref, f"P{1 +(j%2)}", j // 2), port2=(new_ref, "P0", j)
            )
            for j in range(n)
        )
        ref = new_ref

    p1 = ref.get_ports("P1")
    p2 = ref.get_ports("P2")
    c.add_port([p for pair in zip(p1, p2) for p in pair])

    return c


splitter = splitter_tree(splitter=mmi)
viewer(splitter)
[7]:
../_images/examples_Lidar_12_0.svg

The last section is, of course, the phased array, built form an array of shallow gratings:

[8]:
@pf.parametric_component
def phased_array(*, period=0.6, ff=0.5, spacing=2.0, length=100):
    port_spec = pf.config.default_kwargs["port_spec"]
    width, _ = port_spec.path_profile_for("Si")

    num_periods = int(length / period)
    length = num_periods * period

    c = pf.Component()

    for i in range(num_channels):
        offset = i * spacing
        c.add(
            "Si",
            pf.Path((0, offset), width).segment((width, offset)),
            "Si slab",
            pf.Path((width, offset), width).segment((length, 0), relative=True),
        )
        c.add_port(pf.Port(center=(0, offset), input_direction=0, spec=port_spec))

    tooth_size = (period * ff, num_channels * spacing)
    c.add(
        "Si",
        *(
            pf.Rectangle(corner1=(width + j * period, -spacing / 2), size=tooth_size)
            for j in range(num_periods)
        )
    )

    return c


opa = phased_array()
viewer(opa)
[8]:
../_images/examples_Lidar_14_0.svg

Before assembling the full chip, we will also need bounding pads for external electrical connections. Once again, we will use the ones provided by the PDK.

[9]:
bp = siepic.component("ebeam_BondPad")
viewer(bp)
[9]:
../_images/examples_Lidar_16_0.svg
[10]:
main = pf.Component("MAIN")

# Grating couplers
gc_ref = main.add_reference(gratings).translate(-gratings["P0"].center)

# splitter
st_ref = main.add_reference(splitter)
st_ref.x_min = gc_ref.x_max
main.add(pf.parametric.route(port1=gc_ref["P0"], port2=st_ref["P0"]))

# Phase shifter array
dy = ps.size()[1] + ps.parametric_kwargs["conductor_width"]
y = -(num_channels - 1) * dy / 2
s_offset = splitter["P1"].center[1] - y
x = st_ref.x_max + pf.s_bend_length(s_offset)
ps_array = pf.Reference(ps, (x, y), rows=num_channels, spacing=(0, dy))
main.add(ps_array)

# connect phase shifters to splitter tree
radius = pf.config.default_kwargs["radius"]
main.add(
    pf.parametric.route_s_bend(port1=(st_ref, f"P{i+1}"), port2=(ps_array, "P0", i))
    for i in range(num_channels)
)

# bond pads
spacing = 150
y_offset = ps_array.size()[1] + spacing
bp_ref = pf.Reference(
    bp,
    (ps_array.x_max - spacing / 2, -y_offset / 2),
    rows=2,
    columns=num_channels // 2 + 1,
    spacing=(spacing, y_offset),
)
main.add(bp_ref)

# Bottom connections
main.add(
    pf.parametric.route_manhattan(
        terminal1=(ps_array, "T1", i), terminal2=(bp_ref, "T0", i + 1), direction1="x"
    )
    for i in range(num_channels // 2)
)

# Top connections
main.add(
    pf.parametric.route_manhattan(
        terminal1=(ps_array, "T1", i + num_channels // 2),
        terminal2=(bp_ref, "T0", num_channels - i + 1),
        direction1="x",
    )
    for i in range(num_channels // 2)
)

# Ground connection
w0 = ps["T0"].size()[1]
w1 = bp["T0"].size()[1]
main.add(
    pf.parametric.route_manhattan(
        terminal1=(bp_ref, "T0", 0),
        terminal2=(bp_ref, "T0", num_channels // 2 + 1),
        direction1="x",
        waypoints=[ps_array.x_min - w1 / 2 - 2 * w0],
    )
)

for i in range(num_channels):
    x, y = ps_array["T0"][i].center()
    main.add(
        ps_array["T0"][i].routing_layer,
        pf.Rectangle((x - 3 * w0, y - w0 / 2), (x, y + w0 / 2)),
    )

# OPA
opa_ref = main.add_reference(opa)
opa_ref.y_mid = 0
opa_ref.x_min = bp_ref.x_max + spacing

# connect OPA to MZM
main.add(
    pf.parametric.route_s_bend(port1=(opa_ref, f"P{i}"), port2=(ps_array, "P1", i))
    for i in range(num_channels)
)

bounds = pf.envelope(main, 100, use_box=True)
main.add("DevRec", bounds)

viewer(main)
[10]:
../_images/examples_Lidar_17_0.svg