2. Optical phased array (OPA)

Integrated optical phased arrays (OPAs) enable beam steering at low cost when compared to bulk optics systems. They are used in many applications such as:

  • Sensing
  • Imaging
  • Displays
  • Light Detection and Ranging (LiDAR)
  • Telecommunications, such as demultiplexing switches

The IPKISS Photonics Design Platform is very well suited for the design and simulation of OPAs because it allows simultaneous parametric control of both the layout and the simulation of integrated photonic circuits using a scripting approach. As OPAs are large - yet regular - circuits, the scripting approach allows the designer to scale simple design concepts to large designs that can be simulated easily, while being confident that the simulations match the layout.

In this application example we will show how to create this parametric control in both layout and simulation for the design of an OPA. The methodology used here can be easily applied to circuits that have similar requirements, such as switch arrays, optical computing arrays or quantum computing arrays.

Our main focus will be on the file structure and on the coding style, so that this approach can be easily applied to many, different circuits.

../../../_images/lidar_gds.png

2.1. Introduction

An optical phased array is a splitter tree, where each output is connected to a heater. If you would like to learn how to design a splitter tree, we recommend to have a look at Design a circuit: splitter tree before continuing with this tutorial. In addition, in Thermo-optic phase shifter (Heater) you may find an explanation on how to design and simulate a heater.

In this application example, we will go through the following steps:

  1. First, we instantiate and simulate a pre-defined unrouted OPA (OPA). This way, we get a first feeling of what the final goal is in terms of layout and simulation.
  2. Next, we dig deeper into the code of the OPA PCell.
  3. We analyze how the simulation recipe of the OPA is defined.
  4. Finally, we route the OPA to grating couplers and bond pads, obtaining the RoutedOPA PCell. We instantiate it and simulate it.

2.2. Unrouted OPA: visualization and simulation

We have designed an OPA PCell, called OPA and we have stored it in opa/cell.py. This PCell is unrouted, which means that it has not been connected to contact pads and grating couplers yet. We are now going to use this PCell in example_unrouted_opa.py to instantiate an OPA and simulate it.

2.2.1. Instantiating the parametric components of the OPA

Many circuits, including the OPA we have designed in opa/cell.py, contain components that the designer may want to vary. In this case, we have chosen the heater PCell to be a property of the OPA. This allows for maximum flexibility, as the designer may decide at a later moment to use a different heater, without needing to define a new PCell for the OPA.

Therefore, the first step towards creating the OPA is to instantiate the heater PCell with the desired length.

Listing 2.48 luceda-academy/training/topical_training/opa/example_unrouted_opa.py
# Heater
heater = pdk.HeatedWaveguide(name="heated_wav")
heater.Layout(shape=[(0, 0), (1000.0, 0.0)])

2.2.2. Instantiating the unrouted OPA

Next, we instantiate the unrouted OPA PCell (OPA), which has the following properties:

  • heater: Heater PCell used in the OPA, at the outputs of the splitter tree
  • splitter: Splitter PCell used in the OPA, in the splitter tree
  • levels: Number of levels in the splitter tree
  • spacing_y: Horizontal spacing between the levels of the splitter tree
  • spacing_x: Vertical spacing between the splitters in the last level

The resulting circuit has a layout that can be visualized:

Listing 2.49 luceda-academy/training/topical_training/opa/example_unrouted_opa.py
# Unrouted OPA
opa = OPA(name="opa_array", heater=heater, levels=3)
opa_lv = opa.Layout()
fig = opa_lv.visualize(annotate=False)
fig.savefig(os.path.join("{}_layout.png".format(tag)), bbox_inches='tight')

../../../_images/unrouted_opa_layout.png

It also has a circuit model that can be simulated, using the simulate_opa function (pre-defined in opa/simulate.py), which defines the simulation recipe of the OPA.

Listing 2.50 luceda-academy/training/topical_training/opa/example_unrouted_opa.py
# Simulation
res = simulate_opa(cell=opa)

fig = plt.figure()
for cnt in range(opa._get_n_outputs()):
    plt.plot(res.timesteps[1:], np.unwrap(np.angle(res["out{}".format(cnt)]))[1:], label="out{}".format(cnt))

