Coarse WDM (CWDM) filter using cascaded MZIs

This example is organised as follows:

. Introduction: objectives and technology . Design and simulation of the circuit . . how to define a virtual fabrication process . how to alter values in the predefined technology files

Note: to run this example you will also need to download



In this example, we are going to show how to design a filter based on cascaded Mach-Zender interferometers (MZI). The filter will have 2 channels, with low cross-talk and with a free spectral range ΔFSR=0.04μm\Delta FSR = 0.04 \mu m , around a center wavelength λc=1.55μm\lambda _c = 1.55 \mu m , as shown below.

CWDM filter

CWDM filter

In addition, we are going to fit the filter in a pre-defined frame. The frame will have 5 input ports on the left and 5 output ports on the right.

Frame with gratings

Frame with grating couplers

Importing the technology

Before we start, we need to import demolib, which is the technology containing the components we will use in this example. In addition, we also need to import the MZILattice from, which contains the PCell of the MZI we are going to use, and PlaceAndAutoRoute from IPKISS, which is used to place components and build waveguide routing between them.

import demolib.all as pdk
import ipkiss3.all as i3
from mzi import MZILattice
from picazzo3.routing.place_route.cell import PlaceAndAutoRoute
import numpy as np
import pylab as plt

The frame for our circuit

The frame we use for this example is included in the file We can instantiate it and visualize it.

my_frame = pdk.FrameWithGratings(name="my_frame", n_input=5, n_output=5)
my_frame_lo = my_frame.Layout()

Design and simulation

We use IPKISS and its circuit solver Caphe to construct a MZI lattice and simulate its behaviour (click here for an introduction to Caphe).

Step 1: the trace template

In IPKISS, waveguides are drawn according to a trace template. A trace template defines the cross-section of the waveguide and its simulation model, i.e. the effective and group indexes. Here, we instantiate a trace template from the demolib library, visualize the cross section and extract the simulation parameters, i.e. the effective and group indeces and the center wavelength.

my_tt = pdk.SWG450()
print("n_eff = {}".format(my_tt.CircuitModel().n_eff))
print("n_g = {}".format(my_tt.CircuitModel().n_g))
print("center_wavelength = {}".format(my_tt.CircuitModel().center_wavelength))


n_eff = 2.35
n_g = 4.3
center_wavelength = 1.55

Step 2: the directional coupler

The main ingredient needed to design a MZI is the directional coupler. We import the directional coupler defined in, set the coupling coefficient to 0.5 and visualise it. Then, we have a look at the circuit model of this directional coupler. We extract the S-matrix between λ=1.5μm\lambda = 1.5 \mu m and λ=1.6μm\lambda = 1.6 \mu m and plot the transmission.

dc = pdk.DirectionalCoupler(power_coupling=0.5, target_wavelength=1.55)
dc_lo = dc.Layout()

print("coupler length: {0:.3f}".format(dc.coupler_length))


coupler length: 10.645
dc_cm = dc.CircuitModel()
sim_wavelengths = np.linspace(1.5, 1.6, 201)
sm = dc_cm.get_smatrix(wavelengths=sim_wavelengths)

plt.plot(sim_wavelengths, np.abs(sm['in1', 'out1']) ** 2, label='Straight')
plt.plot(sim_wavelengths, np.abs(sm['in1', 'out2']) ** 2, label='Cross')
plt.xlabel('Wavelength [um]')
plt.ylabel('Transmission [power]')
plt.title('Transmission of the directional coupler')

Step 3: our first MZI

Now we can use this directional coupler to build our first MZI. The delay lengths of our MZI lattice are calculated from neffn_{eff} , ngn_{g} , λcenter\lambda_{center} and our chosen ΔFSR\Delta FSR . With the help of the following formulas:

L=λcenter2ngΔFSRL = \dfrac{\lambda _{center} ^2}{n_g \Delta FSR}

Lπ=λcenter2ngL_{\pi} = \dfrac{\lambda _{center}}{2 n_g}

we can calculate the delay lengths, which are [L[L , 2L2 L , (2L+Lπ)(- 2 L + L_{\pi}) , 2L]- 2 L] .

