Interfacing with Lumerical FDTD Solutions

When you setup an electromagnetic simulation using Lumerical FDTD Solutions from within IPKISS, you go through the following steps:

  1. Define the geometry that represents your component.
  2. Specify the simulation job, consisting of the geometry, the expected outputs and simulation settings
  3. Inspect the exported geometry
  4. Retrieve the simulation results

Using a simple crossing we’ll show how to complete each of these steps. First let us start with defining the layout of this crossing:

1. Define the Simulation Geometry

Layout

We assume you already know how to define the layout of a component using IPKISS. If you don’t know how to do so, you can have a look at our tutorial. Below we’ve prepared the implementation of the layout of our crossing, you can either paste it into a new file called crossing_pcell.py, or find the file in the device_sim folder in the samples.

# Parametric cell for device exploration/optimization

__all__ = ["Crossing"]

from picazzo3.traces.wire_wg import WireWaveguideTemplate
import ipkiss3.all as i3


class Crossing(i3.PCell):
    """ Low-loss waveguide crossing
        See DOI: 10.1364/OE.21.029374
    """
    _name_prefix = "CROSSING"
    # design parameters
    widths = i3.ListProperty(default=[0.5, 0.6, 0.7, 0.8, 0.5], restriction=i3.RestrictTypeList([float]), doc="list of widths [um].")
    segment_length = i3.PositiveNumberProperty(default=0.375, doc="length of each segment [um].")

    # internal
    wg_template = i3.WaveguideTemplateProperty(name="waveguide template of the ports", locked=True)

    def _default_wg_template(self):
        return WireWaveguideTemplate(name="{}_wgtmpl".format(self.name))

    class Layout(i3.LayoutView):
        min_straight = i3.NonNegativeNumberProperty(default=0.1, doc="Minimum straight segment [um].")
        discretisation = i3.PositiveNumberProperty(default=0.01, doc="Step size of the interpolated curves.")

        def _default_wg_template(self):
            lv = self.cell.wg_template.get_default_view(self)
            lv.set(core_width=self.widths[0])
            return lv

        def _generate_elements(self, elems):
            import numpy as np

            ylist = 0.5 * np.asarray(self.widths)
            waypoint_shape = i3.Shape(
                [
                    (self.min_straight + n * self.segment_length, y)
                    for n, y in enumerate(ylist)
                ],
                start_face_angle=0.0,
                end_face_angle=0.0
            )
            spl_shape = i3.ShapeFitClampedCubicSpline(original_shape=waypoint_shape, discretisation=self.discretisation)
            arm_top_shape = i3.Shape([(0.0, ylist[0])]) + spl_shape
            L = (len(self.widths) - 1) * self.segment_length + self.min_straight
            arm_top_shape.transform(i3.Translation((-L, 0.0)))
            arm_shape = arm_top_shape + arm_top_shape.v_mirror_copy().reversed()
            for n in range(4):
                elems += i3.Boundary(layer=i3.TECH.PPLAYER.WG.CORE,
                                     shape=arm_shape.rotate_copy(rotation=n*90.0))
            elems += i3.Rectangle(layer=i3.TECH.PPLAYER.WG.CLADDING,
                                  box_size=(2 * L, 2 * L))
            return elems

        def _generate_ports(self, ports):
            L = (len(self.widths) - 1) * self.segment_length + self.min_straight
            ports += i3.OpticalPort(name="west", position=(-L, 0.0), angle=180.0, trace_template=self.wg_template)
            ports += i3.OpticalPort(name="south", position=(0.0, -L), angle=-90.0, trace_template=self.wg_template)
            ports += i3.OpticalPort(name="east", position=(L, 0.0), angle=0.0, trace_template=self.wg_template)
            ports += i3.OpticalPort(name="north", position=(0.0, L), angle=90.0, trace_template=self.wg_template)
            return ports

(download link)

Next we’ll first visualize the layout of our crossing, create example1_layout.py with the following content:

from technologies import silicon_photonics
import ipkiss3.all as i3
from crossing_pcell import Crossing

c = Crossing(widths=[0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5])
c_lo = c.Layout()

c_lo.visualize()
Visualization of the 2D layout of the crossing.

Fig. 67 Visualization of the 2D layout of the crossing we’ll use for running EM simulations.