plt.title("Unrouted OPA - Phase")
plt.xlabel("Time step")
plt.ylabel("Phase")
plt.legend()
plt.show()
plt.tight_layout()
fig.savefig(os.path.join("{}_phase.png".format(tag)), bbox_inches='tight')
../../../_images/unrouted_opa_phase.png

2.3. Unrouted OPA: parametric cell

Next, we are ready to dig deeper into the IPKISS code that defines the unrouted OPA PCell.

Listing 2.51 luceda-academy/training/topical_training/opa/opa/cell.py
class OPA(CircuitCell):
    """Class for an optical phased array (OPA) composed of a splitter tree and an array of heaters.
    """
    heater = i3.ChildCellProperty(doc="Heater PCell used at the outputs of the splitter tree")
    splitter = i3.ChildCellProperty(doc="Splitter PCell used in the splitter tree")
    levels = i3.PositiveIntProperty(default=4, doc="Number of levels in the splitter tree")
    spacing_y = i3.PositiveNumberProperty(default=50.0,
                                          doc="Horizontal spacing between the levels of the splitter tree")
    spacing_x = i3.PositiveNumberProperty(default=100.0,
                                          doc="Vertical spacing between the splitters in the last level")

    def _get_n_outputs(self):
        return 2**self.levels

    def _default_heater(self):
        # By default, the heater is a 1 mm-long heated waveguide
        ht = pdk.HeatedWaveguide()
        ht.Layout(shape=[(0.0, 0.0), (1000.0, 0.0)])
        return ht

    def _default_splitter(self):
        return pdk.MMI1x2Optimized()

    def _default_child_cells(self):
        child_cells = dict()
        # Create a splitter tree, then add a heater at each output
        splitter_tree = pt_lib.SplitterTree(
            levels=self.levels,
            splitter=self.splitter,
            spacing_y=2 * self.spacing_y,
        )
        child_cells["tree"] = splitter_tree
        for cnt in range(self._get_n_outputs()):
            child_cells["ht{}".format(cnt)] = self.heater
        return child_cells

    def _default_place_specs(self):
        # Provides positions in which the heaters will be placed with respect to the splitter tree
        specs = []
        east = self.child_cells["tree"].get_default_view(i3.LayoutView).size_info().east

        for cnt in range(self._get_n_outputs()):
            specs.append(
                i3.Place(
                    "ht{}".format(cnt),
                    (east + self.spacing_x, (cnt - (self._get_n_outputs() - 1) / 2.0) * self.spacing_y)
                )
            )
        return specs

    def _default_connectors(self):
        return [("ht{}:in".format(cnt), "tree:out{}".format(cnt + 1), manhattan) for cnt in range(2**self.levels)]

    def _default_propagated_electrical_ports(self):
        # Propagate the electrical ports of each individual heaters so that they appear as ports of the CircuitCell
        pep = []
        for cnt in range(self._get_n_outputs()):
            pep.append("hti{}".format(cnt))
            pep.append("hto{}".format(cnt))
        return pep

    def _default_external_port_names(self):
        # Here the names of the electrical ports are set
        epn = dict()
        epn["tree:in"] = "in"
        for cnt in range(self._get_n_outputs()):
            epn["ht{}:elec1".format(cnt)] = "hti{}".format(cnt)
            epn["ht{}:elec2".format(cnt)] = "hto{}".format(cnt)
            epn["ht{}:out".format(cnt)] = "out{}".format(cnt)
        return epn

Let’s have a closer look:

  • The OPA PCell inherits from CircuitCell in order to create the circuit.
  • The heater used at the outputs of the splitter tree is a property of the OPA (heater), therefore it can be parametrically changed when the OPA is instantiated, as we have already done in the previous step.
  • A splitter tree from the P-Team library (based on SiFab) is used. Note that here the parametricity of SplitterTree here is handy, as we can change both the spacing and the splitter PCell according to the requirements of the OPA we want to design.
  • All components are placed using the functionality offered by CircuitCell.
  • All the input and output electrical ports of the heaters are propagated to the next level.
  • Ports are renamed to a shorter name for easier handling.

2.4. Simulation recipe

The goal of a simulation recipe is to find out what is the performance of a device or circuit. Inside the simulation recipe we script the interactions with the simulators that are required to answer that question.

For the OPA the questions is: what is the phase difference at the end of the array when a quadratic voltage sweep is applied across the heater array? To answer this question, we perform an optical circuit simulation in time domain.

