Introduction to simphony#

Simphony is a Python package that, in conjunction with SAX, helps in defining and simulating photonic circuits. Having a basic understanding of Python will be helpful.

In order to get started, you will need to set up a Python environment with simphony installed. If you are new to Python, we recommend using Miniconda to install and manage your Python environment. Once you have Miniconda installed, you can create a new environment and install the simphony package by running the following commands in your terminal:

conda create -n simphony python=3.11
conda activate simphony
pip install simphony

Our goal with this tutorial is to give some of the background of the basics of simphony and SAX, the underlying scattering parameter solver, in order to simulate a very simple photonic circuit. We’ll go through the typical objects found in every circuit definition. We’ll also show you how to simulate the circuit and obtain the results.

You can follow along with these tutorials, executing the code cells one at a time on your own machine, in a JupyterLab/Notebook file by creating your own notebook (in VSCode this is as simple as creating a file with a “.ipynb” extension) or simply downloading this tutorial page as an .ipynb file using the link in the top right corner of this page.

Note

We run the following command first to ensure that JAX, the library that allows Simphony and SAX to run calculations on GPU’s, uses double precision. Be aware that this setting must be set before JAX initializes, or the setting won’t take. That is why it must be the first command in your file. Depending on the models used, this can be important for the accuracy of the results.

from jax import config
config.update("jax_enable_x64", True)

For advanced users

Alternatively, you can set it as an environment variable so your development environment always uses the right setting:

JAX_ENABLE_X64=True

Models#

Models are the most basic building block in SAX, and they are used to represent an element/component/device/geometry in a photonic circuit. Models are simply functions that return s-parameters, or “scattering parameters,” when called with the appropriate arguments. These could be parameters that modify a geometry, such as its length, or modify its behavior, such as a current, voltage, or temperature change.

The returned s-parameter format in SAX is simply a dictionary of port-to-port scattering parameters (usually sampled at some predefined set of wavelength points). Hence its keys are human-readable pairs, which makes it easy to select which parameter you want simply by inspection, and its values are arrays.

An additional computational benefit of dictionaries over the usual matrix representation is that it also takes less memory in the case of sparse matrices (those where most port relationships are 0). As an example of this, consider the case of an wavelength-dependent “ideal” waveguide–a “pipe” for light with transmission that depends upon wavelength but has no back reflections. We can store in our s-dictionary an array of only the forward transmission coefficients for the given wavelength range, and leave off the definitions for the back reflection, saving us half the memory a normal s-parameter matrix would consume storing nothing but zeros.

A sample s-dictionary might look like this:

s = {
    ("in", "in"): np.array([[0.5]]),
    ("in", "out"): np.array([[0.5]]),
    ("out", "in"): np.array([[0.5]]),
    ("out", "out"): np.array([[0.5]]),
}

You can see that selecting the port relationship you’re interested in is as simple as indexing use the desired pair:

s[("in", "out")]

Simphony includes a number of pre-built models sourced from multiple model libraries (including the SiEPIC Ebeam PDK and SiPANN), but you can also create your own custom models by writing a function that implements the interface for a model. Here we make a simple custom model with two ports and one parameter (note our use of type hints, which is best practice in modern Python and helps document the function parameters for any future user who might use models you create):

import numpy as np
import sax
from jax.typing import ArrayLike

def custom_model(param: float = 0.5) -> sax.SDict:
    """This model will have one parameter, param, which defaults to 0.5.
    
    Args:
        param: Some float parameter.

    Returns:
        sdict: A dictionary of scattering matrices.
    """
    # a simple wavelength independent s-matrix
    sdict = sax.reciprocal({
        ("in0", "out0"): -1j * np.sqrt(param),
    })
    return sdict
    
# A model is simulated by "calling" it with appropriate paraeters.
custom_model(param=0.25)
{('in0', 'out0'): -0.5j, ('out0', 'in0'): -0.5j}

Important