To make things more readable, our MZI is instantiated from the MZILattice class that can be found in

power_couplings = [0.5, 0.13, 0.12, 0.5, 0.25]
mzi = MZILattice(power_couplings=power_couplings, target_wavelength=1.55, fsr=0.04)
mzi_lay = mzi.Layout()
print("couplings = {}".format(mzi.power_couplings))
print("delay lengths = {}".format(np.round(mzi.delay_lengths, 2)))


couplings = [0.5, 0.13, 0.12, 0.5, 0.25]
delay lengths = [ 14.51  29.02 -29.35 -29.02]

We can now instantiate the circuit model and visualize the transmission of our MZI.

mzi_cm = mzi.CircuitModel()
wavelengths = np.linspace(1.5, 1.6, 200)
S0 = mzi_cm.get_smatrix(wavelengths)

plt.plot(wavelengths, 10 * np.log10(np.abs(S0['in1', 'out1']) ** 2), label="in1 -> out1")
plt.plot(wavelengths, 10 * np.log10(np.abs(S0['in1', 'out2']) ** 2), label="in1 -> out2")
plt.xlim(1.50, 1.60)
plt.ylim(-30, 0)
plt.xlabel('Wavelength [um]')
plt.ylabel('Transmission [dB]')
plt.title('Transmission of the MZI')

Step 4: Creating our cascaded MZI

We are ready to cascade our MZIs. We want to make our final circuit as symmetric as possible and it also has to fit in the frame. By making the connecting waveguides between the MZI’s parametric we can have more control over the overall shape. Here is an example of the connecting waveguide:

height = 100.
width = 170.
shape = i3.Shape([(0., 0.), (0., height / 2), (-width, height / 2), (-width, height)])
wg = i3.RoundedWaveguide(trace_template=my_tt)
wg_lv = wg.Layout(shape=shape, manhattan=True)

Now we put everything together with the help of the specification-based placement solver i3.place_insts of IPKISS, which allows to flip, translate and join our components.