Naturally, we want to be able to vary the relevant parameters that affect the behaviour of the OPA. Therefore, we create a parametric simulation recipe. In our case the parameters are:

  • cell : OPA PCell to be simulated
  • dv_start : Linear difference in voltage at the beginning of the sweep
  • dv_end : Linear difference in voltage at the end of the sweep
  • nsteps : Number of steps to be used in the voltage sweep
  • center_wavelength: Center wavelength
  • debug: If True, the simulation is run in debug mode

The simulate_opa function takes these parameters as input and returns the time response at the output of our circuit.

Listing 2.52 luceda-academy/training/topical_training/opa/opa/simulate.py
from ipkiss3 import all as i3
import re


def simulate_opa(
        cell,
        dv_start=0.0,
        dv_end=1.0,
        nsteps=50,
        center_wavelength=1.5,
        debug=False,
):
    """Simulation recipe to simulate an optical phased array (OPA) by sweeping the bias voltage.

    Parameters
    ----------
    cell : i3.PCell
        OPA PCell to be simulated
    dv_start : float
        Linear difference in voltage at the beginning of the sweep
    dv_end : float
        Linear difference in voltage at the end of the sweep
    nsteps : int
        Number of steps to be used in the voltage sweep
    center_wavelength : float
        Center wavelength
    debug : bool
        If True, the simulation is run in debug mode

    Returns
    -------
    Time response

    """
    dt = 1
    t0 = 0
    t1 = nsteps

    # Get the number of output ports
    dut_lv = cell.get_default_view(i3.LayoutView)
    port_list_out_sorted = [p.name for p in dut_lv.ports.y_sorted() if re.search("(out)", p.name)]
    n_outs = len(port_list_out_sorted)

    # Define the optical source at the input.
    # Note, FunctionExcitation is a PCell.
    # It's essentially a component that is placed in the circuit and sends a signal into the chip
    # through its output port.
    #
    # Using the argument port_domain, you can specify whether to input an optical or electrical signal,
    # excitation_function provides the input signal in terms of time.

    optical_in = i3.FunctionExcitation(
        port_domain=i3.OpticalDomain,
        excitation_function=lambda t: 1.0,
    )

    # Use nested functions to generate a function that defines the distribution
    # of the electrical signal, in volts, in terms of time.
    def get_source(v_start, v_end):
        def linear_ramp(t):
            return v_end * (t / t1)**0.5 + v_start * ((t1 - t) / t1)**0.5
        return linear_ramp

    # Define the child cells and links (direct connections)
    child_cells = {
        "dut": cell,
        "optical_in": optical_in,
    }
    links = [("optical_in:out", "dut:in")]

    for cnt in range(n_outs):

        # Define sources of electrical signals at electrical ports
        child_cells["v{}".format(cnt)] = i3.FunctionExcitation(
            port_domain=i3.ElectricalDomain,
            excitation_function=get_source(cnt*dv_start, cnt*dv_end),
        )
        links.append(("v{}:out".format(cnt), "dut:hti{}".format(cnt)))

        child_cells["out{}".format(cnt)] = i3.Probe(port_domain=i3.OpticalDomain)
        links.append(("out{}:in".format(cnt), "dut:out{}".format(cnt)))

    # Create a test bench by connecting the sources to the ports of the circuit using ConnectComponent
    testbench = i3.ConnectComponents(
        child_cells=child_cells,
        links=links,
    )

    # Now we simulate the test bench and return the time response.
    print("Simulating circuit between {} and {} with step {} - nsteps {}".format(t0, t1, dt, nsteps))

    cm = testbench.CircuitModel()
    results = cm.get_time_response(
        t0=t0,
        t1=t1,
        dt=dt,
        center_wavelength=center_wavelength,
        debug=debug,
    )

    return results

2.5. Routed OPA: parametric cell

As a final step, we create the RoutedOPA PCell (defined in opa/cell.py) that routes an unrouted OPA (such as the one we used so far) to the outside world. The parameters of this PCell are:

  • dut: The device under test (DUT), such as the OPA in this example. Note that the code has been written in such a way that it can be generalised to other PCells with electrical ports.
  • bond_pads_spacing: The horizontal distance between the contact pads
  • wire_spacing: The spacing between the electrical wires
