Step 3: Using ports and routes

Result

When waveguides are used to connect components, often we do not want to manually calculate all control points needed to draw a path between the two components. the exact shape along which the waveguides are drawn is not always crucial. In that case, it is convenient to use routing algorithms to calculate those routes automatically based on the position where the waveguides leave the components. In IPKISS the position (and angle) where the waveguides leave a component can be defined through ports. A convenient shape that connects two ports is called a route. In this example, we will introduce ports and routes to create a Ring-Loaded Mach-Zehnder Interferometer (RLMZI). This is a Mach-Zehnder Interferometer where both arms contain a ring resonator. This is a very good example to illustrate how to connect components, in this case the splitter, the rings, and the combiner.

Note

In this part of the tutorial, we do not yet round the bends. This will be done in the last part (step 4) of this tutorial.

Routes and ports

Routes and ports to create a RLMZI.

Illustrates

  1. How to use ports

  2. How to use routes

Files (see: tutorials/layout_advanced/03-rlmzi)

There are four files contained in this step.

  • ring.py : this is a self-contained python file that contains a description of our ring resonator.

  • dircoup.py : this is a self-contained python file that contains a description of a directional coupler PCell.

  • rlmzi.py : this is a self-contained python file that contains a description of a RLMZI PCell.

  • execute.py : this is the file that is executed by python. It instantiates an rlmzi and performs operations on it.

How to run this example

To run the example, run ‘execute.py’ from the command line

Defining ports to connect components together

Ports are used in layout views of components to define a location on the layout where other components can connect. Ports contain many properties that specify this connection such as:

  • position: coordinate in the plane (Coord2),

  • angle: outward pointing angle in the plane (azimuth), if applicable,

  • direction: directionality (in, out, both, none),

  • name: a name for the port so it can be easily referred to.

Ports are added in a Layout view using the predefined function def _generate_ports(self, prts). Let’s first see how this is done for the RingResonator class.

class RingResonator(i3.PCell):

    # ...

    class Layout(i3.LayoutView):

        #...

         def _generate_instances(self, insts):
             # 6. Placing a ring and bus instance in our layout.
             insts += i3.SRef(name="ring", reference=self.ring)
             insts += i3.SRef(name="bus", reference=self.bus)
             return insts


         def _generate_ports(self, ports):
             # 7. Add ports
             ports += self.instances["bus"].ports
             return ports

Inside _generate_ports of RingResonator, we reuse the ports of the bus waveguide. We take the layout instance of the bus waveguide by using the self.instances. self.instances returns all the instances that have been placed in _generate_instances. The default port names of a waveguide are in and out. We do not modify these names here, so the port names will be reused.

If you need to define new ports, you can create them from scratch using i3.OpticalPort. In that case, you have to specify the name, position and angle of the port.

You can also find a description of ports in (Ports).

We have now added two ports (named in and out) to the Layout view of the Ringresonator class. In the BentDirectionalCoupler class we added ports in a similar way. As depicted in the illustration those ports are called :in_1, in_2, out_1, out_2. We now move on to create a hierarchical cell that contains two directional couplers and two rings. To connect the ports, we will use Routes. Routes use the information contained in the ports in order to calculate the shape along which the connecting waveguides are drawn.

Using Routes

As depicted in the illustration, an RLMZI is composed of four components (splitter, coupler, ring1 and ring2) and four waveguides that connect all these components. In this section we will use routes to connect the four components. The first step is to declare all the components and the waveguides at the PCell level:

class RingLoadedMZI(i3.PCell):

    """ A Mach-Zehnder with a ring resonator in each arm
    """
    _name_prefix = "MZI"
    # 1. Define the rings and splitters as child cells.
    # We add a type restriction so that the passed class would inherit from RingResonator and BentDirectionalCoupler repectively.

    ring1 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    ring2 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    splitter = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))
    combiner = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))

    # 2. We define a waveguide template and the waveguide cells
    wg_template = i3.WaveguideTemplateProperty(default=i3.TECH.PCELLS.WG.DEFAULT)
    wgs = i3.ChildCellListProperty(doc="list of waveguides")

    #3. We first define the 4 waveguides that we need at the PCELL level.
    def _default_wgs(self):
        w1 = i3.Waveguide(name=self.name+"_w1", trace_template=self.wg_template)
        w2 = i3.Waveguide(name=self.name+"_w2", trace_template=self.wg_template)
        w3 = i3.Waveguide(name=self.name+"_w3", trace_template=self.wg_template)
        w4 = i3.Waveguide(name=self.name+"_w4", trace_template=self.wg_template)
        return w1, w2, w3, w4
  1. Here we have defined the splitter, coupler, ring1 and ring2 as ChildCellProperties, making their respective views available throughout the active design. Also we added a restriction using i3.RestrictType. RestrictType checks that the value assigned to this property inherits from a given type. In this case, we want to be sure that ring1 and ring2 inherit from RingResonator and splitter and combiner inherit from BentDirectionalCoupler. i3.RestrictType(RingResonator), for instance, ensures that the property should be a RingResonator, or any class that inherits from RingResonator. In this way, we can safely assume that all functions and properties of the RingResonator are available for the RLMZI.

  2. We define wg_template, and use the default waveguide template of our TECH file.

