Scattering Matrix Plugin#

Run this notebook in your browser using Binder.

This notebook will give a demo of the tidy3d ComponentModeler plugin used to compute scattering matrix elements.

# make sure notebook plots inline
%matplotlib inline

# standard python imports
import numpy as np
import matplotlib.pyplot as plt
import os
import gdspy

# tidy3D imports
import tidy3d as td
from tidy3d import web

# set tidy3d to only print error information to reduce verbosity


We will simulate a directional coupler, similar to the GDS and Parameter scan tutorials.

Let’s start by setting up some basic parameters.

# wavelength / frequency
lambda0 = 1.550                     # all length scales in microns
freq0 = td.constants.C_0 / lambda0
fwidth = freq0 / 10

# Spatial grid specification
grid_spec =, wavelength=lambda0)

# Permittivity of waveguide and substrate
wg_n = 3.48
sub_n = 1.45
mat_wg = td.Medium(permittivity=wg_n**2)
mat_sub = td.Medium(permittivity=sub_n**2)

# Waveguide dimensions

# Waveguide height
wg_height = 0.22
# Waveguide width
wg_width = 1.0
# Waveguide separation in the beginning/end
wg_spacing_in = 8
# length of coupling region (um)
coup_length = 6.0
# spacing between waveguides in coupling region (um)
wg_spacing_coup = 0.05
# Total device length along propagation direction
device_length = 100
# Length of the bend region
bend_length = 16
# Straight waveguide sections on each side
straight_wg_length = 4
# space between waveguide and PML
pml_spacing = 2

Define waveguide bends and coupler#

Here is where we define our directional coupler shape programmatically in terms of the geometric parameters

def bend_pts(bend_length, width, npts=10):
    """ Set of points describing a tanh bend from (0, 0) to (length, width)"""
    x = np.linspace(0, bend_length, npts)
    y = width*(1 + np.tanh(6*(x/bend_length - 0.5)))/2
    return np.stack((x, y), axis=1)

def arm_pts(length, width, coup_length, bend_length, npts_bend=30):
    """ Set of points defining one arm of an integrated coupler """
    ### Make the right half of the coupler arm first
    # Make bend and offset by coup_length/2
    bend = bend_pts(bend_length, width, npts_bend)
    bend[:, 0] += coup_length / 2
    # Add starting point as (0, 0)
    right_half = np.concatenate(([[0, 0]], bend))
    # Add an extra point to make sure waveguide is straight past the bend
    right_half = np.concatenate((right_half, [[right_half[-1, 0] + 0.1, width]]))
    # Add end point as (length/2, width)
    right_half = np.concatenate((right_half, [[length/2, width]]))

    # Make the left half by reflecting and omitting the (0, 0) point
    left_half = np.copy(right_half)[1:, :]
    left_half[:, 0] = -left_half[::-1, 0]
    left_half[:, 1] = left_half[::-1, 1]

    return np.concatenate((left_half, right_half), axis=0)

