# Example of an Athermal Mach-Zehnder Interferometer¶

This is an example of an Athermal MZI.

Note: to run this example you will also need to download mzi.py.

Result

In this example, we construct an athermal Mach-Zehnder Interferometer based on the paper of S. Dwivedi et al [DwivediPTL2013].

Athermal MZI

Illustrates

1. how to use the PICAZZO DirectionalCoupler cell
2. how to design a component, run a Caphe simulation and visualize the result
3. how to incorporate higher-level design algorithms into a PCell

## Operation principle and design¶

This device relies on the polarisation and temperature dependence in silicon photonic waveguides: the difference in temperature dependence of the TE and TM modes in a strip waveguide is exploited in order to make a temperature independent MZI. Since the effective indices of the TE and TM modes are very different at equal temperature, the Free Spectral Range (FSR) of the MZI will be defined not just by the physical path length difference but also by the difference between the group indices of both modes. Athermal behaviour can be obtained by carefully choosing the lengths of both arms. Since the second-order dispersion in these waveguides is relatively large, it the athermal behavious is obtained within a certain wavelength range in which the FSR does not vary much with temperature.

A detailed discussion is given in [DwivediPTL2013].

In the paper, results are obtained with waveguides with a nominal width of 600nm, for a center wavelength of 1540nm, with the following properties calculated in the paper.

 $n_{eff,TE}$ $2.6$ effective index of the TE mode $n_{eff,TM}$ $1.79$ effective index of the TM mode $n_{g,TE}$ $4.02$ group index of the TE mode $n_{g,TM}$ $3.73$ group index of the TM mode $\frac{dn}{dT}_{TE}$ $2.055 \times 10^{-4} /K$ temperature dependence of effective index of TE mode $\frac{dn}{dT}_{TM}$ $1.3 \times 10^{-4} /K$ temperature dependence of effective index of TM mode $\lambda_0$ $1.54 \mu m$ center wavelength for which the device is athermal $FSR$ $10 \times 10^{-3} \mu m$ desired free spectral range of the MZI

## Constructing the Mach-Zehnder Interferometer¶

We use the MZI device defined in the Advanced Caphe Tutorial.

However, we modify the Caphe Model of the MZIArmWaveguide in order to capture the temperature and width dependence of the waveguide mode. A temperature-dependent effective index is calculated and used to define the S-matrix of the arm, taking into account group index, $\dfrac{dn}{dT}$ and $\dfrac{dn}{dw}$ . In a first-order approximation, we let $\dfrac{dn_g}{dT} \approx \dfrac{dn_{eff}}{dT}$ .

import ipkiss3.all as i3
import numpy as np
import matplotlib.pylab as plt
import mzi

class WGModel(i3.CompactModel):
parameters = [
'n_eff',
'd_temperature',
'reference_temperature',
'dn_dT',
'd_width',
'dn_dw',
'center_wavelength',
'dneff',
'length',
]

terms = [
i3.OpticalTerm(name='in'),
i3.OpticalTerm(name='out'),
]

def calculate_smatrix(parameters, env, S):
length = parameters.length
wavelength = env.wavelength

n_eff = (
parameters.n_eff +
(parameters.d_temperature - parameters.reference_temperature) * parameters.dn_dT +
parameters.d_width * parameters.dn_dw +
(wavelength - parameters.center_wavelength) * parameters.dneff
)

S['in', 'out'] = S['out', 'in'] = np.exp(1j * 2 * np.pi * length * n_eff / wavelength)

class MziArmWaveguide(mzi.MziArmWaveguide):

# ...

class CircuitModel(i3.CircuitModelView):
n_eff = i3.PositiveNumberProperty(default=2.86, doc="Effective index at center wavelength")
n_g = i3.PositiveNumberProperty(default=4.0,
doc="Group index of the circuit model for the given center wavelength")
loss_dB_m = i3.NonNegativeNumberProperty(default=0, doc="Loss in dB/m")
center_wavelength = i3.PositiveNumberProperty(default=1.55,
doc="Center wavelength for which n_eff and n_g are defined")