Listing 2.53 luceda-academy/training/topical_training/opa/opa/cell.py
class RoutedOPA(CircuitCell):
    """Optical phased array (OPA) whose heaters are electrically connected to metal contact pads via electrical wires.
    In this example, the device under test (DUT) is the OPA, but the code has been written in such a way that
    it can be generalised to other PCells with electrical ports.
    """
    dut = i3.ChildCellProperty(doc="The device under test, in this case the OPA")
    electrical_links = i3.LockedProperty(doc="The electrical connectors between the heaters and the contact pads")
    bond_pads_spacing = i3.PositiveNumberProperty(default=100.0, doc="The horizontal distance between the contact pads")
    wire_spacing = i3.PositiveNumberProperty(default=10.0, doc="The spacing between the electrical wires")

    def _default_dut(self):
        return OPA()

    def _default_connectors(self):
        # Default optical connectors (don't put electrical connectors here)
        # Connect input and output optical ports of the DUT to grating couplers

        dut_lv = self.dut.get_default_view(i3.LayoutView)

        # List of output optical ports of the dut
        port_list_out_sorted = [p.name for p in dut_lv.ports.y_sorted() if re.search("(out)", p.name)]

        # Connect them all to output grating couplers
        conn = [("dut:{}".format(p), "gr{}:out".format(p), manhattan) for p in port_list_out_sorted]

        # Connect the input grating coupler
        conn.append(("dut:in", "gr_in:out", manhattan))

        return conn

    def _default_electrical_links(self):
        # Default electrical connectors
        conn = []
        dut_lv = self.dut.get_default_view(i3.LayoutView)

        # List of input and list of output electrical ports of the DUT
        el_in_sorted = [p.name for p in dut_lv.ports.y_sorted() if re.search("(hti)", p.name)]
        el_out_sorted = [p.name for p in dut_lv.ports.y_sorted_backward() if re.search("(hto)", p.name)]

        # Connect them all to the contact pads.
        conn.extend([("dut:{}".format(p), "bp{}:m1".format(p)) for p in el_in_sorted])
        conn.extend([("dut:{}".format(p), "bp{}:m1".format(p)) for p in el_out_sorted])

        return conn

    def _default_child_cells(self):
        # The child cells are the DUT, the grating couplers and the contact pads
        child_cells = {"dut": self.dut}
        for connector in self.connectors:
            out_cell = connector[1].split(":")[0]
            child_cells[out_cell] = pdk.FC_TE_1550()
        for el_link in self.electrical_links:
            out_cell = el_link[1].split(":")[0]
            child_cells[out_cell] = pdk.BONDPAD_5050()

        return child_cells

    def _default_place_specs(self):
        specs = []

        # Define the positions of the ports.
        bp_cnt = 0
        bp_spacing = self.bond_pads_spacing
        height = self.dut.get_default_view(i3.LayoutView).size_info().north

        # Place the dut (splitter tree) at the origin
        specs.append(i3.Place("dut", (0, 0)))

        # Place the outputs and inputs of the optical connectors in respect to each other
        for connector in self.connectors:
            if "in" in connector[0]:
                specs.append(i3.PlaceRelative(connector[1], connector[0], (-100, 0), angle=0.0))
            else:
                specs.append(i3.PlaceRelative(connector[1], connector[0], (300, 0), angle=180.0))

        # Do the same for the electrical connectors
        for el_link in self.electrical_links:
            specs.append(
                i3.Place(
                    el_link[1].split(":")[0],
                    (bp_cnt * bp_spacing, height + bp_spacing + len(self.electrical_links) * self.wire_spacing)
                )
            )
            bp_cnt = bp_cnt + 1
        return specs

    def _default_external_port_names(self):
        epn = dict()
        epn["gr_in:vertical_in"] = "in"
        for cnt in range(self.dut._get_n_outputs()):
            epn["grout{}:vertical_in".format(cnt)] = "out{}".format(cnt)
        return epn

    class Layout(CircuitCell.Layout):
        def _generate_elements(self, elems):
            n_links = len(self.electrical_links)
            cnt = 0
            cnt_x = 0
            insts = self.instances

            # Loop over each electrical link to provide the route for them
            for el_link in self.electrical_links:
                sp = get_port_from_interface(port_id=el_link[0], inst_dict=insts)  # Start port
                ep = get_port_from_interface(port_id=el_link[1], inst_dict=insts)  # End port
                d = self.wire_spacing
                cnt_x = cnt_x + 1
                if sp.x > ep.x:
                    cnt = cnt + 1
                else:
                    cnt = cnt - 1
                shape = i3.Shape([
                    sp,
                    (sp.x - (n_links/2 - cnt_x) * d, sp.y),
                    (sp.x - (n_links/2 - cnt_x) * d, ep.y - self.bond_pads_spacing + cnt * d),
                    (ep.x, ep.y - self.bond_pads_spacing + cnt * d),
                    ep
                ])

                # Draw a path along the shape defined above on the M1 metal layer
                elems += i3.Path(shape=shape, layer=i3.TECH.PPLAYER.M1, line_width=4.0)

            return elems

    class Netlist(CircuitCell.Netlist):
        def _generate_netlist(self, netlist):
            netlist = super(CircuitCell.Netlist, self)._generate_netlist(self)  # Optical netlist

            for p1, p2 in self.electrical_links:
                term_name = p1.split(":")[1]
                netlist += i3.ElectricalTerm(name=term_name)  # Adding an output term
                del (netlist.instances["bp{}".format(term_name)])  # Deleting the bondpads from the netlist
                netlist.link("dut:{}".format(term_name), term_name)  # Linking the dut to the bondpads

            return netlist