Verifying the device geometry in IPKISS

Before simulating, you can verify the virtual fabrication of a device in IPKISS.

Two functions (methods of any layout view) are available for that:

  • visualize_2d() shows a top-down view of the device geometry based on material stacks
  • cross_section() shows a cross-section of a device along a given path

We can visualize the geometry of the crossing example defined above as:

c = Crossing(widths=[0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5])
c_lo = c.Layout()

c_lo.visualize_2d()
xs = c_lo.cross_section(
  cross_section_path=i3.Shape([(-2.0, -2.0), (-2.0, 2.0)]),
  path_origin=-2.0
)

xs.visualize()
../../_images/lumerical_fdtd_solutions-2_00.png
../../_images/lumerical_fdtd_solutions-2_01.png
../../_images/lumerical_fdtd_solutions-2_02.png
../../_images/lumerical_fdtd_solutions-2_03.png

To use the cross_section() method, you need to specify the path along which to take the cross-section. This needs to be an IPKISS Shape. You can also specify the path_origin argument in order to have a meaningful x axis. The method returns on object which you can visualize with its visualize() method.

Both visualize_2d() and cross_section() use the default virtual fabrication process. You can override this to a custom virtual fabrication process, by specifying vfabrication_process_flow in visualize_2d() and specifying process_flow in cross_section():

c = Crossing(widths=[0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5])
c_lo = c.Layout()

c_lo.visualize_2d(vfabrication_process_flow=process_flow_soi_oxide)
xs = c_lo.cross_section(
     cross_section_path=i3.Shape([(-2.0, -2.0), (-2.0, 2.0)]),
     process_flow=process_flow_soi_oxide,
     path_origin=-2.0
)

xs.visualize()
../../_images/lumerical_fdtd_solutions-3_00.png
../../_images/lumerical_fdtd_solutions-3_01.png

Simulation Geometry

Now that we have a layout for our component, we can use it to define the geometry of our simulation. IPKISS reuses the information it has to provide reasonable defaults, this way the initial declaration is straightforward.

sim_geom = i3.device_sim.SimulationGeometry(
    layout=c_lo,
    waveguide_growth=0.1,
)

That’s all you have to do when it comes to building the simulation geometry, IPKISS can now:

  1. Extend your waveguides with 0.1 micrometer, to make sure the ports are not at the edge of the simulation bounding box.
  2. Transform the layer information in a three dimension representation.
  3. Derive default dimensions for the simulation bounding box
  4. Derive defaults for the position and size of the ports

However with just a geometry we can’t do much, to put it to use we have to pass it to i3.device_sim.LumericalFDTDSimulation, that’s what we’ll do next.

2. Define the simulation

To create a simulation using the geometry we’ve just defined, we’ll instantiate a i3.device_sim.LumericalFDTDSimulation object:

simulation = i3.device_sim.LumericalFDTDSimulation(
    geometry=sim_geom,
    outputs=[
       i3.device_sim.SMatrixOutput(
          name='smatrix',
          wavelength_range=(1.5, 1.6, 100)
       )
    ]
)

You see that we’ve added an SMatrixOutput. In this way IPKISS knows what kind of simulations to run. When we add an SMatrixOutput we know we have to perform an S-parameter sweep. With an output defined, your component is ready to be exported and simulated in your preferred simulation tool.

3. Inspect the simulation job

To see what it looks like, you can use the inspect method:

simulation.inspect()

The FDTD Solutions graphical user interface will open twice. First, the GUI will be opened to build up the geometry and settings in an automated way. Then, the GUI will close and re-open for inspection. This is for technical implementation reasons.

After execution, the FDTD Solutions GUI will open and look like this:

../../_images/lumerical_inspect.png

You can now explore the geometry, ports, outputs and other settings as exported by IPKISS. You can make changes and modify settings from here. See Export additional settings below on how to make those settings available for future projects.

4. Retrieve and plot simulation results

When you’ve confirmed that the simulation setup is correct, you can continue with running the simulation and retrieving the results from the outputs. By default you don’t have to launch the simulation explicitly, when you request the results, IPKISS will run the simulation when required.

import numpy as np
smat = simulation.get_result(name='smatrix')
transmission = np.abs(smat['east', 'west']) ** 2

