Example of an athermal Mach-Zehnder Interferometer

Result

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

Schematic of an athermal MZI using two polarisations

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

Files (see: samples/ipkiss3/samples/athermal_mzi)

There are 3 files contained in this sample.

  • execute.py : first executable example: illustrating the principle, device definition and simulation
  • execute2.py : second executable example: incorporating the design logical into a single PCell class
  • mzi.py : Our PCell definition of a Mach-Zehnder Interferometer (MZI) and a MZI waveguide arm.
  • athermal_mzi.py : MZI with integrated athermal design algorithm

How to run this example

To run the example, run ‘execute.py’ and ‘execute2.py’

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 * 10^{-4} /K\) temperature dependence of effective index of TE mode
\(\frac{dn}{dT}_{TM}\) \(1.3 * 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 * 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, \(\frac{dn_{eff}}{dT}\) and \(\frac{dn}{dw}\). In a first-order approximation, we let \(\frac{dn_g}{dT} \simeq \frac{dn_{eff}}{dT}\).

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'] = exp(1j * 2 * pi * length * n_eff / wavelength)

 class MziArmWaveguide(__MziArm__):

   ...

  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\cdot\Delta \beta_{eff} = \frac{2\pi}{4}\) (for 50/50 coupling) or \(\Delta n_{eff} = \frac{\lambda}{4L_c}\). The coupling length \(L_c\) was found to be 10um.

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

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(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))

The result is shown here:

Transmission spectrum of an athermal MZI at different temperatures, for the nominal design and for an off-target design.

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:

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 :code:_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

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:

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

We separate the splitter and combiner by 120um in X. The resulting layout is plotted here:

Layout (GDSII) of an athermal MZI
[DwivediPTL2013](1, 2, 3, 4, 5)
  1. 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)
  1. 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