Let’s have a closer look at the code:

  • First, we create the connectors, which are based on a sorted list of the optical output ports of the dut. The idea is that the routing script can detect the number of fiber grating couplers and create connectors for each of them.
  • Using a similar approach, we define the property electrical_links that detects the electrical outputs of the heaters to connect them to the bond pads. Also here a sorted list is used.
  • We create the child cells, which contain all the fiber grating couplers and bond pads that we need. The number of child cells is based on the content of connectors and electrical_links. This means that the correct number of fiber grating couplers and bond pads is created, depending on the number of optical and electrical outputs in the circuit.
  • The placement is done using a combination of heuristics to make sure there is enough space for the electrical routing.
  • The electrical routing is performed using elements of the CircuitCell class, using an approach similar to the one used in Advanced Routing: Routing to the chip edge for the waypoints.
  • External port names are propagated and renamed to the be the same as in the unrouted OPA. This makes it easier to use the same simulation recipe on both the unrouted and routed circuits, as the interface is the same.

2.6. Routed OPA: visualization and simulation

Using the PCell we have just defined (RoutedOPA), we can now instantiate and simulate a routed OPA, similarly to what we have done before with the unrouted one. In example_routed_opa.py, we instantiate the heater and then use it to instantiate the unrouted OPA. The latter is in turn used to instantiate the routed OPA. We then export the routed OPA to GDSII and simulate it.

Listing 2.54 luceda-academy/training/topical_training/opa/example_routed_opa.py
"""Instantiate and simulate a routed optical phased array (OPA).
"""

import si_fab.all as pdk
from opa.cell import OPA, RoutedOPA
from opa.simulate import simulate_opa
import pylab as plt
import numpy as np
import os

tag = "routed_opa"

# Heater
heater = pdk.HeatedWaveguide(name="heated_wav")
heater.Layout(shape=[(0, 0), (1000.0, 0.0)])

# Unrouted OPA
opa = OPA(name="opa_array", heater=heater, levels=3)

# Routed OPA
opa_routed = RoutedOPA(dut=opa)
opa_routed_lv = opa_routed.Layout()
opa_routed_lv.write_gdsii("{}.gds".format(tag))

# Simulation
res = simulate_opa(cell=opa_routed)

fig = plt.figure()
for cnt in range(opa._get_n_outputs()):
    plt.plot(res.timesteps[1:], np.unwrap(np.angle(res["out{}".format(cnt)]))[1:], label="out{}".format(cnt))

plt.title("Routed OPA - Phase")
plt.xlabel("Time step")
plt.ylabel("Phase")
plt.legend()
plt.show()
plt.tight_layout()
fig.savefig(os.path.join("{}_phase.png".format(tag)), bbox_inches='tight')
print("Done")
../../../_images/routed_opa_layout.png
../../../_images/routed_opa_phase.png

2.7. Conclusion

In this application example, we have seen how to construct a routed optical phase array (OPA) by leveraging the flexibility and parametricity both in layout and simulation offered by the IPKISS Photonics Design Platform.