Since not all tools use the same physics representation conventions (e.g. \(\exp(-j\omega t)\) vs \(\exp(j \omega t)\)), IPKISS will ensure that the S-parameters are converted from the tool conventions into IPKISS conventions (\(\exp(-j\omega t)\)).

You can now plot these S-parameters with matplotlib:

from scipy.constants import speed_of_light
import numpy as np
import matplotlib.pyplot as plt
wavelengths = speed_of_light / smatrix.sweep_parameter_values * 1e-3
dB = lambda x : 20*np.log10(np.abs(x))
plt.figure()
plt.plot(wavelengths, dB(smatrix["east", "west"]), label='transmission', linewidth=5)
plt.plot(wavelengths, dB(smatrix["north", "west"]), label='crosstalk', linewidth=5)
plt.plot(wavelengths, dB(smatrix["west", "west"]), label='reflection', linewidth=5)
plt.legend()
plt.xlabel('wavelength [$\mu m$]')
plt.ylabel('S-parameter magnitude')
plt.show()
../../_images/cst_crossing_sparamresult.png

S matrices can be stored to disk, S-parameter data handling.

5. Tune the geometry settings

Geometry Configuration

IPKISS provides reasonable defaults for your simulation. These are sufficient for a first step when you want to get a qualitative idea of the performance of your component. To improve the accuracy or efficiency, you’ll want to tune the settings of your simulation. In the crossing example we’ve demonstrated how to extend the waveguides. If we don’t do so, the ports will fall at the edge of the simulation region. This is one example of how you can change how IPKISS exports your component.

Process Flow

A Process Flow describes how we turn layout elements defined on layers, into a 3D representation. To create this process flow, you need information from the foundry on the relation between layers and the fabricated device. Many of the IPKISS PDKs offered by foundries will contain a process flow definition. If that’s not the case or you’re using your own PDK, you can build your own. We’ve written documentation to help you doing so.

By default IPKISS will use the process flow defined in the Technology of your PDK, which is assumed to be available under i3.TECH.VFABRICATION.PROCESS_FLOW. You can override this by providing a value for the process_flow attribute when initializing i3.device_sim.SimulationGeometry. This allows you to experiment with variations of the material properties or the material thicknesses.

Excluding Layers

When you define the layout of a component, you sometimes use layers that you don’t want export to the simulation tool. There might be various reasons for this, these layers might be logical layers like a device recognition layer, or you might want to exclude metal layers from your simulation. You can do this with the excluded_layers attribute. Here’s a small example for the PhaseModulator included in Picazzo3.

from technologies.silicon_photonics import TECH
from picazzo3.modulators.phase import PhaseModulator
import ipkiss3.all as i3

pmod = PhaseModulator()
pmod_lay = pmod.Layout(length=40.)

# we declare here that we exclude all the layers that don't belong to the
# waveguide, though in a real simulation, you'll most likely do want include those.
sim_geom = i3.device_sim.SimulationGeometry(
  layout=pmod_lay,
  excluded_layers=[
      i3.TECH.PPLAYER.P.LINE,
      i3.TECH.PPLAYER.N.LINE,
      i3.TECH.PPLAYER.PPLUS.LINE,
      i3.TECH.PPLAYER.NPLUS.LINE,
      i3.TECH.PPLAYER.M1,
      i3.TECH.PPLAYER.SIL.LINE,
      i3.TECH.PPLAYER.CONTACT.PILLAR,
  ]
)

In the case of our PhaseModulator example, you’ll probably want to only list the layers you’re interested in. That’s something you can do as well:

from technologies.silicon_photonics import TECH
from picazzo3.modulators.phase import PhaseModulator
import ipkiss3.all as i3

pmod = PhaseModulator()
pmod_lay = pmod.Layout(length=40.)

sim_geom = i3.device_sim.SimulationGeometry(
  layout=pmod_lay,
  layers=[
      i3.TECH.PPLAYER.WG.CORE,
      i3.TECH.PPLAYER.WG.CLADDING,
      i3.TECH.PPLAYER.RWG.CORE,
      i3.TECH.PPLAYER.RWG.CLADDING,
  ]
)

Note

Using the layers attribute, will override the excluded_layers setting.

Bounding Box