#. The connecting waveguides are also PCells. When many wavegudies are used to connect the components inside a cell, it is often good practice to combine them in a single property. This is a ChildCellListProperty (as opposed to a ChildCellProperty). We define wgs as the property containing all our waveguides, and use the _default_wgs method to generate all four waveguide cells.

Before calculating the waveguides in our layout, we need to position the instances of the splitter, coupler, ring1 and ring2. This is done in an internal method _get_components, which we can reuse each time we need these instances (for that reason, we cache the method). It is later called by _generate_instances.

Tip

Although not strictly required, it is advised to group the instantiation of different related components in internal methods such as _get_components that is called in _generate_instances. This makes your code much more easy to read and to maintain. (_get_components is not a special name; you can add other such methods to calculate intermediate results yourself)

In the _get_components we do two things:
  1. Calculate the transformations needed for each component.

  2. Create the instances of each component using SRef.

class RingLoadedMZI(i3.PCell):

    #...

    ring1 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    ring2 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    splitter = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))
    combiner = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))

    # 2. We define a waveguide template and the waveguide cells
    wg_template = i3.WaveguideTemplateProperty(default=i3.TECH.PCELLS.WG.DEFAULT)
    wgs = i3.ChildCellListProperty(doc="list of waveguides")

    #3. We first define the 4 waveguides that we need at the PCELL level.
    def _default_wgs(self):
       #...

    class Layout(i3.LayoutView):
        bend_radius = i3.PositiveNumberProperty(default=i3.TECH.WG.BEND_RADIUS)


        @i3.cache()
        def _get_components(self):

            si_splitter = self.splitter.size_info()  # object with contours of the component
            si_combiner = self.combiner.size_info()  # object with contours of the component
            si_ring1 = self.ring1.size_info()  # object with contours of the component
            si_ring2 = self.ring2.size_info()  # object with contours of the component


            # spacing between components to allow easy routing (can be optimized)
            spacing = 2 * self.bend_radius + 3 * TECH.WG.SHORT_STRAIGHT

            # splitter
            t_splitter = i3.IdentityTransform()  # place the splitter in (0,0)
            splitter = i3.SRef(reference=self.splitter, name="splitter", transformation=t_splitter)

            # ring1
            t_ring1 = i3.Translation((si_splitter.east - si_ring1.west + spacing,
                                      si_dircoups.north - si_ring1.south + spacing))
            ring1 = i3.SRef(reference=self.ring1, name="ring1", transformation=t_ring1)

            # ring2
            t_ring2 = i3.VMirror() + i3.Translation((si_splitter.east - si_ring2.west + spacing,
                                                     si_dircoups.south + si_ring2.south - spacing))
            ring2 = i3.SRef(reference=self.ring2, name="ring2", transformation=t_ring2)

            # combiner
            si_rings = si_ring1.transform(t_ring1) + si_ring2.transform(t_ring2)
            t_combiner = i3.Translation((si_rings.east - si_combiner.west + spacing, 0.0))
            combiner = i3.SRef(reference=self.combiner, name="combiner", transformation=t_combiner)

            return splitter, ring1, ring2, combiner

We need to position our 4 components in such a way that the waveguides have sufficient space. For this, we use size_info() of each component. This will return a SizeInfo object which contains information about the bounding box of the cell. This information is best illustrated by the following code excerpt:

>>> my_component = MyComponent(name="mycomp")
>>> my_layout = my_component.Layout()
>>> si = my_layout.size_info()
>>> (north, south, west, east) = (si.north, si.south, si.west, si.east) #Northernmost, southernmost, westernmost and easternmost of the component
>>> area = si.area # Area of the component.

