Introduction to simphony
Contents
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!