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

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

# 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:

# 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')



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.

# 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')


## 2.3. Unrouted OPA: parametric cell¶

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

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.

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,
}

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

child_cells["out{}".format(cnt)] = i3.Probe(port_domain=i3.OpticalDomain)

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

# 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
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")
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

# 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()

return child_cells

def _default_place_specs(self):
specs = []

# Define the positions of the ports.
bp_cnt = 0
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
specs.append(
i3.Place(
(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):
cnt = 0
cnt_x = 0
insts = self.instances

# Loop over each electrical link to provide the route for them
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

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

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.

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


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