We use the size_info() object to define the proper transformations for all the components and place them in the layout.

In _default_wgs at the layout view level we define the layout view of our waveguides. Instead of defining the shapes ourselves, we use routes to draw waveguides between the ports of our components.

class RingLoadedMZI(i3.PCell):

    # ...

    ring1 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    ring2 = i3.ChildCellProperty(restriction=i3.RestrictType(RingResonator))
    splitter = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))
    combiner = i3.ChildCellProperty(restriction=i3.RestrictType(BentDirectionalCoupler))

    # We define a waveguide template and the waveguide cells
    wg_template = i3.WaveguideTemplateProperty(default=i3.TECH.PCELLS.WG.DEFAULT)
    wgs = i3.ChildCellListProperty(doc="list of waveguides")

    # Since first define the 4 waveguides that we need at the PCELL level.
    def _default_wgs(self):
       # ...

    class Layout(i3.LayoutView):
        bend_radius = i3.PositiveNumberProperty(default=i3.TECH.WG.BEND_RADIUS)

        # Calculating the instances.
        @i3.cache()
        def _get_components(self):

            # ...
            return splitter, ring1, ring2, combiner

        # 5. Getting the layout view of the waveguides.
        def _default_wgs(self):

            splitter, ring1, ring2, combiner = self._get_components()
            w1_cell, w2_cell, w3_cell, w4_cell  = self.cell.wgs
            wg_template = self.wg_template

            # Defining the north arm
            w1_layout = w1_cell.get_default_view(i3.LayoutView)

            # We set the shape using RouteManhattan and the ports.
            w1_layout.set(shape=i3.RouteManhattan(start_port=splitter.ports["out_1"],
                                                  end_port=ring1.ports["in"],
                                                  bend_radius=self.bend_radius),
                          trace_template=wg_template)

            w2_layout = w2_cell.get_default_view(i3.LayoutView)
            w2_layout.set(shape=i3.RouteManhattan(start_port=ring1.ports["out"],
                                                  end_port=combiner.ports["in_1"],
                                                  bend_radius=self.bend_radius),
                          trace_template=wg_template)

            # Defining the south arm
            w3_layout = w3_cell.get_default_view(i3.LayoutView)
            w3_layout.set(shape=i3.RouteManhattan(start_port=splitter.ports["out_2"],
                                                  end_port=ring2.ports["in"],
                                                  bend_radius=self.bend_radius),
                          trace_template=wg_template)

            w4_layout = w4_cell.get_default_view(i3.LayoutView)
            w4_layout.set(shape=i3.RouteManhattan(start_port=ring2.ports["out"],
                                                  end_port=combiner.ports["in_2"],
                                                  bend_radius=self.bend_radius),
                          trace_template=wg_template)

            return w1_layout, w2_layout, w3_layout, w4_layout

In previous steps we used to use shape to define the path along which the waveguide would be drawn. Now we use a route that is calculated using i3.RouteManhattan. RouteManhattan takes ports as an argument and returns a route that can be used when instantiating the Layout view of w1, …, w4.

In a last step, we use the _generate_instances() and _generate_ports() methods to calculate the instances and the ports of our RLMZI.

class RingLoadedMZI(i3.PCell):

    #...

    class Layout(i3.LayoutView):
        bend_radius = i3.PositiveNumberProperty(default=i3.TECH.WG.BEND_RADIUS)

        #...

        # 6. Placing both the component and the waveguides in the layout.
        def _generate_instances(self, insts):

            insts += self._get_components()
            w1_layout, w2_layout, w3_layout, w4_layout = self.wgs

            insts += i3.SRef(reference=w1_layout, name="w1")
            insts += i3.SRef(reference=w2_layout, name="w2")
            insts += i3.SRef(reference=w3_layout, name="w3")
            insts += i3.SRef(reference=w4_layout, name="w4")
            return insts

        # 7. Generating the ports of the rlmzi
        def _generate_ports(self, prts):
            instances = self.instances
            prts += instances["splitter"].ports["in_1"]
            prts += instances["splitter"].ports["in_2"]
            prts += instances["combiner"].ports["out_1"]
            prts += instances["combiner"].ports["out_2"]
            return prts

Defining the directional coupler using routes