Model function parameters are required to be keyword-only.

  • In the backend, SAX inspects the model signature and passes in only the requested variables to the model function.

  • The global simulation parameters that are passed in must be named the same across all pertinent models.

  • In order to determine the names of the ports in a model while building the netlist, SAX evaluates the model once without arguments. Hence, sensible defaults that run without raising any errors are required.

Using Pre-built Models#

Simphony includes some pre-built models that can be used to build photonic circuits simphony.libraries:

One library we use comes from the SiEPIC PDK (developed at the University of British Columbia). This library contains the s-parameters for a number of photonic components that were simulated with various combinations of different parameters. This concept is known as a “parameter sweep,” and can help you predict circuit performance particularly in the presence of fabrication variations.

Let’s instantiate two different waveguides of different lengths. We’ll use the same model for both: the waveguide() model. Inspecting the function signature for our models, we see that these SiEPIC models are parameterizable, and we can ascertain from the documentation the parameter names and units required when instantiating them:

from simphony.libraries import siepic


siepic.waveguide?
Signature:
siepic.waveguide(
    wl: Union[float, jax.Array] = 1.55,
    pol: Literal['te', 'tm'] = 'te',
    length: float = 0.0,
    width: float = 500.0,
    height: float = 220.0,
    loss: float = 0.0,
) -> Dict[Tuple[str, str], jaxtyping.Complex[Array, '...']]
Docstring:
Model for an waveguide optimized for TE polarized light at 1550
nanometers.

A waveguide easily connects other optical components within a circuit.

.. image:: /_static/images/ebeam_wg_integral_1550.png
    :alt: ebeam_bdc_te1550.png

Parameters
----------
pol : str, optional
    Polarization of the grating coupler. Must be either 'te' (default) or
    'tm'.
length : float, optional
    Waveguide length in microns (default 0).
width : float, optional
    Waveguide width in nanometers (default 500).
height : float, optional
    Waveguide height in nanometers (default 220).
loss : float, optional
    Loss of the waveguide in dB/cm (default 0).
sigma_ne : float, optional
    Standard deviation of the effective index for monte carlo simulations
    (default 0.05).
sigma_ng : float, optional
    Standard deviation of the group velocity for monte carlo simulations
    (default 0.05).
sigma_nd : float, optional
    Standard deviation of the group dispersion for monte carlo simulations
    (default 0.0001).

Notes
-----
The `sigma_` values in the parameters are used for monte carlo simulations.

Sorted matrix of valid parameter combinations for waveguides:

========  =======
height    width
========  =======
    210      400
    210      420
    210      440
    210      460
    210      480
    210      500
    210      520
    210      540
    210      560
    210      580
    210      600
    210      640
    210      680
    210      720
    210      760
    210      800
    210      840
    210      880
    210      920
    210      960
    210     1000
    210     1040
    210     1080
    210     1120
    210     1160
    210     1200
    210     1240
    210     1280
    210     1320
    210     1360
    210     1400
    210     1500
    210     1600
    210     1700
    210     1800
    210     1900
    210     2000
    210     2100
    210     2200
    210     2300
    210     2400
    210     2500
    210     2600
    210     2700
    210     2800
    210     2900
    210     3000
    210     3100
    210     3200
    210     3300
    210     3400
    210     3500
    220      400
    220      420
    220      440
    220      460
    220      480
    220      500
    220      520
    220      540
    220      560
    220      580
    220      600
    220      640
    220      680
    220      720
    220      760
    220      800
    220      840
    220      880
    220      920
    220      960
    220     1000
    220     1040
    220     1080
    220     1120
    220     1160
    220     1200
    220     1240
    220     1280
    220     1320
    220     1360
    220     1400
    220     1500
    220     1600
    220     1700
    220     1800
    220     1900
    220     2000
    220     2100
    220     2200
    220     2300
    220     2400
    220     2500
    220     2600
    220     2700
    220     2800
    220     2900
    220     3000
    220     3100
    220     3200
    220     3300
    220     3400
    220     3500
    230      400
    230      420
    230      440
    230      460
    230      480
    230      500
    230      520
    230      540
    230      560
    230      580
    230      600
    230      640
    230      680
    230      720
    230      760
    230      800
    230      840
    230      880
    230      920
    230      960
    230     1000
    230     1040
    230     1080
    230     1120
    230     1160
    230     1200
    230     1240
    230     1280
    230     1320
    230     1360
    230     1400
    230     1500
    230     1600
    230     1700
    230     1800
    230     1900
    230     2000
    230     2100
    230     2200
    230     2300
    230     2400
    230     2500
    230     2600
    230     2700
    230     2800
    230     2900
    230     3000
    230     3100
    230     3200
    230     3300
    230     3400
    230     3500
========  =======
File:      ~/git/simphony/simphony/libraries/siepic/models.py
Type:      function

Since all models require keyword arguments, that means they ought to have sensible defaults. In this case, they do, and we’ll just set the parameters that are nonstandard for our purposes: the length (in microns) and waveguide silicon thickness. We’ll make wg1 thicker than wg2. Due to the thickness difference and the length difference between the two waveguides, their s-parameters will differ.

# waveguide of 2.5 mm length
wg1 = siepic.waveguide(length=2500, height=220)
# waveguide of 7.5 mm length
wg2 = siepic.waveguide(length=7500, height=210)

We can see the s-parameter dictionary for one of the waveguides by simply printing it (this will also tell us the port naming convention for this model):

wg1
{('o0', 'o0'): Array([0.+0.j], dtype=complex128),
 ('o0', 'o1'): Array([-0.82076344+0.57126822j], dtype=complex128),
 ('o1', 'o0'): Array([-0.82076344+0.57126822j], dtype=complex128),
 ('o1', 'o1'): Array([0.+0.j], dtype=complex128)}

Note

The convention in simphony is to use microns for units of length.

Creating a Circuit#

Ports are represented simply by string names in SAX, and a netlist (or a circuit) is simply a dictionary defining instances of models, their corresponding connections, and the subsequently exposed ports. Netlists can also be used as models in other netlists; this concept is called recursive netlists, and we will discuss them in a later tutorial. Suffice it for now to say that the ports of the composite model (i.e. the subcircuit defined by some netlist), when connected within the context of a larger circuit, are the ports defined by your netlist.

The simplest way to create a circuit and connect ports is to define the keys "instances", "connections", and "ports", as follows:

netlist = {
    "instances": {
        "wg1": "waveguide",
        "wg2": "waveguide",
    },
    "connections": {
        "wg1,o1": "wg2,o0",
    },
    "ports": {
        "in": "wg1,o0",
        "out": "wg2,o1",
    }
}

You then create a circuit object and define which functions (also called models in SAX parlance, and type hinted as “SModel”) should be used to calculate the s-parameters for your component instances. This makes it very easy to swap other models in and out during different simulations.

Because we said that "wg1" and "wg2" are instances of the "waveguide" model, we need to tell the circuit what model use to calculate the s-parameters for the "waveguide" instances. We do this by passing in a dictionary of model names to model functions. In this case, we only have one model, so we pass in a dictionary with one key-value pair.

circuit, info = sax.circuit(
    netlist=netlist,
    models={
        "waveguide": siepic.waveguide,
    }
)

Many models in simphony have port names prefixed with “o” and increasing from 0, e.g. “o0”, “o1”, etc. However, you should check the model functions you use before placing them in a netlist in order to guarantee you know what your model’s port names are (this is easily accomplished by simply evaluating the model function and inspecting the keys of the resulting dictionary).

In our netlist, we specified “o1” of wg1 must connect to “o0” of wg2. We gave our overall circuit more useful port names, though; “in” and “out” are more descriptive than “o0” and “o1”.

Note

For each model you use, refer to its documentation to see how port names are assigned.

With this netlist defined, we now have a rudimentary circuit to run simulations on.

