3. Parametric splitter tree

In the previous section, we have seen how to create a circuit, specifically a two-level splitter tree with three splitters. As an exercise, you will have tried to create a three-level splitter tree. Defining all the child cells, connections and place specifications manually every time can be tiresome and time-consuming. In this section, the goal is to create a parametric circuit, i.e. a circuit where the number of levels can easily be changed as parameter, without needing to redefine a new and increasingly complicated PCell every time. It also has other parameters that describe the geometry of the circuit, such as the distance between the MMIs. If defined correctly, changing these parameters will adapt the circuit simulation along with the layout.

3.1. Class SplitterTree

As before, the splitter tree is based on the CircuitCell class. The difference from the previous example is that here we are going to use for loops to create a circuit with a variable number of components and levels.

Listing 3.1 luceda-academy/training/getting_started/circuit_splitter_tree/splitter_tree_parametric.py
class SplitterTree(CircuitCell):

    _name_prefix = "SPLITTER_TREE"
    splitter = i3.ChildCellProperty(doc="Splitter used")
    levels = i3.IntProperty(default=3, doc="Number of levels")
    spacing_x = i3.PositiveNumberProperty(default=150.0, doc="Horizontal spacing between the levels")
    spacing_y = i3.PositiveNumberProperty(default=50.0, doc="Vertical spacing between the MMIs in the last level")
    bend_radius = i3.PositiveNumberProperty(default=5.0, doc="Bend radius of the connecting waveguides")

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

    def _default_child_cells(self):
        child_cells = dict()
        n_levels = self.levels
        for lev in range(n_levels):
            n_splitters = int(2 ** lev)  # Number of splitters per level
            for sp in range(n_splitters):
                child_cells["sp_{}_{}".format(lev, sp)] = self.splitter
        return child_cells

    def _default_connectors(self):
        conn = []
        n_levels = self.levels
        for lev in range(1, n_levels):
            n_splitters = int(2 ** lev)  # Number of splitters per level
            for sp in range(n_splitters):
                if sp % 2 == 0:
                    in_port = "sp_{}_{}:out1".format(lev - 1, int(sp / 2.0))
                else:
                    in_port = "sp_{}_{}:out2".format(lev - 1, int(sp / 2.0))
                out_port = "sp_{}_{}:in1".format(lev, sp)
                conn.append((in_port, out_port, bezier_sbend, {"bend_radius": self.bend_radius}))
        return conn

    def _default_place_specs(self):
        place_specs = []
        n_levels = self.levels
        spacing_x = self.spacing_x
        spacing_y = self.spacing_y
        for lev in range(n_levels):
            n_splitters = int(2 ** lev)  # Number of splitters per level
            y_0 = - 0.5 * spacing_y * 2**(n_levels - 1)
            for sp in range(n_splitters):
                x_coord = lev * spacing_x
                y_coord = y_0 + (sp + 0.5) * spacing_y * 2**(n_levels - lev - 1)
                place_specs.append(
                    i3.Place("sp_{}_{}".format(lev, sp), (x_coord, y_coord))
                )
        return place_specs

    def _default_external_port_names(self):
        epn = {"sp_{}_{}:in1".format(0, 0): "in"}
        cnt = 1
        lev = self.levels - 1
        n_splitters = int(2 ** lev)  # Number of splitters per level
        for sp in range(n_splitters):
            epn["sp_{}_{}:out1".format(lev, sp)] = "out{}".format(cnt)
            cnt += 1
            epn["sp_{}_{}:out2".format(lev, sp)] = "out{}".format(cnt)
            cnt += 1
        return epn

Let’s analyze this piece of code:

  • Properties. These are the parameters that can be changed when instantiating the splitter tree. Each property has a default value which is used when no value is assigned by the user.
  • Child cells. The method _default_child_cells is used to define the child cells that are needed in the splitter tree. The logic behind the naming of each child cell is the same as that used for the splitter tree with two levels shown in the previous section. However, here we perform the same operation using a for loop instead of manually listing each child cell.
  • Connectors. The method _default_connectors defines the connections between the input and the output ports of the splitters. Also, here this list of tuples is defined iteratively.
  • Place specs. The layout transformations of the splitters are defined in _default_place_specs. Once again, this is done iteratively to adapt to the number of levels given as a parameter.
  • External port names. The method _default_external_port_names is used to expose the ports that we want to access once the circuit is completed. This is useful to expose only the ports that need to be routed on the upper hierarchical level as external ports, and to rename them.

3.2. Instantiating and simulating the parametric splitter

Now, we can instantiate the splitter tree and visualize the circuit simulation. You can open this file with PyCharm on your computer and play around with the number of levels to see how the layout and circuit simulation change automatically.

Listing 3.2 luceda-academy/training/getting_started/circuit_splitter_tree/splitter_tree_parametric.py
    # 1. Instantiate the splitter
    splitter = pdk.MMI1x2Optimized()
    splitter_cm = splitter.CircuitModel()

    # 2. Instantiate the splitter tree with the desired splitter and number of levels
    my_circuit = SplitterTree(levels=4, splitter=splitter)
    my_circuit_lv = my_circuit.Layout()
    my_circuit_cm = my_circuit.CircuitModel()
    my_circuit_lv.visualize(annotate=True)
    my_circuit_lv.write_gdsii("splitter_tree.gds")

    # 3. Simulate the splitter and the circuit
    wavelengths = np.linspace(1.5, 1.6, 501)
    S_splitter = splitter_cm.get_smatrix(wavelengths=wavelengths)  # S-matrix of the splitter
    S_total = my_circuit_cm.get_smatrix(wavelengths=wavelengths)  # S-matrix of the totalev circuit
    Ss = [S_splitter, S_total]
    names = ["Single splitter", "Splitter tree with {} levels".format(my_circuit.levels)]
    in_names = ["in1", "in"]
    nports = [2, 2 ** (my_circuit.levels)]

    # 4. Plot the transmission
    for S, name, in_name, nport in zip(Ss, names, in_names, nports):
        fig = plt.figure()
        for p in range(nport):
            plt.plot(wavelengths, 10 * np.log10(np.abs(S["out{}".format(p + 1), in_name]) ** 2), '-', linewidth=2.2,
                     label="out{}".format(p + 1))
        plt.plot(wavelengths, 10 * np.log10(np.abs(S[in_name, in_name]) ** 2), '-', linewidth=2.2,
                 label="in")
        plt.ylim(-70, 0)
        plt.xlabel('Wavelength [um]', fontsize=16)
        plt.ylabel('Transmission [dB]', fontsize=16)
        plt.title("{} - Transmission".format(name), fontsize=16)
        plt.legend(fontsize=14, loc='lower center', ncol=6, columnspacing=0.5, handletextpad=0.2)
        plt.tight_layout()
        plt.show()
../../../_images/splitter_tree_four_levels_layout.png ../../../_images/mmi_optimized_circuitsim.png ../../../_images/splitter_tree_four_levels_circuitsim.png

3.3. Adding fiber grating couplers

Another advantage of the parametric splitter tree is that we can automate the placement and connection of the fiber grating couplers to the circuit. To learn how to do this, we advise you to follow the following tutorial: Optical phased array (OPA).