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 tidy3d as td
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]:
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).
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=None, subpixel=None, courant=None, 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)}
Credit Estimation and Running¶
Now we are ready to compute the S parameters, but we might want to estimate the cost of our simulations before that. The function get_simulations generates all simulations that will be run by the model. With them, we can create a Batch to estimate the cost.
[4]:
freqs = pf.C_0 / np.linspace(1.28, 1.36, 9)
simulations = model.get_simulations(coupler, freqs)
td.web.Batch(simulations=simulations).estimate_cost()
12:55:23 -03 Maximum FlexCredit cost: 2.230 for the whole batch.
[4]:
2.230112503105919
PhotonForge defaults are chosen to produce accurate simulation results in the most common cases, including poorly resonant devices. That means that the default mesh resolution is significantly higher than the default in Tidy3D, the run time is usually overestimated, and the simulation boundary layers are conservative in trying to provide minimal reflections without risks of divergence. Those defaults have the advantage of working “out of the box” for the great majority of planar circuits without requiring user intervention. However, the simulation costs might be higher than what is usually found in hand-tuned Tidy3D simulations (due to finer mesh and wider absorbing layers), and the estimated cost significantly higher than the real cost of the actual run.
Of course, the Tidy3DModel
can be created with specific settings for the component at hand to fine-tune the simulation as one would do in Tidy3D. A more convenient approach, when possible, is to change the default mesh refinement, or the run time and boundary specification in the default_kwargs to appropriate values to the PDK being used and types of components being
simulated.
We will keep the defaults and compute the S parameters as is:
[5]:
_ = pf.plot_s_matrix(coupler.s_matrix(freqs))
Starting...
Loading cached simulation from .tidy3d/pf_cache/N4H/fdtd_info-ADAOGIY4WIMIQOZKF4JF6U4G22ZMX6PO4L4CPUTKWJDXGBL7YUVQ.json.
Loading cached simulation from .tidy3d/pf_cache/N4H/fdtd_info-NY6CIGU6JLCZO4SX4ZOJPF5KEFH2CUUC3FVY42RSKX35SV7YXIOA.json.
Progress: 100%

Calculating Real Cost¶
After the simulations are run, we can get the associated batch data for the component. With the task ids stored within, the real simulation cost can be computed:
[6]:
data = model.batch_data_for(coupler)
total_cost = sum(td.web.real_cost(task_id) for task_id in data.task_ids.values())
print(f"Total FlexCredit cost: {total_cost:.3f}")
Billed flex credit cost: 0.568.
Note: the task cost pro-rated due to early shutoff was below the minimum threshold, due to fast shutoff. Decreasing the simulation 'run_time' should decrease the estimated, and correspondingly the billed cost of such tasks.
12:55:24 -03 Billed flex credit cost: 0.612.
Note: the task cost pro-rated due to early shutoff was below the minimum threshold, due to fast shutoff. Decreasing the simulation 'run_time' should decrease the estimated, and correspondingly the billed cost of such tasks.
Total FlexCredit cost: 1.180
We see that the total cost is well bellow the estimated cost, which is expected, as discussed before, because our component has no resonances and the default run time specification assumes a quality factor of 5.
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:
[7]:
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-FCMIRQI5VRCK6D63Y76BRWNF74WWUUOFFVQNB5REJZXRXFKCRZMA.json.
Progress: 100%
[7]:
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:
[8]:
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
[8]:
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=None, subpixel=None, courant=None, 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.
[9]:
s_matrix = coupler.s_matrix(freqs)
data = coupler.active_model.batch_data_for(coupler)
data.task_ids
Starting...
Progress: 100%
[9]:
{'P0@0': 'fdve-f01a392a-7450-4c8b-a36f-0f415a716b8d',
'P1@0': 'fdve-1fb7f487-c58a-4c56-8f16-53302d546305'}
[10]:
_ = data["P1@0"].plot_field("field", "E", val="abs^2", robust=False)

Diving Deeper¶
Finally, it is also possible to get the simulations generated by the Tidy3D model to inspect and customize them at will:
[11]:
simulations = model2.get_simulations(coupler, freqs)
simulations.keys()
[11]:
dict_keys(['P0@0', 'P1@0'])
[12]:
_ = simulations["P0@0"].plot(z=0.11)
