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.
import numpy as npimport photonforge as pfimport siepic_forge as siepicimport tidy3d as tdfrom photonforge.live_viewer import LiveViewer
viewer = LiveViewer()LiveViewer started at http://127.0.0.1:41975. Call its 'stop()' method to free the port.Next, we can define a few default parameters that will be used throughout the design, as well as the global number of channels.
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"] = 30pf.config.default_kwargs["euler_fraction"] = 0.5
num_channels = 32Sub-Components
Section titled “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:
gc = siepic.component("ebeam_gc_te1550")viewer(gc)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.
@pf.parametric_componentdef 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)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.
@pf.parametric_componentdef 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)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:
@pf.parametric_componentdef 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()
# Keep the vanishing slab at least 100 nm wide at the strip end taper = pf.parametric.transition( port_spec1=strip, port_spec2=rib, length=taper_length, min_width=0.1 ) straight = pf.parametric.straight(port_spec=rib, length=length)
# Taper in, rib section, and the same taper reversed on the way out refs = pf.reference_chain({"taper": taper, "rib": straight}, "taper - rib - P1:taper") c.add(*refs)
c.add_port([refs[0]["P0"], refs[-1]["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)@pf.parametric_componentdef 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)The last section is, of course, the phased array, built form an array of shallow gratings:
@pf.parametric_componentdef 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)Before assembling the full chip, we will also need bounding pads for external electrical connections.
bp = pf.Component("BondPad")bp.add_terminal(pf.Terminal("M2_router", pf.Rectangle(size=(100, 100))), add_structure=True)bp.add("M_Open", pf.Rectangle(size=(95, 95)))viewer(bp)main = pf.Component("MAIN")
# Grating couplersgc_ref = main.add_reference(gratings).translate(-gratings["P0"].center)
# splitterst_ref = main.add_reference(splitter)st_ref.x_min = gc_ref.x_maxmain.add(pf.parametric.route(port1=gc_ref["P0"], port2=st_ref["P0"]))
# Phase shifter arraydy = ps.size()[1] + ps.parametric_kwargs["conductor_width"]y = -(num_channels - 1) * dy / 2s_offset = splitter["P1"].center[1] - yx = 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 treeradius = 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 padsspacing = 150y_offset = ps_array.size()[1] + spacingbp_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 connectionsmain.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 connectionsmain.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 connectionw0 = 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)), )
# OPAopa_ref = main.add_reference(opa)opa_ref.y_mid = 0opa_ref.x_min = bp_ref.x_max + spacing
# connect OPA to MZMmain.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)