1. MZI lattice filter

In this section, we will explain how to design a lattice filter based on Mach-Zender interferometers (MZI) using Luceda IPKISS.

1.1. Directional coupler

The basic building block of an MZI is the directional coupler. In this case, we use SiDirectionalCouplerSPower from SiFab, the demonstration pdk distributed with this training material. This class is useful as it has a precomputed model, which is used to calculate the correct length of the directional coupler for a given power coupling coefficient into the cross arm. We can visualize this component, run a circuit simulation and plot the transmission. For more info on directional couplers available in SiFab, have a look at Directional coupler.

luceda-academy/pdks/si_fab/si_fab/ipkiss/si_fab/components/dir_coupler/doc/example_dc_s_power.py
from si_fab import all as pdk
from ipkiss3 import all as i3
import numpy as np
import pylab as plt

dc = pdk.SiDirectionalCouplerSPower(power_fraction=0.5, target_wavelength=1.55)
dc_lv = dc.Layout()
dc_lv.visualize(annotate=True)

dc_cm = dc.CircuitModel()

wavelengths = np.linspace(1.51, 1.6, 500)
S = dc_cm.get_smatrix(wavelengths=wavelengths)
plt.figure()
plt.plot(wavelengths, i3.signal_power(S["out1", "in1"]), linewidth=2.2, label="in1-out1 (through)")
plt.plot(wavelengths, i3.signal_power(S["out2", "in1"]), linewidth=2.2, label="in1-out2 (drop)")
plt.axvline(x=dc.target_wavelength)
plt.title("Power transmission", fontsize=16)
plt.xlabel("Wavelength", fontsize=16)
plt.ylabel("Power", fontsize=16)
plt.xlim(1.5, 1.6)
plt.legend(fontsize=14, loc=1)
plt.legend()
plt.show()
../../../_images/dir_coup_s_power_layout.png ../../../_images/dir_coup_s_power_simulation.png

1.2. MZI lattice filter

Now we can use this directional coupler to build an MZI lattice filter. To achieve this, we define a Python class that inherits from i3.Circuit and call it MZILatticeFilter. This lattice filter is a parametric circuit with customizable number of cascaded MZIs. The lengths of the directional couplers used for the MZIs are automatically calculated by providing a list of power coupling coefficients.

luceda-academy/training/topical_training/wdm_transmitter_mzi/mzi_lattice_filter.py
class MZILatticeFilter(i3.Circuit):
    """Mach-Zender interferometer lattice filter based on directional couplers with different power
    coupling.
    The number of power couplings should be equal to the number of delay lengths plus 1.
    """

    directional_couplers = i3.ChildCellListProperty(doc="list of directional couplers")
    center_wavelength = i3.PositiveNumberProperty(default=1.55, doc="Center wavelength")
    delay_lengths = i3.ListProperty(default=[100.0], doc="List of delay lengths")
    bend_radius = i3.PositiveNumberProperty(default=5.0, doc="Bend radius")
    phase_error_width_deviation = i3.NonNegativeNumberProperty(default=0.0)
    phase_error_correlation_length = i3.NonNegativeNumberProperty(default=0.0)

    def _default_directional_couplers(self):
        power_couplings = [0.5, 0.5]
        dir_couplers = [
            pdk.SiDirectionalCouplerSPower(
                name=self.name + "dc_{}".format(cnt),
                power_fraction=p,
                target_wavelength=self.center_wavelength,
            )
            for cnt, p in enumerate(power_couplings)
        ]
        return dir_couplers

    def _default_insts(self):
        insts = dict()
        for cnt, dc in enumerate(self.directional_couplers):
            insts["dc_{}".format(cnt)] = dc
        return insts

    def _default_specs(self):
        distance = 4 * self.bend_radius
        specs = []
        specs += [
            i3.PlaceRelative("dc_{}:in1".format(cnt), "dc_{}:out1".format(cnt - 1), (distance, 0))
            for cnt in range(1, len(self.directional_couplers))
        ]

        for cnt, delay_length in enumerate(self.delay_lengths):
            if delay_length > 0:
                l_top = delay_length / 2
                l_bot = 0
            else:
                l_top = 0
                l_bot = -delay_length / 2
            p1 = "dc_{}:out1".format(cnt)
            p2 = "dc_{}:in1".format(cnt + 1)
            # modify the trace template of the port
            p1_port = self.insts[p1.split(":")[0]].get_default_view(i3.LayoutView).ports[p1.split(":")[1]]
            tt = p1_port.trace_template
            tt_mod = tt.cell.modified_copy()
            tt_mod.CircuitModel(
                phase_error_width_deviation=self.phase_error_width_deviation,
                phase_error_correlation_length=self.phase_error_correlation_length,
            )
            specs.append(
                i3.ConnectManhattan(
                    p1,
                    p2,
                    trace_template=tt_mod,
                    bend_radius=self.bend_radius,
                    control_points=[i3.H(p1_port.position.y - 2 * self.bend_radius - l_bot)],
                    min_straight=0,
                    start_straight=0,
                    end_straight=0,
                )
            )

            p1 = "dc_{}:out2".format(cnt)
            p2 = "dc_{}:in2".format(cnt + 1)

            p1_port = self.insts[p1.split(":")[0]].get_default_view(i3.LayoutView).ports[p1.split(":")[1]]
            tt = p1_port.trace_template
            tt_mod = tt.cell.modified_copy()
            tt_mod.CircuitModel(
                phase_error_width_deviation=self.phase_error_width_deviation,
                phase_error_correlation_length=self.phase_error_correlation_length,
            )
            specs.append(
                i3.ConnectManhattan(
                    p1,
                    p2,
                    trace_template=tt_mod,
                    bend_radius=self.bend_radius,
                    control_points=[i3.H(p1_port.position.y + 2 * self.bend_radius + l_top)],
                    min_straight=0,
                    start_straight=0,
                    end_straight=0,
                )
            )

        return specs

    def _default_exposed_ports(self):
        exposed_ports = {
            "dc_0:in1": "in1",
            "dc_0:in2": "in2",
            "dc_{}:out1".format(len(self.delay_lengths)): "out1",
            "dc_{}:out2".format(len(self.delay_lengths)): "out2",
        }
        return exposed_ports

Let’s analyze the code above:

  1. Properties. We defined a list of properties that are used to design the MZI lattice filters. The length of the list provided in power_couplings determines how many directional couplers are used to design the lattice filter. If 5 values are provided for the power coupling coefficients, then 4 values should be provided for the delay_lengths to be used to connect the 5 directional couplers to each other. The phase error properties can be used to perform a variability analysis of the MZI lattice filter.

  2. Default directional couplers. In _default_directional_couplers we instantiate as many directional couplers as provided in power_couplings. As mentioned earlier, the default directional coupler used in this example is SiDirectionalCouplerSPower from SiFab.

  3. i3.Circuit properties. Next, the properties needed to create a circuit with i3.Circuit are specified:

    1. The directional couplers are added to the dictionary of cells that form our circuit in _default_insts.

    2. The placement (of the directional couplers) and routing (connectors between the directional couplers) specifications are provided in _default_specs. A connector which is used to draw the arms connecting the directional couplers to each other implements the delay length given by the values in the delay_lengths.

    3. The external ports of the final MZI lattice filter are exposed using _default_exposed_ports.

The connectivity defined in i3.Circuit is automatically used to extract the netlist of the circuit and to perform circuit simulations.