def make_coupler(
    """ Make an integrated coupler using the gdspy FlexPath object. """

    # Compute one arm of the coupler
    arm_width = (wg_spacing_in - wg_width - wg_spacing_coup)/2
    arm = arm_pts(length, arm_width, coup_length, bend_length, npts_bend)
    # Reflect and offset bottom arm
    coup_bot = np.copy(arm)
    coup_bot[:, 1] = -coup_bot[::-1, 1] - wg_width/2 - wg_spacing_coup/2
    # Offset top arm
    coup_top = np.copy(arm)
    coup_top[:, 1] += wg_width/2 + wg_spacing_coup/2

    # Create waveguides as GDS paths
    path_bot = gdspy.FlexPath(coup_bot, wg_width, layer=1, datatype=0)
    path_top = gdspy.FlexPath(coup_top, wg_width, layer=1, datatype=1)

    return [path_bot, path_top]

Create Base Simulation#

The scattering matrix tool requires the “base” Simulation (without the modal sources or monitors used to compute S-parameters), so we will construct that now.

We generate the structures and add a FieldMonitor so we can inspect the field patterns.

gdspy.current_library = gdspy.GdsLibrary()
lib = gdspy.GdsLibrary()

# Geometry must be placed in GDS cells to import into Tidy3D
coup_cell = lib.new_cell('Coupler')

substrate = gdspy.Rectangle(
    (-device_length/2, -wg_spacing_in/2-10),
    (device_length/2, wg_spacing_in/2+10),

# Add the coupler to a gdspy cell
gds_coup = make_coupler(

# Substrate
[oxide_geo] = td.PolySlab.from_gds(
    slab_bounds=(-10, 0),

oxide = td.Structure(

# Waveguides (import all datatypes if gds_dtype not specified)
coupler1_geo, coupler2_geo = td.PolySlab.from_gds(
    slab_bounds=(0, wg_height),

coupler1 = td.Structure(

coupler2 = td.Structure(

# Simulation size along propagation direction
sim_length = 2*straight_wg_length + 2*bend_length + coup_length

# Spacing between waveguides and PML
sim_size = [
    wg_spacing_in + wg_width + 2*pml_spacing,
    wg_height + 2*pml_spacing]

# source
src_pos = sim_length/2 - straight_wg_length/2

# in-plane field monitor (optional, increases required data storage)
domain_monitor = td.FieldMonitor(
    center = [0,0,wg_height/2],
    size = [td.inf, td.inf, 0],
    freqs = [freq0],

# initialize the simulation
sim = td.Simulation(
    structures=[oxide, coupler1, coupler2],

f, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(15, 10))
ax1 = sim.plot(z=wg_height/2, ax=ax1)
ax2 = sim.plot(x=src_pos, ax=ax2)

Setting up Scattering Matrix Tool#

Now, to use the S matrix tool, we need to defing the spatial extent of the “ports” of our system using Port objects.

These ports will be converted into modal sources and monitors later, so they require both some mode specification and a definition of the direction that points into the system.

We’ll also give them names to refer to later.

from tidy3d.plugins.smatrix.smatrix import Port

port_right_top = Port(
    center=[src_pos, wg_spacing_in / 2, wg_height / 2],
    size=[0, 4, 2],

port_right_bot = Port(
    center=[src_pos, -wg_spacing_in / 2, wg_height / 2],
    size=[0, 4, 2],

port_left_top = Port(
    center=[-src_pos, wg_spacing_in / 2, wg_height / 2],
    size=[0, 4, 2],

port_left_bot = Port(
    center=[-src_pos, -wg_spacing_in / 2, wg_height / 2],
    size=[0, 4, 2],

ports = [port_right_top, port_right_bot, port_left_top, port_left_bot]

Next, we will add the base simulation and ports to the ComponentModeler, along with the frequency of interest and a name for saving the batch of simulations that will get created later.

from tidy3d.plugins.smatrix.smatrix import ComponentModeler
modeler = ComponentModeler(simulation=sim, ports=ports, freq=freq0)

We can plot the simulation with all of the ports as sources to check things are set up correctly.

f, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(15, 10))
ax1 = modeler.plot_sim(z=wg_height/2, ax=ax1)
ax2 = modeler.plot_sim(x=src_pos, ax=ax2)

Solving for the S matrix#

With the component modeler defined, we may call it’s .solve() method to run a batch of simulations to compute the S matrix. The tool will loop through each port and create one simulation per mode index (as defined by the mode specifications) where a unique modal source is injected. Each of the ports will also be converted to mode monitors to measure the mode amplitudes and normalization.

smatrix ='data')
[20:30:36] Started working on Batch.                               
[20:34:17] Batch complete.                                         

Working with Scattering Matrix#

The scattering matrix returned by the solve is actually a nested dictionarty relating the port names. For example smatrix[name1][name2] gives an array of shape (m, n) relating the coupling between the m modes injected into port name1 with the n modes measured in port name2.

For example, here each waveguide has 2 modes, so we can compute the coupling between the 2 input modes in left_top with the 2 output modes in right_bot as:

array([[0.06403264+0.07115989j, 0.00674624-0.00312738j],
       [0.00572602-0.00474166j, 0.35255399+0.43919063j]])

Alternatively, we can convert this into a numpy array:

blocks_cols = []
for port_name_in, val_in in smatrix.items():
    blocks_rows = []
    for port_name_out, S_in_out in val_in.items():
S = np.concatenate(blocks_cols)
(8, 8)

We can inspect S and note that the diagonal elements are very small indicating low backscattering.

[[1.19078321e-04 1.30931086e-05 1.16879605e-06 1.07789799e-05
  9.88812626e-01 3.46404029e-04 9.16371768e-03 5.62165021e-05]
 [1.30665821e-05 2.25409915e-04 1.08267498e-05 6.25443386e-05
  3.47373215e-04 6.65922725e-01 5.61198261e-05 3.17072149e-01]
 [1.16921547e-06 1.07839636e-05 1.20195892e-04 1.31139477e-05
  9.16391295e-03 5.53021556e-05 9.88809121e-01 3.46142393e-04]
 [1.08160626e-05 6.25908947e-05 1.31136930e-05 2.23223603e-04
  5.52537426e-05 3.17203352e-01 3.47328492e-04 6.66091058e-01]
 [9.88803634e-01 3.48626514e-04 9.16390813e-03 5.52922363e-05
  1.20215074e-04 1.30683090e-05 1.16891791e-06 1.07645497e-05]
 [3.47787557e-04 6.66139642e-01 5.52706008e-05 3.17182723e-01
  1.30610041e-05 2.23897738e-04 1.07679124e-05 6.25463406e-05]
 [9.16343253e-03 5.62507440e-05 9.88808382e-01 3.46481381e-04
  1.16884656e-06 1.07461109e-05 1.19041634e-04 1.30477235e-05]
 [5.62381417e-05 3.17202124e-01 3.47453140e-04 6.66108763e-01
  1.07868267e-05 6.25648948e-05 1.30194290e-05 2.24671497e-04]]

Summing each rows of the matrix should give 1.0 if no power was lost.

np.sum(abs(S)**2, axis=0)
array([0.99851522, 0.98405852, 0.99852032, 0.98400292, 0.9985244 ,
       0.98383806, 0.99852029, 0.9838766 ])

Finally, we can check whether S is close to unitary as expected.

S times it’s Hermitian conjugate should be the identy matrix.

mat = S @ (np.conj(S.T))
f, (ax1, ax2, ax3) = plt.subplots(1, 3, tight_layout=True, figsize=(12, 3.5))
imabs = ax1.imshow(abs(mat))
imreal = ax2.imshow(mat.real)
imimag = ax3.imshow(mat.imag)
plt.colorbar(imabs, ax=ax1)
plt.colorbar(imreal, ax=ax2)
plt.colorbar(imimag, ax=ax3)
ax1.set_title('abs{$S^\dagger S$}')
ax2.set_title('real{$S^\dagger S$}')
ax3.set_title('imag{$S^\dagger S$}')

It looks pretty close, but there seems to indeed be a bit of loss (expected).

Viewing individual Simulation Data#

To verify, we may want to take a look the individual simulation data. For that, we can load up the batch and inspect the SimulationData for each task.

batch_data = modeler.batch_data
f, (ax1, ax2) = plt.subplots(2, 1, tight_layout=True, figsize=(15, 10))
ax1 = batch_data['smatrix_portleft_top_mode0'].plot_field('field', 'int', freq=freq0, z=wg_height/2, ax=ax1)
ax2 = batch_data['smatrix_portleft_top_mode1'].plot_field('field', 'int', freq=freq0, z=wg_height/2, ax=ax2)