Simulation#

Native sax simulation#

In sax, the object returned by sax.circuit is a callable–this simply means it returns a function that can be called with the appropriate arguments. In this case, the arguments are the simulation parameters. In this case, that includes the array of wavelength points to evaluate the circuit performance at. The returned function is the one that actually calculates the s-parameters for the overall circuit. It inspects the signatures of the models comprising the circuit and passes through parameters provided at the toplevel. It passes to each only the parameters requied for a given function evaluation.

Parameters can be specified for each instance in the netlist using keyword parameters corresponding to instance names and providing dictionaries containg parameter mappings to those keywords. Alternatively, parameters that share names can be specified at the toplevel and applied to all instances in a way that is reminiscent of a global parameter.

We can inspect the function parametere list of our circuit:

circuit?
Signature:
circuit(
    *,
    wg1={'wl': Array(1.55, dtype=float64), 'pol': 'te', 'length': Array(0., dtype=float64), 'width': Array(500., dtype=float64), 'height': Array(220., dtype=float64), 'loss': Array(0., dtype=float64)},
    wg2={'wl': Array(1.55, dtype=float64), 'pol': 'te', 'length': Array(0., dtype=float64), 'width': Array(500., dtype=float64), 'height': Array(220., dtype=float64), 'loss': Array(0., dtype=float64)},
) -> 'SType'
Docstring: <no docstring>
File:      ~/git/sax/sax/circuit.py
Type:      function

And we can evaluate it at a set of wavelength points:

sdict = circuit(wl=np.linspace(1.5, 1.6, 5))
sdict
{('in',
  'in'): Array([0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j], dtype=complex128),
 ('in',
  'out'): Array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j], dtype=complex128),
 ('out',
  'in'): Array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j], dtype=complex128),
 ('out',
  'out'): Array([0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j, 0.+0.j], dtype=complex128)}

As seen here, it would be very easy to select the overall transmission of our circuit by simply indexing the s-parameter dictionary with the appropriate port pair:

sdict[("in", "out")]
Array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j], dtype=complex128)

Helper simulation objects provided by simphony#

In bare-bones sax, it is difficult to provide multiple sources to a circuit to evaluate performance. It’s also a more manual process to assign responsivity values to ports, for example, to mimic what you might actually observe on an oscilloscope after measuring light using a photodetector.

simphony.classical provides a collection of simulators that connect to an input and output pin on a circuit, then perform a series of calculations to solve for the relationship between output light at each port of the circuit for given inputs of light.

Let’s run a simple sweep simulation on the circuit we have created. We can pass parameters to the individual components within our circuit using the names we gave them in our netlist–that is, “wg1” and “wg2”. This time, we’ll pass them dictionaries of keyword arguments that will be used to instantiate their given models, so the key names must match those in the parameter list of the model. (Remember: always check the API of the models you want to use to see what parameters they take.)

from simphony.classical import ClassicalSim

# Create a simulation and add a laser and detector
sim = ClassicalSim(circuit, wl=1.55, wg1={"length": 2500.0, "loss": 3.0}, wg2={"length": 7500.0, "loss": 3.0})
laser = sim.add_laser(ports=["in"], power=1.0)
detector = sim.add_detector(ports=["out"])

# Run the simulation
result = sim.run()

# Since the total wg length is 1 cm and the loss is 3 dB/cm, the power should be approximately 50%.
print(f"Power transmission: {abs(result.sdict['out'])**2}")
Power transmission: [0.50118723]

We instantiated our simulator with our circuit, adding a laser input to the “input” port of wg1 and placing a detector on wg2. Our sweep simulation injected light at the input over a range of wavelengths from 1.5 microns to 1.6 microns, and now result contains parameters that came out of our circuit corresponding to the injected frequencies. We can use these results however we like in further analyses.

In order to view the results, we can use the matplotlib package to graph our output, but that will be demonstrated in following tutorials. For this tutorial, we’re done!