When you don’t specify the bounding box manually, a default is calculated. This is done in such a way that all ports and elements fit within the bounding box. You can modify this bounding box incrementally, this means that you only need to set those values you wish to set, when not set the default value is kept. The example below illustrates this:

from technologies.silicon_photonics import TECH
import ipkiss3.all as i3

wg = i3.Waveguide()
wg_layout = wg.Layout(shape=[
  (0, 0),
  (10, 0),
])

sz_info = wg_layout.size_info()

sim_geom = i3.device_sim.SimulationGeometry(
  layout=wg_layout,
  bounding_box=[
    [sz_info.west - 1.0, sz_info.east + 0.5],  # x-span of the bounding box
    None, # y-span of the bounding box, here we use the default calculated by IPKISS
    [0.3, None] # z-span of the bbox, here we only specify a value for the lower end of the bbox
  ]
)

Similar to what we did for the crossing earlier, you can use the inspect on the simulation definition to inspect the simulation setup within Lumerical FDTD Solutions:

simulation = i3.device_sim.LumericalFDTDSimulation(
    geometry=sim_geom,
    outputs=[
       i3.device_sim.SMatrixOutput(
          name='smatrix',
          wavelength_range=(1.5, 1.6, 100)
       )
    ]
)

simulation.inspect()
../../_images/lumerical_fdtd_bbox.png

6. Tune the generic simulation settings

Just as is the case with the geometry, IPKISS sets reasonable defaults for the port monitors. When you don’t specify any ports, defaults will automatically be added. The default position, direction are directly taken from the OpticalPorts of the layout, the dimensions of the Ports are calculated with a heuristic. Specifically, the width of the port is taken from the core_width attribute of the trace_template of the IPKISS OpticalPort. Any waveguide template that is a WindowWaveguideTemplate will have this attribute. For the majority of waveguide templates this is the case. The heuristic will add a fixed margin of 1um to this core_width. The height of the port is derived from the Material Stack used to virtually fabricate the layer of the core of the Waveguide, searching for the highest refractive index region but excluding the bottom, top, left and right boundary materials.

You can override these defaults in a similar way as we did for the geometry:

from technologies.silicon_photonics import TECH
import ipkiss3.all as i3

wg = i3.Waveguide()
wg_layout = wg.Layout(shape=[
  (0, 0),
  (10, 0),
])

sz_info = wg_layout.size_info()

in_port = wg_layout.ports['in']
in_pos = in_port.position.move_polar_copy(1., 0)

sim_geom = i3.device_sim.SimulationGeometry(
  layout=wg_layout,
  settings=i3.SimulationSettings(
      monitors=[
        i3.device_sim.Port(
          name='in',
          # for the z-coordinate we keep the default
          # by setting the value to 'None'
          position=(in_pos.x, in_pos.y, None)
        ),

        i3.device_sim.Port(
          name='out'
          # we make the box of the port slightly wider
          box_size=(1.50, None)
        )
      ]
  )
)

When you inspect the simulation definition within FDTD Solutions, you’ll see something similar to the picture below:

../../_images/lumerical_fdtd_ports.png

Multimode waveguides

By default, waveguide ports will be simulated with a single mode (the ground mode). You can override this in order to take multiple modes into account:

simjob = i3.device_sim.LumericalFDTDSimulation(
   geometry=geometry,
   monitors=[i3.device_sim.Port(name="in", n_modes=2),
             i3.device_sim.Port(name="out", n_modes=2)],
   outputs=[
      i3.device_sim.SMatrixOutput(
         name='smatrix',
         wavelength_range=(1.5, 1.6, 100)
      )
   ]
)

The expectation is that the tool will order the modes according to descending propagation constant, the ground mode being the first mode, the mode with the second largest propagation constant second, and so forth.

The simulation results will then contain the S-parameters for each port-mode combination:

smat = simjob.get_result(name='smatrix')

import matplotlib.pyplot as plt
import numpy as np
plt.figure()
plt.plot(smat.sweep_parameter_values, 10 * np.log10(np.abs(smat['out:0', 'in:0'])**2), 'b-', label='transmission mode 0', linewidth=5)
plt.plot(smat.sweep_parameter_values, 10 * np.log10(np.abs(smat['out:1', 'in:1'])**2), 'b-.', label='transmission mode 1', linewidth=5)
plt.plot(smat.sweep_parameter_values, 10 * np.log10(np.abs(smat['in:0', 'in:0'])**2), 'r-', label='reflection mode 0', linewidth=5)
plt.plot(smat.sweep_parameter_values, 10 * np.log10(np.abs(smat['in:1', 'in:1'])**2), 'r-.', label='reflection mode 1', linewidth=5)
plt.xlabel(smat.sweep_parameter_name)
plt.ylabel('S-parameter [dB]')
plt.legend()
plt.show()

