# 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).

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

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

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]

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

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)