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
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]:
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]:
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]:
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]:
[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]:
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]:
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]:
[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]: