Tidy3D Model

Any number of models can be assigned to a component to compute its S matrix. In this guide, we take a look into the Tidy3DModel, which leverages the power of Tidy3D to compute the S parameters from FDTD simulations of the 3D device.

Component Creation

We will create the component based on the Strip waveguide available in the basic technology. More advanced component creation is described in the Custom Parametric Components guide.

[1]:
import numpy as np
import photonforge as pf

pf.config.default_technology = pf.basic_technology(strip_width=0.3)
[2]:
# Geometry parameters
port_spec = pf.config.default_technology.ports["Strip"]
length = 0.5
radius = 4
gap = 0.15
core_width, _ = port_spec.path_profile_for("WG_CORE")

coupler = pf.Component("COUPLER")

# Add straight section
for layer, path in port_spec.get_paths((0, 0)):
    path.segment((2 * radius + length, 0))
    coupler.add(layer, path)

# Add racetrack section
for layer, path in port_spec.get_paths((0, core_width + gap + radius)):
    path.arc(180, 270, radius, euler_fraction=0.5)
    path.segment((length, 0), relative=True)
    path.arc(-90, 0, radius, euler_fraction=0.5)
    coupler.add(layer, path)

# Add ports
coupler.add_port(coupler.detect_ports([port_spec]))

coupler
[2]:
../_images/guides_Tidy3D_Model_3_0.svg

Create the Tidy3D Model

The Tidy3D model supports many arguments related to the configurations of the FDTD simulation that will be run. One is particularly important for most devices: setting port_symmetries. Without specification, the default behavior is to execute 1 FDTD simulation per component port, so that the full S matrix can be computed. In many cases, though, because of device symmetries, it is possible to reuse the results from one simulation in place of others.

In our example, if we simulate the field distribution using port P0 as input, we can use the same results to deduce the fields when the input is at port P2, as long as we properly account for polarization-specific phase inversions. We describe this symmetry as follows: ("P0" ,"P2", {"P2": "P0", "P1": "P3", "P3": "P1"}), that is, the results from input at P0 can be mapped to input at P2 (first 2 items) by replacing P2 with P0, P1 with P3, and P3 with P1 (dictionary). In general, the tuple (j, n, {i: m}) indicates that \(S_{mn}\) should be calculated from \(S_{ij}\) (and \(S_{nn}\) from \(S_{jj}\) is also implied).

image.png

By setting the proper symmetries, our device can be completely characterized from 2 FDTD runs (and a few mode solver runs for phase correction) instead of 4 FDTD runs.

[3]:
port_symmetries = [
    ("P0", "P2", {"P2": "P0", "P1": "P3", "P3": "P1"}),
    ("P1", "P3", {"P0": "P2", "P2": "P0", "P3": "P1"}),
]

model = pf.Tidy3DModel(port_symmetries=port_symmetries)

coupler.add_model(model, "Tidy3DModel")
coupler.models
[3]:
{'Tidy3DModel': Tidy3DModel(run_time=None, medium=None, symmetry=(0, 0, 0), boundary_spec=None, monitors=(), structures=(), grid_spec=None, shutoff=1e-05, subpixel=True, courant=0.99, port_symmetries=[('P0', 'P2', {'P2': 'P0', 'P1': 'P3', 'P3': 'P1', 'P0': 'P2'}), ('P1', 'P3', {'P0': 'P2', 'P2': 'P0', 'P3': 'P1', 'P1': 'P3'})], bounds=((None, None, None), (None, None, None)), verbose=True)}

No we can compute and plot the S parameters. The warnings generated by Tidy3D are due to the slab region ending exactly at the simulation boundaries, which is not a problem in this case because it is far enough from the waveguide core.

[4]:
freqs = pf.C_0 / np.linspace(1.28, 1.36, 9)

_ = pf.plot_s_matrix(coupler.s_matrix(freqs))
Starting...
Loading cached simulation from .tidy3d/pf_cache/N4H/fdtd_info-YQUMBQ5GVT54MOHL72VJHQG2364P5YCNWQGBUHLTW6KFJ4WXCLLA.json.
Loading cached simulation from .tidy3d/pf_cache/N4H/fdtd_info-2J25C7GYUZKJXES3RBCAP4QRGLIF672X7EXG5EYS3WG7O6PNCFBA.json.
Progress: 100%
../_images/guides_Tidy3D_Model_7_1.png

Other parameters that might require adjustments for specific use cases are run_time (which should be increased from the default for resonant devices) and grid_spec (if the default grid settings are not refined enough for the geometry in question).

Skipping Unnecessary Runs

There are situations when we are only interested in a single or a few S matrix elements. In such cases, it is possible to limit the number of simulation runs by selecting the ports we want to be used as sources. As a result, only the matrix elements from those source will be computed (plus any other allowed by port symmetries).

For example, we can constrain the computation to use only the fundamental mode of port P3 as source:

[5]:
s_limited = coupler.s_matrix(freqs, model_kwargs={"inputs": ["P3@0"]})
s_limited.elements.keys()
Starting...
Loading cached simulation from .tidy3d/pf_cache/N4H/fdtd_info-FXDIGEF6674BOQT75R3HTMJ6ZFM3ICZFWARR6RDV2YLXABUDSJSQ.json.
Progress: 100%
[5]:
dict_keys([('P3@0', 'P0@0'), ('P3@0', 'P1@0'), ('P3@0', 'P2@0'), ('P3@0', 'P3@0')])

Visualizing The Fields

During early design phases, it might be necessary to visualize the field distributions on the device to guide the design. That can be accomplished with the Tidy3D model by including a conventional Tidy3D field monitor:

[6]:
import tidy3d as td

field_monitor = td.FieldMonitor(
    center=(0, 0, 0.11), size=(td.inf, td.inf, 0), freqs=[freqs.mean()], name="field"
)

model2 = pf.Tidy3DModel(monitors=[field_monitor], port_symmetries=port_symmetries, verbose=False)

# The newly added model is set to active automatically
coupler.add_model(model2, "Tidy3DModel2")
coupler.active_model
[6]:
Tidy3DModel(run_time=None, medium=None, symmetry=(0, 0, 0), boundary_spec=None, monitors=[FieldMonitor(attrs={}, type='FieldMonitor', center=(0.0, 0.0, 0.11), size=(inf, inf, 0.0), name='field', interval_space=(1, 1, 1), colocate=True, freqs=(227202454954557.62,), apodization=ApodizationSpec(attrs={}, start=None, end=None, width=None, type='ApodizationSpec'), fields=('Ex', 'Ey', 'Ez', 'Hx', 'Hy', 'Hz'))], structures=(), grid_spec=None, shutoff=1e-05, subpixel=True, courant=0.99, port_symmetries=[('P0', 'P2', {'P2': 'P0', 'P1': 'P3', 'P3': 'P1', 'P0': 'P2'}), ('P1', 'P3', {'P0': 'P2', 'P2': 'P0', 'P3': 'P1', 'P1': 'P3'})], bounds=((None, None, None), (None, None, None)), verbose=False)

After a new computation with the new model, the batch data from the simulations will contain the field distributions from Tidy3D as usual.

[7]:
s_matrix = coupler.s_matrix(freqs)

data = coupler.active_model.batch_data_for(coupler)
data.task_ids
Starting...
Progress: 100%
[7]:
{'P0@0': 'fdve-f89e0e43-3d92-4a9e-907e-f3426bbc35a3',
 'P1@0': 'fdve-5c05dc4b-db55-4f34-af4f-2e0902cb5e72'}
[8]:
_ = data["P1@0"].plot_field("field", "E", val="abs^2", robust=False)
../_images/guides_Tidy3D_Model_15_0.png

Diving Deeper

Finally, it is also possible to get the simulations generated by the Tidy3D model to inspect and customize them at will:

[9]:
simulations = model2.get_simulations(coupler, freqs)
simulations.keys()
[9]:
dict_keys(['P0@0', 'P1@0'])
[10]:
_ = simulations["P0@0"].plot(z=0.11)
../_images/guides_Tidy3D_Model_18_0.png