Optical phased array (OPA)

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

  • Sensing

  • Imaging

  • Displays

  • Light Detection and Ranging (LiDAR)

  • Telecommunications, such as demultiplexing switches

The Luceda 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, this approach allows the designer to scale simple design concepts to large designs that are easy to simulate 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 the coding style so that this approach can be applied easily to many different circuits.

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

Introduction

An optical phased array consists of a splitter tree whose every output is connected to a heater. If you would like to learn how to design a splitter tree using Luceda IPKISS, we recommend that you have a look at Designing a 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 the routed OPA and simulate it.

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 yet been connected to contact pads and grating couplers. We are now going to use this PCell in example_unrouted_opa.py to instantiate an OPA and simulate it.

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.

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)])

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:

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()
opa_lv.visualize(annotate=False)

../../../_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.

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

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.tight_layout()
plt.show()
../../../_images/unrouted_opa_phase.png

Unrouted OPA: parametric cell

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

luceda-academy/training/topical_training/opa/opa/cell.py
class OPA(i3.Circuit):
    """Class for an optical phased array (OPA) composed of a splitter tree and an array of heaters."""

    _name_prefix = "OPA"

    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="Vertical spacing between the levels of the splitter tree",
    )
    spacing_x = i3.PositiveNumberProperty(
        default=100.0,
        doc="Horizontal 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.MMI1x2Optimized1550()

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

    def _default_specs(self):
        # Position the heaters and connect them to the splitter tree
        specs = []
        east = self.insts["tree"].get_default_view(i3.LayoutView).size_info().east
        n_outputs = self._get_n_outputs()
        for n_out in range(n_outputs):
            specs += [
                i3.Place(
                    "ht{}".format(n_out),
                    (east + self.spacing_x, (n_out - (n_outputs - 1) / 2.0) * self.spacing_y),
                ),
                i3.ConnectManhattan(
                    "ht{}:in".format(n_out),
                    "tree:out{}".format(n_out + 1),
                ),
            ]
        return specs

    def _default_exposed_ports(self):
        # Set the names of the electrical ports
        exposed_ports = dict()
        exposed_ports["tree:in"] = "in"
        for n_out in range(self._get_n_outputs()):
            exposed_ports["ht{}:elec1".format(n_out)] = "hti{}".format(n_out)
            exposed_ports["ht{}:elec2".format(n_out)] = "hto{}".format(n_out)
            exposed_ports["ht{}:out".format(n_out)] = "out{}".format(n_out)
        return exposed_ports

Let’s take a closer look:

  • The OPA PCell inherits from i3.Circuit 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.

  • We use a splitter tree from P-Team library (based on SiFab). 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 i3.Circuit.

  • 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.

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.

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

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 just created) 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.

