Circuit Simulations for Advanced Users

Model definition guidelines

Below we summarize some guidelines you can use to write efficient models.

  • Use simple parameter types in the calculation methods (calculate_smatrix, calculate_signals and calculate_dydt)

    The following are considered simple parameter types: integers, floats, complex numbers, and numpy arrays. In most cases, circuit models can be described as a function of simple parameters, this allows the model to be recompiled by the simulator into a more efficient form. In case you need more flexibility, or need to debug, the model will need to run in Python mode (see enabling Python mode).

  • Preload stored data.

    Instead of loading the data during the calculation methods, make sure to load them upfront. Compare the following code:

    import numpy as np
    
    class MyModel(i3.CompactModel):
        parameters = ['length']
        terms = [
           i3.OpticalTerm(name='in'),
           i3.OpticalTerm(name='out')
        ]
    
        def calculate_smatrix(parameters, env, S):
            n_eff_data = np.loadtxt('n_eff_tabulated_data.csv')
            idx = np.argmin(np.abs(n_eff_data[:, 0] - env.wavelength))
            n_eff = n_eff_data[idx, 1]
            S['in', 'out'] = S['out', 'in'] = np.exp(1j * 2 * np.pi * parameters.length / env.wavelength * n_eff)
    

    Instead, use:

    import numpy as np
    
    class MyModel(i3.CompactModel):
        parameters = ['length', 'n_eff_data']
        terms = [
           i3.OpticalTerm(name='in'),
           i3.OpticalTerm(name='out')
        ]
    
        def calculate_smatrix(parameters, env, S):
            idx = np.argmin(np.abs(parameters.n_eff_data[:, 0] - env.wavelength))
            n_eff = parameters.n_eff_data[idx, 1]
            S['in', 'out'] = S['out', 'in'] = np.exp(1j * 2 * np.pi * parameters.length / env.wavelength * n_eff)
    

    Now n_eff_data can be passed onto the model. The important difference is that now the data is only loaded once. n_eff_data is a 2D data array, so it can be a property of the model. The CircuitModelView can then organize the passing of the variables, as such:

    import numpy as np
    
    class CircuitModel(i3.CircuitModelView):
        filename = i3.StringProperty(default='n_eff_tabulated_data.csv')
        length = i3.NonNegativeNumberProperty(default=10)
        n_eff_data = i3.NumpyArrayProperty(doc="Effective index, 2D array of (wavelengths, n_eff)")
    
        def _default_n_eff_data(self):
            return np.loadtxt(self.filename)
    
        def _generate_model(self):
            return NeffModel(length=self.length, n_eff_data=self.n_eff_data)
    

    To improve the interpolation we can implement a simple linear interpolation scheme:

    idx = np.argmin(np.abs(n_eff_data[:, 0] - env.wavelength))
    if n_eff_data[idx, 0] > env.wavelength:
        idx = idx - 1
    frac = (env.wavelength - n_eff_data[idx, 0]) / (n_eff_data[idx + 1, 0] - n_eff_data[idx, 0])
    n_eff = frac * n_eff_data[idx, 1] + (1 - frac) * n_eff_data[idx + 1, 1]
    

    Download the full example here.

Debugging Models

By default, models are compiled using Numba to make them very efficient at runtime. Especially in timedomain simulations this can result in a significant performance boost. Though numba also puts some restrictions in the way models can be written. For more details, please visit the numba documentation:

Sometimes numba gets in the way because you can’t debug your code just as you would with normal python code. To circumvent this you can run your simulations in ‘debug’ mode, your code will run slower but you can use your debugger to debug your code (see for example how to debug in PyCharm).

To enable debug mode just pass the debug=True keyword argument when invoking the simulation:

wavelengths = np.linspace(1.54, 1.56, 1001)
wg = MyWaveguide()
wg_cm = wg.CircuitModel()
S = wg_cm.get_smatrix(wavelengths, debug=True)

Python Models

In some cases, the restrictions imposed by numba limit you so much that you can’t implement the model you desire. In those cases you can fall back to pure python models. The tradeoff is that you won’t be able to take advantage of numba to accelerate your models.

to enable python mode for a model:

class MyPythonModel(i3.CompactModel):
    _model_type = 'python'

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

    def calculate_smatrix(parameters, env, S):
        # numba doesn't support strings (yet)
        S['in', 'out'] = len("abcde") * 0.1

Note

If you have trouble writing a model in numba mode, make sure to reach out to us at support@lucedaphotonics.com. We’ll be glad to help.