length = i3.NonNegativeNumberProperty(doc="Total length of the arm")
d_temperature = i3.NumberProperty(default=0, doc="Temperature of the chip w.r.t. the reference temperature")
reference_temperature = i3.NumberProperty(default=300, doc="Reference temperature for the model")
dn_dT = i3.NumberProperty(default=0.01, doc="Change of n_eff w.r.t. temperature")
dng_dT = i3.NumberProperty(default=0.001, doc="Change of ng w.r.t. temperature")
d_width = i3.NumberProperty(default=0, doc="Width deviation from central width for which n_eff is defined")
dn_dw = i3.NumberProperty(default=0.01, doc="Change of n_eff w.r.t. width deviation")

dneff = i3.LockedProperty(doc="Calculated, first-order derivative of n_eff due to dispersion")

# Get length from the layout view.
def _default_length(self):
return self.layout_view.length

def _default_dneff(self):
ng_actual = self.n_g + (self.d_temperature - self.reference_temperature) * self.dng_dT
return -(ng_actual - self.n_eff) / self.center_wavelength

def _default_dngdT(self):
return self.dn_dT  # first-order approximation

def _generate_model(self):

return WGModel(length=self.length,
n_eff=self.n_eff,
dneff=self.dneff,
dn_dw=self.dn_dw,
dn_dT=self.dn_dT,
d_width=self.d_width,
center_wavelength=self.center_wavelength,
d_temperature=self.d_temperature,
reference_temperature=self.reference_temperature)


## Designing the component and running the simulation¶

As a first step in the design procedure, we define the input parameters:

n_eff_TE = 2.6
n_eff_TM = 1.79
n_g_TE = 4.02
n_g_TM = 3.73
dn_dT_TE = 2.055*1e-4
dn_dT_TM = 1.3*1e-4
center_wavelength = 1.54
FSR = 10*1e-3


Secondly, the arm lengths and actual FSR are derived in accordance with [DwivediPTL2013].

L_TE = center_wavelength**2 / (n_g_TM * dn_dT_TE / dn_dT_TM-n_g_TE) / FSR
L_TM = center_wavelength**2 / (n_g_TM-n_g_TE*dn_dT_TM / dn_dT_TE) / FSR

calculated_FSR = center_wavelength**2 / (n_g_TM * L_TM-n_g_TE * L_TE)


The next step is to define the 2x2 couplers and their models which will function as integrated splitter/polarisation-rotator (SPR) devices ([DwivediPTL2013], [LiuOL2011]). These SPR devices rely on an asymmetric waveguide cladding for their operation, see [LiuOL2011]. The difference in effective index between the supermodes in the coupler (the property delta_n_eff of the splitter and combiner models), is 0.0385, which can be derived from the paper as $L_c \Delta\beta_{eff} = \dfrac{2\pi}{4}$ (for 50-50 coupling) or $\Delta n_{eff} = \dfrac{\lambda}{4L_c}$ . The coupling length $L_c$ was found to be $10 \mu m$ .

Since BendDirectionalCoupler.SimpleCircuitModel may not be the default model of BendDirectionalCoupler, we set the created model to be the default model.

from picazzo3.wg.dircoup import BendDirectionalCoupler

my_splitter = BendDirectionalCoupler(coupler_length=0.25*1.55/0.0385)
splitter_cm = my_splitter.SimpleCircuitModel(delta_n_eff=0.0385)
my_splitter.set_default_view(splitter_cm) # Since SimpleCircuitModel may not be the default, we set it here

my_combiner = BendDirectionalCoupler(coupler_length=0.25*1.55/0.0385)
combiner_cm = my_combiner.SimpleCircuitModel(delta_n_eff=0.0385)
my_combiner.set_default_view(combiner_cm)


Now we construct the arms of the MZI, using TE mode in the lower arm and TM mode in the upper arm, using the parameters defined above.

my_arm1 = MziArmWaveguide(name="my_mzi_arm1")
my_arm1.CircuitModel(n_eff=n_eff_TM,  # effective index
n_g=n_g_TM,      # group index
dn_dT=dn_dT_TM,  # thermo-optic coefficient
length=L_TM)     # length

my_arm2 = MziArmWaveguide(name="my_mzi_arm2")
my_arm2.CircuitModel(n_eff=n_eff_TE,
n_g=n_g_TE,
dn_dT=dn_dT_TE,
length=L_TE)


Defining the MZI and its model are now straightforward:

my_mzi = mzi.MZI(arm1=my_arm1,
arm2=my_arm2,
splitter=my_splitter,
combiner=my_combiner)

my_mzi_cm = my_mzi.CircuitModel()


For the simulation, we run a simulation over the 1500-1600nm wavelength range, looping over 3 temperatures (30, 50 and 80 $\deg C$ ) and 2 different cases: the nominal case and a case where one arm length is 10um longer then designed. This allows to demonstrate the athermal behaviour (in the nominal case) and will reproduce the results in [DwivediPTL2013].

First, we specify the wavelengths over which to sweep:

wavelengths = np.linspace(1.5, 1.6, 5001)


Next, we prepare a matplotlib plot with two sub-graphs: one for the nominal case and one for the case with arm length deviation. This is done using the subplots feature in matplotlib, which allows to create multiple axes on one canvas.

f, (ax1, ax2) = plt.subplots(2, 1, sharex=True)

ax1.set_title("MZI transmission with optimal length")
ax1.set_xlabel("Wavelength ($\mu m$)")
ax2.set_title("MZI transmission with 10um length error")
ax2.set_xlabel("Wavelength ($\mu m$)")

for delta_L, ax in [(0, ax1), (10.0, ax2)]:
for delta_T in [30, 50, 80]:

print("simulating for temperature difference {0}".format(delta_T))
# adapt temperature in arm models
my_arm1.get_default_view("CircuitModel").d_temperature = delta_T
my_arm2_cm = my_arm2.get_default_view("CircuitModel")
my_arm2_cm.set(d_temperature=delta_T,
length=L_TE + delta_L
)

# Simulate
trans_model = my_mzi_cm.get_smatrix(wavelengths=wavelengths)

# Plot the result on the given axis
ax.plot(wavelengths, 10*np.log10(np.abs(trans_model['in1', 'out1']) ** 2), label=r'$\Delta T={}$'.format(delta_T))

plt.legend()
plt.show()


Out:

simulating for temperature difference 30
simulating for temperature difference 50
simulating for temperature difference 80
simulating for temperature difference 30
simulating for temperature difference 50
simulating for temperature difference 80


## Incorporating the design logic into the device¶

Now that we have a working proof-of-concept, we can embed the design algorithm in the PCell, so that we obtain a “smart” PCell which can derive its own physical parameters (lengths) from functional specifications (FSR, center wavelength) and physical properties (effective index, …).

The way to do this is by sub-classing the WaveguideMZI class, adding some design algorithms but re-using the functionality of WaveguideMZI. WaveguideMZI defines its own arms, so that we don’t need to define them anymore in our main script.

We start by defining a new class and defining its properties which will represent the functional specifications:

from mzi import WaveguideMZI

class AthermalMZI(WaveguideMZI):
# functional specs
center_wavelength = i3.PositiveNumberProperty(default=1.54)
FSR = i3.PositiveNumberProperty(default=10*1e-3)


We also add the physical properties (effective index, group index and their temperature variation) and the derivation of the length of the lower arm (TE mode) and upper arm (TM mode).

class AthermalMZI(WaveguideMZI):

#...

# physical properties
n_eff_TE = i3.PositiveNumberProperty(default=2.6)
n_eff_TM = i3.PositiveNumberProperty(default=1.79)
n_g_TE = i3.PositiveNumberProperty(default=4.02)
n_g_TM = i3.PositiveNumberProperty(default=3.73)
dn_dT_TE = i3.PositiveNumberProperty(default=2.055*1e-4)
dn_dT_TM = i3.PositiveNumberProperty(default=1.3*1e-4)

TE_length = i3.PositiveNumberProperty()
TM_length = i3.PositiveNumberProperty()

def _default_TE_length(self):
return self.center_wavelength**2/(self.n_g_TM*self.dn_dT_TE/self.dn_dT_TM-self.n_g_TE)/self.FSR

def _default_TM_length(self):
return self.center_wavelength**2/(self.n_g_TM-self.n_g_TE*self.dn_dT_TM/self.dn_dT_TE)/self.FSR


Using _default_TE_length and _default_TM_length, we calculate the optimal lengths for the TE and TM arm, respectively. These will then be used in the AthermalMZI’s CircuitModel in _default_arm1 and _default_arm2 to generate the CircuitModel for both arms.

