Skip to content

Results and field monitors

When fdtdx.run_fdtd returns, everything the monitors recorded is in arrays.detector_states, keyed by monitor name. The example is a short simulation of a straight waveguide (about a minute on 8 CPU cores).

Run a short simulation

The device and settings of Simulation setup and ports, plus two Poynting-flux monitors and a field monitor, run for 300 fs so the whole pulse crosses the guide:

import fdtdx
import jax
import numpy as np
import gdsfactory as gf
import bifrost as bf
from gdsfactory.technology import LayerLevel, LayerStack

gf.gpdk.PDK.activate()

component = gf.components.straight(length=5)  # a 5 um long, 500 nm wide waveguide with ports o1 and o2
layer_stack = LayerStack(
    layers={"core": LayerLevel(layer=gf.gpdk.LAYER.WG, zmin=0.0, thickness=0.22, material="core", mesh_order=1)}
)
materials = {"core": fdtdx.Material(permittivity=3.48**2), "clad": fdtdx.Material(permittivity=1.444**2)}
stack = bf.StackDevice.from_layer_stack(
    component, layer_stack, materials, background="clad", zrange_layers=["core"],
    extend_ports=(("o1", 2.0), ("o2", 2.0)), name="stack",
    boundary_offset=(0, 0, 1.6, -1.6, 1.59, -1.59),  # a 9 x 3.7 x 3.4 um box: whole cells at 50 nm
)

RESOLUTION = 0.05  # um
config = stack.get_simulation_config(time=300e-15, grid=fdtdx.core.grid.UniformGrid(spacing=RESOLUTION * 1e-6))
wave = fdtdx.WaveCharacter(wavelength=1.55e-6)
pulse = fdtdx.GaussianPulseProfile(
    center_wave=wave, spectral_width=fdtdx.WaveCharacter(frequency=0.05 * wave.get_frequency())
)
te0 = dict(mode_index=0, filter_pol="te")
volume, constraints = stack.get_simulation_volume()
objects = [volume, stack]
for obj, placing in (
    stack.create_mode_plane_source_at(
        "o1", (0.5, 0.3), wave_character=wave, temporal_profile=pulse, port_offset=-0.5, name="source", **te0,
    ),
    # mode monitors: how much of TE0 passes each port
    stack.create_mode_overlap_detector_at(
        "o1", (0.5, 0.3), direction="in", wave_characters=(wave,), scaling_mode="pulse", name="in", **te0,
    ),
    stack.create_mode_overlap_detector_at(
        "o2", (0.5, 0.3), wave_characters=(wave,), scaling_mode="pulse", name="out", **te0,
    ),
    # Poynting-flux monitors: the power through a plane at every time step (no wave of their own, so
    # the padding's unit is wavelength_um)
    stack.create_poynting_flux_detector_at("o1", (0.5, 0.3), wavelength_um=1.55, direction="in", name="flux_in"),
    stack.create_poynting_flux_detector_at("o2", (0.5, 0.3), wavelength_um=1.55, name="flux_out"),
):
    objects.append(obj)
    constraints += placing
# a field monitor: E on the xy plane through the middle of the core, at the wavelength
field = fdtdx.PhasorDetector(
    name="field", wave_characters=(wave,), components=("Ex", "Ey", "Ez"),
    scaling_mode="pulse", partial_grid_shape=(None, None, 1),
)
objects.append(field)
constraints += [field.same_size(volume, axes=(0, 1)), field.place_at_center(volume)]
boundaries, placing = fdtdx.boundary_objects_from_config(
    fdtdx.BoundaryConfig.from_uniform_bound(thickness=round(1.0 / RESOLUTION)), volume
)
objects += boundaries.values()
constraints += placing

key = jax.random.PRNGKey(0)
objects, arrays, params, config, _ = fdtdx.place_objects(objects, config, constraints, key)
arrays, objects, _ = bf.apply_stack(arrays, objects, {**params, **bf.init_stack_params(objects)}, key)
_, arrays = fdtdx.run_fdtd(arrays=arrays, objects=objects, config=config, key=key)