7. Use tool-specific settings

In addition to generic settings which can be applied to multiple solvers, the IPKISS device simulation interface allows also to use the full power of the solver tool. Tool-specific materials can be used and tool-specific macros can be defined.

Using materials defined by the simulation tool

By default, IPKISS exports materials for each material used in the process flow definition.

You can also reuse materials which are already defined by the device solvers by specifying a dictionary solver_material_map to the simulation object (LumericalFDTDSimulation). It maps IPKISS materials onto materials defined by the tool (name-based).

For example:

simjob = i3.device_sim.LumericalFDTDSimulation(
    ....
    solver_material_map={
        i3.TECH.MATERIALS.SILICON: 'Si (Silicon) - Palik',
        i3.TECH.MATERIALS.SILICON_OXIDE: 'SiO2 (Glass) - Palik'
    }
)

This will map the TECH.MATERIALS.SILICON, and SILICON_OXIDE, onto materials defined by the electromagnetic solver. The material name should be know by the solver. Please check the tool documentation to find out which materials are available.

Export additional settings from FDTD Solutions to IPKISS

Often you will want to tweak certain settings (simulation settings, materials, …) using very tool-specific commands or actions. Since it is not feasible to abstract everything, IPKISS provides a way to store and apply tool-specific settings.

When inspecting the simulation project from the FDTD Solutions GUI, one can easily tweak any desired settings from the GUI. Almost anything can be modified, really. Consult the Lumerical manual for information.

As an example, we modify the FDTD grid settings as follows:

../../_images/lumerical_modify_settings.png

Now, we can store all the additional settings which were made back into the IPKISS model. These will then automatically be re-applied when you run the same simulation next time, also when you make changes to the device geometry, virtual fabrication process or simulation settings!

Save the settings to IPKISS by copying your commands and storing them into a file.

../../_images/lumerical_export_ipkiss.png

Verifying the device geometry in IPKISS

Before simulating, you can verify the virtual fabrication of a device in IPKISS.

Two functions (methods of any layout view) are available for that:

  • visualize_2d() shows a top-down view of the device geometry based on material stacks
  • cross_section() shows a cross-section of a device along a given path

We can visualize the geometry of the crossing example defined above as:

c = Crossing(widths=[0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5])
c_lo = c.Layout()

c_lo.visualize_2d()
xs = c_lo.cross_section(
  cross_section_path=i3.Shape([(-2.0, -2.0), (-2.0, 2.0)]),
  path_origin=-2.0
)

xs.visualize()
../../_images/lumerical_fdtd_solutions-5_00.png
../../_images/lumerical_fdtd_solutions-5_01.png
../../_images/lumerical_fdtd_solutions-5_02.png
../../_images/lumerical_fdtd_solutions-5_03.png

To use the cross_section() method, you need to specify the path along which to take the cross-section. This needs to be an IPKISS Shape. You can also specify the path_origin argument in order to have a meaningful x axis. The method returns on object which you can visualize with its visualize() method.

Both visualize_2d() and cross_section() use the default virtual fabrication process. You can override this to a custom virtual fabrication process, by specifying vfabrication_process_flow in visualize_2d() and specifying process_flow in cross_section():

c = Crossing(widths=[0.5, 0.6, 0.95, 1.32, 1.44, 1.46, 1.466, 1.52, 1.58, 1.62, 1.76, 2.15, 0.5])
c_lo = c.Layout()

c_lo.visualize_2d(vfabrication_process_flow=process_flow_soi_oxide)
xs = c_lo.cross_section(
     cross_section_path=i3.Shape([(-2.0, -2.0), (-2.0, 2.0)]),
     process_flow=process_flow_soi_oxide,
     path_origin=-2.0
)

xs.visualize()
../../_images/lumerical_fdtd_solutions-6_00.png
../../_images/lumerical_fdtd_solutions-6_01.png