class CascadedMZI(i3.PCell):
    # User properties
    power_couplings = i3.ListProperty(doc="list of coupling factors of each directional coupler")
    target_wavelength = i3.PositiveNumberProperty(default=1.55, doc="Center wavelength")
    phase_error_sigma = i3.NonNegativeNumberProperty(default=0.0, doc="")
    fsr = i3.PositiveNumberProperty(default=0.04, doc="free spectral range")

    # Generated or fixed properties
    trace_template = i3.TraceTemplateProperty()

    def _default_trace_template(self):
        tt = pdk.SiWireWaveguideTemplate()
        return tt

    def _default_power_couplings(self):
        return [0.5, 0.13, 0.12, 0.5, 0.25]

    class Layout(i3.LayoutView):
        width = i3.PositiveNumberProperty(default=180)
        height = i3.PositiveNumberProperty(default=100)

        def _generate_instances(self, insts):
            mzi = MZILattice(power_couplings=self.power_couplings,
            shape = i3.Shape(
                [(0., 0.), (0., self.height / 2), (-self.width, self.height / 2), (-self.width, self.height)])
            tt = pdk.SWG450()  # the desired trace template
            wg = i3.RoundedWaveguide(trace_template=tt)
            wg.Layout(shape=shape, manhattan=True)

            insts += i3.place_insts(
                insts={"mzi1": mzi,
                       "mzi2": mzi,
                       "mzi3": mzi,
                       "wg1": wg,
                       "wg2": wg
                    i3.Place("mzi1", (0, 0)),
                    i3.FlipV("mzi2", "mzi3"),
                    i3.Join([("mzi1:out2", "wg1:in"),
                             ("mzi1:out1", "wg2:in"),
                             ("wg1:out", "mzi2:in2"),
                             ("wg2:out", "mzi3:in1")
            return insts

        def _generate_ports(self, ports):
            ports += i3.expose_ports(self.instances,
                                     {"mzi1:in1": "in1",
                                      "mzi1:in2": "in2",
                                      "mzi2:out1": "out1",
                                      "mzi2:out2": "out2",
                                      "mzi3:out1": "out3",
                                      "mzi3:out2": "out4",
                                      "mzi2:in1": "in3",
                                      "mzi3:in2": "in4", }
            return ports

    class Netlist(i3.NetlistFromLayout):

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

# Instantiate and visualize the layout
cmzi = CascadedMZI(power_couplings=power_couplings, target_wavelength=1.55, fsr=0.04)
cmzi_lv = cmzi.Layout()

# Instantiate the circuit model and visualize the transmission
cmzi_cm = cmzi.CircuitModel()
S = cmzi_cm.get_smatrix(wavelengths)

plt.plot(wavelengths, 10 * np.log10(np.abs(S0['in1', 'out1']) ** 2), "k--", label="MZI 1->1")
plt.plot(wavelengths, 10 * np.log10(np.abs(S0['in1', 'out2']) ** 2), "k-", label="MZI 1->2")

plt.plot(wavelengths, 10 * np.log10(np.abs(S['in1', 'out1']) ** 2), "r", label="Cas MZI 1->1")
plt.plot(wavelengths, 10 * np.log10(np.abs(S['in1', 'out3']) ** 2), "r--", label="Cas MZI 1->3")
plt.xlabel('Wavelength [um]')
plt.ylabel('Transmission [dB]')
plt.xlim([1.50, 1.60])
plt.ylim(-35, 0)
plt.title('Transmission of the cascaded MZI')
  • ../../_images/sphx_glr_plot_cwdm_filter_008.png
  • ../../_images/sphx_glr_plot_cwdm_filter_009.png

Going from the single MZI to the 3 cascaded MZIs, the crosstalk went from 15 dB to 30 dB!

Step 5: Reference waveguide

We will also add a reference waveguide on our chip so we can normalize the measurements of our circuit.

shape = i3.Shape([(90., 150.), (410., 150.)])
wg_ref = i3.RoundedWaveguide(trace_template=my_tt)
wg_ref_lay = wg_ref.Layout(shape=shape)

Step 6: Placing everything in the frame

With the help of picazzo’s PlaceAndAutoRoute (click here for the documentation), we can link ports together and waveguides will be automatically generated between them. Here we place our cascaded MZI lattice and our reference waveguide in the predefined frame and link the ports which should be connected with a waveguide. Any transformation can be defined with child_transformations.

child_cells = {"my_frame": my_frame,
               "casmzi": cmzi,
               "wg": wg_ref}

links = [("my_frame:p_in5", "casmzi:in3"),
         ("my_frame:p_in4", "casmzi:in2"),
         ("my_frame:p_in3", "casmzi:in1"),
         ("my_frame:p_in2", "casmzi:in4"),

         ("my_frame:p_out5", "casmzi:out1"),
         ("my_frame:p_out4", "casmzi:out2"),
         ("my_frame:p_out3", "casmzi:out3"),
         ("my_frame:p_out2", "casmzi:out4"),

         ("my_frame:p_in1", "wg:in"),
         ("wg:out", "my_frame:p_out1")

child_transformations = {"casmzi": i3.Translation((100, 275.0)),
                         "wg": i3.Translation((0.0, -100.))}

chip = PlaceAndAutoRoute(trace_template=my_tt,

chip_lo = chip.Layout(child_transformations=child_transformations, manhattan=True)

Now we can simulate the complete circuit, including the fiber grating couplers. Because gratings are optimised to transmit one pre-defined wavelength, the grating couplers add an envelope to the transmission we obtained before. In our case, the center wavelength is 1.55μm1.55 \mu m .

chip_cm = chip.CircuitModel()
R = chip_cm.get_smatrix(wavelengths)

plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in1', 'my_frame_out1']) ** 2), "k--")

plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in5', 'my_frame_out4']) ** 2), "b--")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in5', 'my_frame_out5']) ** 2), "g--")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in2', 'my_frame_out2']) ** 2), "k--")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in2', 'my_frame_out3']) ** 2), "r--")

plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in3', 'my_frame_out2']) ** 2), "k")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in3', 'my_frame_out3']) ** 2), "r")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in4', 'my_frame_out4']) ** 2), "b")
plt.plot(wavelengths, 10 * np.log10(np.abs(R['my_frame_in4', 'my_frame_out5']) ** 2), "g")

plt.xlabel('Wavelength [um]')
plt.ylabel('Transmission [dB]')
plt.xlim(1.50, 1.60)
plt.ylim(-30, 0)

Finally, we can write our design to GDS and send it to the foundry to be fabricated.


Influence of variations/inaccuracies

Phase errors

One source of inaccuracies are the waveguides themselves: roughness and variations of the sidewall, thickness or etch depth of the waveguides will give rise to fluctuations of the effective index in the waveguide. Therefore, the differences in delay length between the optical paths will not be exactly as planned anymore. This will give rise to higher cross-talk. We consider variations coming from random, short-range changes in the waveguide width/thickness. In silicon waveguides, these originate mainly from sidewall roughness.

Athermal MZI

CWDM filter

The model we use to estimate the stochastic phase errors is proposed by Goh et al [1]. It is calculated from the RMS amplitude of the waveguide width variations σ(δw)\sigma(\delta w) with a correlation length LcorrL_{corr} . In this model, the phase errors will have a normal distribution with an RMS given by

σ2(δϕ)=(2.πλ.nwgw.σ(δw))2.Lwg.Lcorr\sigma^2 (\delta \phi) = \left( \dfrac{2. \pi}{\lambda} . \dfrac{\partial n_{wg}}{\partial w}. \sigma(\delta w) \right)^2 . L_{wg} . L_{corr}

The length LwgL_{wg} of the waveguide also plays an important role. The average magnitude of the phase errors will increase with Lwg\sqrt{L_{wg}} . This means that longer waveguides will contribute more. We can enable phase errors in the waveguide template and specify the standard deviation of the waveguide width:

def get_response():
    cwdm = CascadedMZI(power_couplings=[0.5, 0.13, 0.12, 0.5, 0.25],
    cwdm_cm = cwdm.CircuitModel()
    S = cwdm_cm.get_smatrix(wavelengths=wavelengths)
    return S['in1', 'out1'], S['in1', 'out3']
def get_xtalk_dB(spectrum, wl_range=[1.545, 1.555]):
    """Calculate the crosstalk in a certain band."""
    idx_min = np.argmin(np.abs(wavelengths - wl_range[0]))
    idx_max = np.argmin(np.abs(wavelengths - wl_range[1]))
    max_value = np.max(np.abs(spectrum[idx_min:idx_max]))
    return 20 * np.log10(np.abs(max_value))  # return the max cross-talk value in dB
def get_crosstalk(get_response_fn=get_response, num_samples=10, plot=False):
    # Function that sweeps over a number of samples for spectral responses (with plot) and extract maximum cross-talk values.
    xt = np.zeros((num_samples,))

    for x in xrange(num_samples):
        t11, t12 = get_response_fn()

        xt[x] = get_xtalk_dB(t11)
        if plot:
            plt.plot(wavelengths, 20 * np.log10(np.abs(t11)), 'r', alpha=0.2)
            plt.plot(wavelengths, 20 * np.log10(np.abs(t12)), 'r', alpha=0.2)
            plt.hlines(xt[x], wavelengths[0], wavelengths[-1], 'g', alpha=0.5)
            plt.xlabel('Wavelength [um]')
            plt.ylabel('Transmission [dB]')
            plt.title('Transmission of the CWDM with phase error')
            plt.xlim(1.50, 1.60)
            plt.ylim(-30, 0)

    return xt
num_samples = 5

fig2 = plt.figure()
get_crosstalk(num_samples=num_samples, plot=True)

Using a foundry PDK

The same design we created in this example using components from demolib can be adapted to use components from other PDKs as well. As an example, we used components from the Australian Silicon Photonics (ASP) PDK to design and fabricate the chip. Here are the designed and the fabricated chip:

ASP designed chip

Chip designed with ASP PDK

ASP fabricated chip

Chip fabricated with ASP PDK