5.2. Parametric splitter tree

In the previous section, we have seen how to create a circuit, specifically a two-level splitter tree with three splitters. 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, as well as 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.

5.2.1. Class SplitterTree

The main building block of the splitter tree is an MMI with 1 input and 2 outputs (1x2 MMI). This component is available in our custom PDK library, SiFab, as an optimized component with circuit model and it’s called MMI1x2Optimized. The splitter tree is based on the CircuitCell class. The difference with the previous example is that here we are going to use inheritance to build the splitter tree. We are going to define the class SplitterTree as a PCell that inherits from CircuitCell. from CircuitCell. In addition, we are going to use for loops to create a circuit with a variable number of components and levels.

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 parameters that can be changed when instantiating the splitter tree. Each property has a default value, which is used in case 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 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 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 those external ports.

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

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