We first make AthermalMZI define its own splitter and combiner components. The length of the couplers is set to 10um as in the paper. Also the coupler could be developed further into a PCell which derives its own properties from functional specifications such as the splitting ratio. We will not do that within the scope of this example.

def _default_splitter(self):
return BendDirectionalCoupler(name="{}_splitter".format(self.name),
coupler_length=10.0)
def _default_combiner(self):
return BendDirectionalCoupler(name="{}_combiner".format(self.name),
coupler_length=10.0)


Then, we subclass the CircuitModel from WaveguideMZI to specify the models of the splitter, combiner and both arms. We pass the physical properties and the desired lengths to the models of the arms as well. These models will now automatically be used for simulation.

class AthermalMZI(WaveguideMZI):

# ...

class CircuitModel(WaveguideMZI.CircuitModel):
d_temperature = i3.NumberProperty(default=0, doc="Temperature of the chip w.r.t. the reference temperature")
reference_temperature = i3.NumberProperty(default=300, doc="Reference temperature for the model")

def _default_splitter(self):
cmspl = self.splitter
cmspl.contents.set(delta_n_eff=0.0385)
return cmspl

def _default_combiner(self):
cmcmb = self.combiner
cmcmb.contents.set(delta_n_eff=0.0385)
return cmcmb

def _default_arm1(self):
return self.cell.arm1.CircuitModel(n_eff=self.n_eff_TM,
n_g=self.n_g_TM,
dn_dT=self.dn_dT_TM,
length=self.TM_length,
d_temperature=self.d_temperature,
reference_temperature=self.reference_temperature)

def _default_arm2(self):
return self.cell.arm2.CircuitModel(n_eff=self.n_eff_TE,
n_g=self.n_g_TE,
dn_dT=self.dn_dT_TE,
length=self.TE_length,
d_temperature=self.d_temperature,
reference_temperature=self.reference_temperature)

# Now the algorithm for deriving the device design from functional specifications and physical properties is integrated within a ‘smarter’ PCell, the main file becomes a lot shorter and cleaner. Furthermore, the AthermalMZI class could be further subclassed to alter the design algorithm without needing to rewrite everything.


## Layout¶

Relying on the Layout view already defined by the basic MZI class, we can also define the Layout view of our AthermalMZI class, and export it to a GDSII file.

We need to override the layout views of both arms, splitter and combiner in the AthermalMZI.Layout view class.

For the arms, we specify the ports of the splitter and combiner, so that the arm can layout itself. In order to arrive at the correct lengths for athermal behaviour, we first take the trace length of the basic arm. Then we adjust the length to the desired length (TE or TM mode length). When the distance between the splitter and combiner is too large for the calculated lengths to fit, we throw an exception and the program will exit.

class AthermalMZI(WaveguideMZI):

# ...

class Layout(WaveguideMZI.Layout):

def _default_arm1(self):
layout = self.cell.arm1.get_default_view(self)
layout.set(splitter_port=self.splitter.ports["E0"].transform_copy(self.splitter_transformation),
combiner_port=self.combiner.ports["W0"].transform_copy(self.combiner_transformation),
south_arm=True)

# adjust the length of the arm based on the desired length and the length as it is currently (connecting splitter and combiner)
base_length = layout.length
el = self.TE_length-base_length
if el<0.0:
raise ValueError("distance between couplers is too long for the calculated TE length")
layout.extra_length = el
return layout

def _default_arm2(self):
layout = self.cell.arm2.get_default_view(self)
layout.set(splitter_port=self.splitter.ports["E1"].transform_copy(self.splitter_transformation),
combiner_port=self.combiner.ports["W1"].transform_copy(self.combiner_transformation))
# adjust the length of the arm based on the desired length and the length as it is currently (connecting splitter and combiner)
base_length = layout.length
el = self.TM_length-base_length
if el<0.0:
raise ValueError("distance between couplers is too long for the calculated TM length")
layout.extra_length = el
return layout


Then we specify the layout views for the splitter and combiner, matching the data in the paper: 600nm and 320nm waveguide width and a 200nm gap. We make the coupler bends cover a 30 degree angle.

The effective index and length properties were calculated for 600nm waveguide width for both arms. Since the lower waveguide of the coupler has a 320nm width, we therefore add 5um long tapers to all of the coupler ports to have a uniform output width. This is done using the TaperPorts container.