Polynomial dependent variables and coefficients

In many cases, a polynomial can be a good approximation for a physical behavior. Polynomes are simple to represent (an array of coefficients) and their value can be evaluated very fast, which is important when building compact models. Numpy provides numpy.polyval() for such use cases.

For instance, let’s say we want to approximate the effective index of a waveguide with a polynomial as function of wavelength (centered around the center wavelength). This can be implemented as follows:

import numpy as np

class PolynomialWgModel(i3.CompactModel):

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

    parameters = [
      'neff_coefficients',    # np.ndarray
      'center_wavelength'  # float
    ]
    def calculate_smatrix(parameters, env, S):
        neff = np.polyval(parameters.neff_coefficients, env.wavelength - parameters.center_wavelength)
        S['in', 'out'] = S['out', 'in'] = np.exp(1j * 2 * np.pi * neff / env.wavelength)

The benefit of this approach is that the order of the polynome does not need to be specified upfront.

A more complete example is available under the sample directory, under samples\ipkiss\model_generation\example4_polyval_model.py.

Writing a netlist manually

In most use cases when you write a netlist, it’s sufficient to start from ConnectComponents or any of its inherited classes (see placement and routing).

Sometimes you want more control over the netlist, or you want to start from an empty PCell to build your components. For these use cases, we explain how to build your netlist manually. It consists of two steps:

  1. Write the netlist in the NetlistView.

  2. Inside the CircuitModelView, the model should be a i3.HierarchicalModel. To reuse the netlist defined in the NetlistView, you can use the class method from_netlistview(netlist).

Here’s the MZI from the tutorial rewritten in this syntax:

class MyMZI(i3.PCell):
    arm1 = i3.ChildCellProperty(locked=True)
    arm2 = i3.ChildCellProperty(locked=True)
    splitter = i3.ChildCellProperty(locked=True)
    combiner = i3.ChildCellProperty(locked=True)

    def _default_arm1(self):
        return MyWaveguide(name=self.name + '_arm1')

    def _default_arm2(self):
        return MyWaveguide(name=self.name + '_arm2')

    def _default_splitter(self):
        return MyDC(name=self.name + '_splitter')

    def _default_combiner(self):
        return MyDC(name=self.name + '_combiner')

    class Netlist(i3.NetlistView):

        def _generate_netlist(self, nl):
            nl += i3.OpticalTerm(name='in1')
            nl += i3.OpticalTerm(name='in2')
            nl += i3.OpticalTerm(name='out1')
            nl += i3.OpticalTerm(name='out2')
            nl += i3.Instance(name='arm2', reference=self.arm2)
            nl += i3.Instance(name='arm1', reference=self.arm1)
            nl += i3.Instance(name='splitter', reference=self.splitter)
            nl += i3.Instance(name='combiner', reference=self.combiner)
            nl.link('splitter:in1', 'in1')
            nl.link('splitter:in2', 'in2')
            nl.link('combiner:out1', 'out1')
            nl.link('combiner:out2', 'out2')
            nl.link('splitter:out1', 'arm1:in')
            nl.link('splitter:out2', 'arm2:in')
            nl.link('combiner:in1', 'arm1:out')
            nl.link('combiner:in2', 'arm2:out')

            return nl

    class CircuitModel(i3.CircuitModelView):
        delay_difference = i3.PositiveNumberProperty(default=100.)

        def _default_arm1(self):
            arm1_model = self.cell.arm1.get_default_view(i3.CircuitModelView)
            arm1_model.set(length=100, use_py=True)
            return arm1_model

        def _default_arm2(self):
            arm2_model = self.cell.arm2.get_default_view(i3.CircuitModelView)
            arm2_model.set(length=100 + self.delay_difference)
            return arm2_model

        def _generate_model(self):
            return i3.HierarchicalModel.from_netlistview(self.netlist_view)

Download the full example here.

Troubleshooting

When building a new model, you may occasionally encounter some errors related to compilation of the model using Numba. Most errors are clear in description, but the stacktrace can be rather big. Most frequently encountered errors can be:

  • An unknown parameter is used inside the model. For example, you use parameters.some_parameter, where some_parameter is not defined on the view.

  • Wrong port names: for example, when accessing S['in', 'out_2'], where the port out_2 is not defined by the model.

To solve these problems, you can either investigate the stacktrace provided by numba, or you can test the model in debug mode which typically gives a more comprehensive error that is easier to fix, and allows you to step through while debugging.