Read the monitors

states = arrays.detector_states  # one entry per monitor, by name

# mode monitors: the complex amplitude of TE0, one entry per wave character
a_in = objects["in"].compute_overlap(states["in"])
a_out = objects["out"].compute_overlap(states["out"])
transmission = float(np.ravel(abs(a_out / a_in) ** 2)[0])
print(f"transmission: {transmission:.3f}")

# the Poynting-flux monitors: the power through each plane, one row per time step. Summed over the run it is
# the energy that crossed, so the ratio of the two is the fraction of the light that got through
flux_in = np.asarray(states["flux_in"]["poynting_flux"]).sum()
flux_out = np.asarray(states["flux_out"]["poynting_flux"]).sum()
print(f"flux ratio: {float(flux_out / flux_in):.3f}")

# the field monitor: a phasor per component, on the monitor's grid
phasor = np.asarray(states["field"]["phasor"][0, 0, :, :, :, 0])  # (3 components, nx, ny)
intensity = np.sum(np.abs(phasor) ** 2, axis=0)                   # |E|^2
print("field grid:", intensity.shape, "peak |E|^2:", float(intensity.max()))

Output:

transmission: 1.000
flux ratio: 1.000
field grid: (180, 74) peak |E|^2: 1132.8427734375
  • Mode monitors (ModeOverlapDetector) give the complex amplitude of a guided mode on their plane: monitor.compute_overlap(states[name]), one entry per wave character. The squared ratio of the output to the input amplitude is the transmission, normalized without a reference run because both monitors measure the same mode of the same guide. A straight guide transmits about 1: a quick check of the source, the monitors and the run time.
  • Poynting-flux monitors (PoyntingFluxDetector) record the power through their plane at every time step, in fdtdx's units. Summed over the run it is the energy that crossed, so the ratio of two of them cross-checks the transmission without assuming a mode (it also counts light in other modes).
  • Field monitors (PhasorDetector) record the field's complex amplitude at the wave characters you give them, on the monitor's grid: states[name]["phasor"][0, 0, :, :, :, 0] is (components, nx, ny) for an xy plane one cell thick. |E|² is the sum of the squared magnitudes over the components. For the field at many times, a FieldDetector records the same plane at time steps you choose (the bend example records 40 of them, which the viewer plays as a movie).

Choosing a monitor

You want Use Read it with
the power in a guided mode (transmission) create_mode_overlap_detector_at compute_overlap(state)
the power through a plane, any mode create_poynting_flux_detector_at state["poynting_flux"]
the field's amplitude and phase at a wavelength PhasorDetector state["phasor"]
the field at a sequence of times (a movie) FieldDetector with an OnOffSwitch(interval=...) the recorded frames
the energy in a region create_energy_detector_at state["energy"]

Mode and flux monitors are placed on ports (see Simulation setup and ports); a field monitor is any fdtdx detector, sized and placed with constraints, as in the script.

Open the result in the viewer

viewer = bf.view(stack)
viewer.load_result(objects, arrays, config, stack=stack)  # shows the monitors' fields in the Result tab

viewer.load_result takes what fdtdx hands out: the placed objects, the arrays and the config. The Result tab then has one read-only entry per monitor, with the field of every plane-shaped monitor drawn on its plane and a frame slider for monitors that recorded several frames; the Sources / Detectors tab lists the same objects. In a notebook, end the cell with viewer. More in Viewer.

Troubleshooting

  • The transmission is far from 1 for a straight guide. The run was too short: the output monitor must see the whole pulse. Make time longer than the pulse needs to cross the device (at 200 fs this script reported 0.87).
  • A monitor reports nothing, or the viewer shows no field for it. The viewer draws monitors that record a plane (one cell thick along its normal), and not ones that sum over their volume (reduce_volume=True).
  • The numbers are tiny or huge. fdtdx's flux and amplitudes are in its own units; compare ratios, not absolute values.