class AthermalMZI(WaveguideMZI):

# ...

class Layout(WaveguideMZI.Layout):

# ...

def _default_splitter(self):
lv = self.cell.splitter.get_default_view(i3.LayoutView)
lv.contents.set(coupler_spacing=0.5*0.6+0.2+0.5*0.32,  # center-to-center spacing
bend_angle=30.0)
lv.contents.trace_template1.core_width = 0.32
lv.contents.trace_template2.core_width = 0.6
lv.set(transition_length=5.0)
return lv

def _default_combiner(self):
lv = self.cell.combiner.get_default_view(i3.LayoutView)
lv.contents.set(coupler_spacing=0.5*0.6+0.2+0.5*0.32,  # center-to-center spacing
bend_angle=30.0)
lv.contents.trace_template1.core_width = 0.32
lv.contents.trace_template2.core_width = 0.6
lv.set(transition_length=5.0)
return lv


Now, the entire class looks like this:

class AthermalMZI(WaveguideMZI):
# functional specs
center_wavelength = i3.PositiveNumberProperty(default=1.54, doc="wavelength for which the MZI will be athermal [um]")
FSR = i3.PositiveNumberProperty(default=10*1e-3, doc="free spectral range [um]")

# physical properties
n_eff_TE = i3.PositiveNumberProperty(default=2.6, doc="effective index of the TE mode (bottom arm)")
n_eff_TM = i3.PositiveNumberProperty(default=1.79, doc="effective index of the TM mode (top arm)")
n_g_TE = i3.PositiveNumberProperty(default=4.02, doc="group index of the TE mode")
n_g_TM = i3.PositiveNumberProperty(default=3.73, doc="group index of the TM mode")
dn_dT_TE = i3.PositiveNumberProperty(default=2.055*1e-4, doc="effective index variation with temperature for the TE mode [/degK]")
dn_dT_TM = i3.PositiveNumberProperty(default=1.3*1e-4, doc="effective index variation with temperature for the TM mode [/degK]")

TE_length = i3.PositiveNumberProperty()
TM_length = i3.PositiveNumberProperty()

def _default_TE_length(self):
return self.center_wavelength**2/(self.n_g_TM*self.dn_dT_TE/self.dn_dT_TM-self.n_g_TE)/self.FSR

def _default_TM_length(self):
return self.center_wavelength**2/(self.n_g_TM-self.n_g_TE*self.dn_dT_TM/self.dn_dT_TE)/self.FSR

delta_length = i3.LockedProperty()

def _default_delta_length(self):
return self.TE_length-self.TM_length

def _default_splitter(self):
from picazzo3.traces.wire_wg import WireWaveguideTemplate
from picazzo3.container.transition_ports import AutoTransitionPorts
dc = BendDirectionalCoupler(name="{}_splitter".format(self.name),
coupler_length=10.0,
trace_template1=WireWaveguideTemplate(),
trace_template2=WireWaveguideTemplate()
)
tapered_dc = AutoTransitionPorts(name="{}_tapered".format(dc.name), contents=dc, trace_template=dc.trace_template2)
return tapered_dc

def _default_combiner(self):
from picazzo3.traces.wire_wg import WireWaveguideTemplate
from picazzo3.container.transition_ports import AutoTransitionPorts
dc = BendDirectionalCoupler(name="{}_combiner".format(self.name),
coupler_length=10.0,
trace_template1=WireWaveguideTemplate(),
trace_template2=WireWaveguideTemplate()
)
tapered_dc = AutoTransitionPorts(name="{}_tapered".format(dc.name), contents=dc, trace_template=dc.trace_template2)
return tapered_dc

class CircuitModel(WaveguideMZI.CircuitModel):
d_temperature = i3.NumberProperty(default=0, doc="Temperature of the chip w.r.t. the reference temperature")
reference_temperature = i3.NumberProperty(default=300, doc="Reference temperature for the model")

def _default_splitter(self):
cmspl = self.cell.splitter.get_default_view("CircuitModel")
cmspl.contents.set(delta_n_eff=0.0385)
return cmspl

def _default_combiner(self):
cmcmb = self.cell.combiner.get_default_view("CircuitModel")
cmcmb.contents.set(delta_n_eff=0.0385)
return cmcmb

