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

## Introduction¶

Objective

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 $\Delta FSR = 0.04 \mu m$ , around a center wavelength $\lambda _c = 1.55 \mu m$ , as shown below.

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 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 mzi.py, 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 frame.py. 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()
my_frame_lo.visualize_2d(annotate=True)


## 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()
my_tt.Layout().cross_section().visualize()
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))


Out:

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 dir_coupler.py, 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 $\lambda = 1.5 \mu m$ and $\lambda = 1.6 \mu m$ and plot the transmission.

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

dc_lo.visualize(annotate=True)
print("coupler length: {0:.3f}".format(dc.coupler_length))


Out:

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.figure()
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')
plt.legend()
plt.show()


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 $n_{eff}$ , $n_{g}$ , $\lambda_{center}$ and our chosen $\Delta FSR$ . With the help of the following formulas:

$L = \dfrac{\lambda _{center} ^2}{n_g \Delta FSR}$

$L_{\pi} = \dfrac{\lambda _{center}}{2 n_g}$

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

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

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()
mzi_lay.visualize(annotate=True)
print("couplings = {}".format(mzi.power_couplings))
print("delay lengths = {}".format(np.round(mzi.delay_lengths, 2)))


Out:

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.figure()
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')
plt.legend()
plt.show()


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)
wg_lv.visualize(annotate=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()
tt.Layout(core_width=self.core_width)
tt.CircuitModel(phase_error_sigma=self.phase_error_sigma)
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,
target_wavelength=self.target_wavelength,
phase_error_sigma=self.phase_error_sigma,
fsr=self.fsr)
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
tt.CircuitModel(phase_error_sigma=self.phase_error_sigma)
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
},
specs=[
i3.Place("mzi1", (0, 0)),
i3.FlipH("wg2"),
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):
pass

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()
cmzi_lv.visualize()

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

plt.figure()
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')
plt.legend()
plt.show()


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)
wg_ref_lay.visualize(annotate=True)


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,
child_cells=child_cells,
links=links,
)

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


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 \mu m$ .

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

plt.figure()
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)
plt.show()


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

chip_lo.write_gdsii("cascaded_mzi.gds")


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

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 $\sigma(\delta w)$ with a correlation length $L_{corr}$ . In this model, the phase errors will have a normal distribution with an RMS given by

$\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 $L_{wg}$ of the waveguide also plays an important role. The average magnitude of the phase errors will increase with $\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],
target_wavelength=1.55,
fsr=0.04,
phase_error_sigma=0.01)
cwdm.Layout()
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)
plt.show()


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

Chip designed with ASP PDK

Chip fabricated with ASP PDK