As routes are a convenient way of defining shapes, we can use them to create our directional coupler as well. Let’s jump straight into the example that shows how to route to a certain angle and how to concatenate different routes by using the + operator in python.

Dircouproutes

Routes used in BentDirectionalCoupler

The directional coupler contains two waveguides:

  • wg_north: Refers to the north arm of the directional coupler.

  • wg_south: Refers to the south arm of the directional coupler.

Here both arms are defined as a regular ChildCellProperty. At the PCell level we create both arms using the waveguide template.

class BentDirectionalCoupler(i3.PCell):

    """ a directional coupler with bends on each side """

    wg_template = i3.WaveguideTemplateProperty(default=i3.TECH.PCELLS.WG.DEFAULT, doc="waveguide template used by the directional coupler")
    coupler_length = i3.PositiveNumberProperty(default=i3.TECH.WG.SHORT_STRAIGHT, doc="length of the directional coupler")

    wg_north = i3.ChildCellProperty(doc="North arm cell")
    wg_south = i3.ChildCellProperty(doc="South arm cell")

    # define the wg pcells
    def _default_wg_north(self):
        wg_north = i3.Waveguide(name=self.name+"_wgnorth", trace_template=self.wg_template)
        return wg_north

    def _default_wg_south(self):
        wg_south = i3.Waveguide(name=self.name+"_wgsouth", trace_template=self.wg_template)
        return wg_south

In the LayoutView of the BentDirectionalCoupler we define the layout view of both waveguides using routes:

class BentDirectionalCoupler(i3.PCell):

    #...
    wg_north = i3.ChildCellProperty(doc="North arm cell")
    wg_south = i3.ChildCellProperty(doc="South arm cell")

    class Layout(i3.LayoutView):

        coupler_spacing = i3.PositiveNumberProperty(default=i3.TECH.WG.DC_SPACING, doc="spacing between the two waveguide centerlines")
        bend_angle = i3.AngleProperty(default=45.0, doc="angle at which the directional coupler is bent")
        straight_after_bend = i3.PositiveNumberProperty(default=i3.TECH.WG.SHORT_STRAIGHT, doc="length of the straight waveguide after the bend")

        def _default_wg_north(self):

            # 1. straight routes defined using points
            r1s = i3.RouteWithPorts(
                 points=[(0, 0.5 * self.coupler_spacing), (self.coupler_length, 0.5 * self.coupler_spacing)],
            )

            # 2. define a section on each end to bend towards the correct angle
            r1_start = i3.RouteToAngle(start_port=r1s.ports[0], angle_out=180.0 - self.bend_angle,
                                       start_straight=0.0, end_straight=self.straight_after_bend)

            r1_end = i3.RouteToAngle(start_port=r1s.ports[1], angle_out=self.bend_angle,
                                     start_straight=0.0, end_straight=self.straight_after_bend)

            # 3. making the upper waveguide.
            r1 = r1_start.reversed() + r1s + r1_end

            wg_north_layout = self.cell.wg_north.get_default_view(i3.LayoutView)
            wg_north_layout.set(trace_template=self.wg_template,
                                shape=r1)
            return wg_north_layout

        def _default_wg_south(self):
            #....

        def _generate_instances(self,insts):
            insts += i3.SRef(reference=self.wg_north, name="w1")
            insts += i3.SRef(reference=self.wg_south, name="w2")
            return insts

        # Generating the ports of the directional coupler
        def _generate_ports(self, prts):
            # We reuse the  ports from the waveguides and change their names
            insts = self.instances
            prts += i3.expose_ports(insts, {
                'w1:in': 'in_1',
                'w1:out': 'out_1',
                'w2:out': 'in_2',
                'w2:out': 'out_2',
            })

            return prts
  1. We first define a straight route using the RouteWithPorts class.

  2. We use the RouteToAngle to define the angled parts of the waveguide.

  3. Using the + operator, we add all the routes together.

  4. We use the r1s.reversed() to reverse the order of the waypoints.

  5. A similar a approach is taken for the lower waveguide in _get_south_arm.

Recap

You have now learned how to use ports and routes to draw waveguides between layout instances. You also have learned how routes can be practical to calculate the shape of any waveguides (not only those that connect between layout instances).

You may have noticed that none of the waveguides we have used in this step have smooth bends. Waveguides can be amended with rounding algorithms so that this works out of the box. This is the topic of the next and final step of this tutorial.