def _default_arm1(self):
return self.cell.arm1.CircuitModel(n_eff=self.n_eff_TM,
n_g=self.n_g_TM,
dn_dT=self.dn_dT_TM,
length=self.TM_length,
d_temperature=self.d_temperature,
reference_temperature=self.reference_temperature)

def _default_arm2(self):
return self.cell.arm2.CircuitModel(n_eff=self.n_eff_TE,
n_g=self.n_g_TE,
dn_dT=self.dn_dT_TE,
length=self.TE_length,
d_temperature=self.d_temperature,
reference_temperature=self.reference_temperature)

class Layout(WaveguideMZI.Layout):

def _default_arm1(self):
layout = self.cell.arm1.get_default_view(self)
layout.set(splitter_port = self.splitter.ports["E0"].transform_copy(self.splitter_transformation),
combiner_port = self.combiner.ports["W0"].transform_copy(self.combiner_transformation),
south_arm = True)
# adjust the length of the arm based on the desired length and the length as it is currently (connecting splitter and combiner)
base_length = layout.length
el = self.TE_length-base_length
if el<0.0:
raise ValueError("distance between couplers is too long for the calculated TE length")
layout.extra_length = el
return layout

def _default_arm2(self):
layout = self.cell.arm2.get_default_view(self)
layout.set(splitter_port = self.splitter.ports["E1"].transform_copy(self.splitter_transformation),
combiner_port = self.combiner.ports["W1"].transform_copy(self.combiner_transformation))
# adjust the length of the arm based on the desired length and the length as it is currently (connecting splitter and combiner)
base_length = layout.length
el = self.TM_length-base_length
if el<0.0:
raise ValueError("distance between couplers is too long for the calculated TM length")
layout.extra_length = el
return layout

def _default_splitter(self):
lv = self.cell.splitter.get_default_view(i3.LayoutView)
lv.contents.set(coupler_spacing = 0.5*0.6+0.2+0.5*0.32, # center-to-center spacing
bend_angle = 30.0)
lv.contents.trace_template1.core_width = 0.32
lv.contents.trace_template2.core_width = 0.6
lv.set(transition_length=5.0)
## Add tapers to the ports of the splitter to match the second waveguide width (trace_template2)
#from picazzo.container.taper_ports import TaperDeepPorts
#end_wg_def = lv.trace_template2
#lv_tapered = TaperDeepPorts(structure=lv, end_wg_def=end_wg_def, taper_length=5.0)
return lv

def _default_combiner(self):
lv = self.cell.combiner.get_default_view(i3.LayoutView)
lv.contents.set(coupler_spacing = 0.5*0.6+0.2+0.5*0.32, # center-to-center spacing
bend_angle = 30.0)
lv.contents.trace_template1.core_width = 0.32
lv.contents.trace_template2.core_width = 0.6
lv.set(transition_length=5.0)

# Add tapers to the ports of the combiner to match the second waveguide width (trace_template2)
#from picazzo.container.taper_ports import TaperDeepPorts
#end_wg_def = lv.trace_template2
#lv_tapered = TaperDeepPorts(structure=lv, end_wg_def=end_wg_def, taper_length=5.0)
return lv


In the main file, it now takes only three lines of code to instantiate the Athermal MZI’s layout view and write it to a GDSII file: We seperate the splitter and combiner by $120 \mu m$ in X.

combiner_transform = i3.Translation((120, 0))
my_mzi = AthermalMZI()
my_mzi_lay = my_mzi.Layout(combiner_transformation=combiner_transform)
my_mzi_lay.write_gdsii("athermal.gds")


The resulting layout is plotted here.

my_mzi_lay.visualize(annotate=True)

 [DwivediPTL2013] (1, 2, 3, 4, 5) Dwivedi, H. D’heer and W. Bogaerts, “A compact All-Silicon Temperature Insensitive Filter for WDM and Bio-Sensing Applications”, Photonics Technology Letters 25(22), Nov 15 2013
 [LiuOL2011] (1, 2) Liu, Y. Ding, K. Yvind and J.M. Hvam, “Efficient and compact TE-TM polarization converter built on silicon-on-insulator platform with a simple fabrication process”, Optics Letters 36(7), Apr 1 2011

To run this example you will also need to download mzi.py.