luceda-academy/training/topical_training/opa/opa/cell.py
class RoutedOPA(i3.PCell):
    """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.
    """

    # 1. We define the properties of the PCell.
    dut = i3.ChildCellProperty(doc="The device under test, in this case the OPA")
    fgc = i3.ChildCellProperty(doc="Fiber grating couplers PCell")
    bondpad = i3.ChildCellProperty(doc="Bondpads PCell")
    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")
    length_of_out_wg = i3.PositiveNumberProperty(default=300.0, doc="Length of output waveguide")
    length_of_in_wg = i3.PositiveNumberProperty(default=300.0, doc="Length of output waveguide")

    def _default_dut(self):
        return OPA(name=self.name + "_opa")

    def _default_fgc(self):
        return pdk.FC_TE_1550()

    def _default_bondpad(self):
        return pdk.BONDPAD_5050()

    class Layout(i3.LayoutView):
        def _generate_instances(self, insts):
            # 2. We define the instances that form our circuit.
            # The instances of our circuit are the DUT (the OPA), the grating couplers and the contact pads
            instances = {"DUT": self.dut}
            dut_lo = self.dut
            for port in dut_lo.ports.optical_ports:
                instances["gr_{}".format(port)] = self.fgc
            for port in (p for p in dut_lo.ports if p.domain is i3.ElectricalDomain):
                instances["bp_{}".format(port)] = self.bondpad

            # 3. We define placement and routing specifications.

            # Useful parameters that we will use in our code:
            bp_spacing = self.bond_pads_spacing
            wire_spacing = self.wire_spacing
            bp_height = abs(self.bondpad.size_info().north - self.bondpad.size_info().south)
            height = dut_lo.size_info().north

            # Sorted list of output optical ports of the DUT
            port_list_out_sorted = [p.name for p in dut_lo.east_ports.y_sorted()]

            # Sorted list of electrical ports of the DUT
            el_in_sorted = [p.name for p in dut_lo.ports.y_sorted() if "hti" in p.name]
            el_out_sorted = [p.name for p in dut_lo.ports.y_sorted_backward() if "hto" in p.name]
            n_el_ports = len(el_in_sorted) + len(el_out_sorted)
            bp_position_y = height + 2 * bp_spacing + (n_el_ports // 2) * wire_spacing

            # Place the DUT (splitter tree) at the origin
            specs = [i3.Place("DUT", (0, 0))]

            # Place the grating couplers relative to the input and output ports of the DUT
            specs += [i3.PlaceRelative("gr_in:out", "DUT:in", (-self.length_of_in_wg, 0), angle=0.0)]
            specs += [
                i3.PlaceRelative(
                    "gr_{}:out".format(p),
                    "DUT:{}".format(p),
                    (self.length_of_out_wg, 0),
                    angle=180.0,
                )
                for p in dut_lo.ports
                if "out" in p.name
            ]

            # Place the bondpads
            specs += [
                i3.Place(
                    "bp_{}:m1".format(p),
                    (idx * bp_spacing, bp_position_y),
                )
                for idx, p in enumerate(el_in_sorted + el_out_sorted)
            ]

            # Connect the output optical ports all to output grating couplers
            specs += [
                i3.ConnectManhattan(
                    "DUT:{}".format(p),
                    "gr_{}:out".format(p),
                )
                for p in port_list_out_sorted
            ]

            # Connect the input optical port to the input grating coupler
            specs += [
                i3.ConnectManhattan(
                    "DUT:in",
                    "gr_in:out",
                )
            ]

            # Define trace templates for the electrical routing
            tt = i3.ElectricalWireTemplate()
            tt.Layout(width=4.0, layer=i3.TECH.PPLAYER.M1)

            cnt, cnt_x = 1, 0

            # Loop over each electrical port to provide the route to the contact pads
            halfway = n_el_ports // 2

            for idx, p in enumerate(el_in_sorted + el_out_sorted):
                cnt_x = cnt_x + 1
                pos_v_line = dut_lo.ports[p].x - (n_el_ports // 2 - cnt_x) * wire_spacing
                if pos_v_line > idx * bp_spacing:
                    cnt = cnt - 1
                else:
                    cnt = cnt + 1

                # Determine starting angle of the manhattan route
                start_angle = 180.0 if cnt_x < halfway else 90.0 if cnt_x == halfway else 0.0

                specs += [
                    i3.ConnectElectrical(
                        "DUT:{}".format(p),
                        "bp_{}:m1".format(p),
                        "DUT_{}_to_bp_{}_m1".format(p, p),
                        start_angle=start_angle,
                        end_angle=-90.0,
                        control_points=[
                            i3.V(pos_v_line),
                            i3.H(i3.END - 2 * bp_height - cnt * wire_spacing),
                        ],
                        trace_template=tt,
                        bend_radius=0.1,
                    )
                ]
            insts += i3.place_and_route(instances, specs)
            return insts

        def _generate_ports(self, ports):
            exposed_ports = dict()
            exposed_ports["gr_in:vertical_in"] = "in"
            for cnt in range(self.dut.cell._get_n_outputs()):
                exposed_ports["gr_out{}:vertical_in".format(cnt)] = "out{}".format(cnt)
                exposed_ports["DUT:hti{}".format(cnt)] = "hti{}".format(cnt)
                exposed_ports["DUT:hto{}".format(cnt)] = "hto{}".format(cnt)
            ports += i3.expose_ports(
                self.instances,
                exposed_ports,
                optical="explicit",
                electrical="explicit",
            )
            return ports

    class CircuitModel(i3.CircuitModelView):
        def _generate_model(self):
            return i3.HierarchicalModel.from_netlistview(self.netlist_view)

    class Netlist(i3.NetlistFromLayout):
        def _generate_netlist(self, netlist):
            # extract from layout
            netlist = super(RoutedOPA.Netlist, self)._generate_netlist(self)
            # remove electrical connections and components which were extracted but we can't use
            instances = copy.copy(netlist.instances)
            for instname in instances:
                instref = netlist.instances[instname].reference
                if isinstance(instref.cell, (pdk.BONDPAD_5050, i3.ElectricalWire)):
                    netlist.instances.pop(instname)
                    remove_nets = set()
                    for net in netlist.nets.values():
                        for term in net.terms:
                            if isinstance(term, InstanceTerm) and term.instance.name == instname:
                                remove_nets.add(net.name)
                    for net_name in remove_nets:
                        netlist.nets.pop(net_name)
            return netlist

Let’s have a closer look at the code:

  • We inherit directly from i3.PCell as we will need to manually tune the netlist to include only the electrical parts that we need. Our bond pads and the electrical wires do not have their i3.CircuitModelView defined. We would thus like to remove those entries.

  • We first generate the instances which contain the DUT, all the fiber grating couplers, and bond pads that we need. The number of instances depends on the number of ports our DUT has. 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.

  • We then create the optical connectors. They 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 electrical connectors that create a route between electrical outputs of the heaters and the bond pads. Also, here a sorted list is used.

  • The whole routing process is performed using connectors and a similar approach to the one used in Routing to the Chip Edge for defining the route waypoints.

  • External port names are exposed and renamed to 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.

Routed OPA: verification, visualization, and simulation

Using the PCell we have just defined (RoutedOPA), we can now instantiate and verify our design. In example_routed_opa.py, we instantiate the routed OPA, open it IPKISS Canvas, visualize and export to GDSII.

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

This script generates several outputs:

- export a schematic to canvas (RoutedOPA.iclib / RoutedOPA.icproject)
- write to GDSII (routed_opa.gds)
- visualize the layout in a matplotlib window (this pauses script execution)
- circuit simulation result (time-domain)

"""

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

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, name="RoutedOPA")
opa_routed_lv = opa_routed.Layout()

# Export to IPKISS Canvas for verification.
# When run, previous information (position of symbols, cards, ...) is remembered because the project is stored on disk.
opa_routed_lv.to_canvas()

opa_routed_lv.write_gdsii("{}.gds".format(tag))
opa_routed_lv.visualize()

# Simulation
res = simulate_opa(cell=opa_routed)

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.tight_layout()
plt.show()
../../../_images/routed_opa_canvas.png

Schematic of routed OPA in IPKISS Canvas generated from layout and after repositioning elements. The banner suggests that this is an auto-generated project.

Let’s have a closer look at the resulting schematic. The banner suggests that this is a project generated from IPKISS Code, as opposed to starting from an clean state (see Schematic capture in IPKISS Canvas). To ensure our circuit is designed as intended, consider these steps:

  • Confirm Proper Connections: We can verify correct connections exist between instances, by matching them to the specs defined in the _generate_instances method of the RoutedOPA class. We can also compare the schematic against the visualized layout. You’ll notice two types of nets, electrical ones in green and optical ones in black. Note that the absence of a net might indicate a problem with the connection in the layout. In addition, a group of electrical terms connected together to form a single net could indicate an unintended short circuit.

    ../../../_images/routed_opa_canvas_vs_layout.png
  • Explore Parameters: The functionality of a particular block depends on the parameters set. To explore those, we can select any of the instances and inspect their properties by opening the context menu (right-click) and selecting properties. This further displays a pane with various instance parameters exported from the corresponding PCell. Parameters that are different from the default values are marked with a circular arrow. By opening properties of an OPA symbol we can confirm that the parameters are equivalent to the ones set in IPKISS Code. Furthermore, to emphasize the important aspects of our design, we can open cards for each instance, accessible within the symbol instance context menu. Use cards to highlight the critical circuit aspects for easy verification.

    ../../../_images/routed_opa_canvas_properties.png
  • Review Design Requirements: Schematics extracted from IPKISS code include annotations, which let us review some of the designs requirements. We can open properties of the waveguides connecting the OPA to the output grating couplers, to confirm a uniform waveguide length throughout. In addition, annotations can be customized to broaden the way we verify the circuit e.g. by adding loss to the waveguides.

    ../../../_images/routed_opa_canvas_annotations.png
  • Exploring Hierarchy: Let’s deep dive into the OPA circuit (right-click -> “Open Schematic”). Designs generated with to_canvas are exported hierarchicaly. This allows us to explore the resulting sub-circuit schematics and apply the same verification steps as we did with the RoutedOPA.

    ../../../_images/routed_opa_canvas_deep_dive_schematic.png

    Schematic of OPA after repositioning elements.

The project (RoutedOPA.icproject) can be opened from a file explorer. Changes to the schematics are stored in RoutedOPA.iclib. Manual changes you make inside IPKISS Canvas such as moving instances and displaying cards, will be retained even if you execute to_canvas multiple times.

As the last step, we perform a simulation of the routed OPA.

../../../_images/routed_opa_phase.png

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 Luceda IPKISS, as well as verify the integrity of the circuit using Luceda IPKISS Canvas.

Extended example: Electrical routing to two sides

Instead of routing all the electrical connections of the OPA to one side (north), we can also define a PCell which routes them to two sides (north and south). To obtain your OPA routed to two sides, you can use the PCell TwoSidesRoutedOPA, which is also defined in opa/cell.py. Here below you may find the layout and the simulation of the OPA routed to two sides obtained by running the script example_twosides_routed_opa.py.

../../../_images/two_sides_routed_opa_layout.png
../../../_images/two_sides_routed_opa_phase.png