TIDY3D
LEARNING CENTER

Complete this course to earn 50 FlexCredits Credits are added to your account when all course items are checked in green.

Local permittivity with subpixel averaging

This notebook demonstrates how to use local subpixel averaging in Tidy3D to obtain a more accurate permittivity profile, and more accurate results when running a ModeSimulation locally.

For more information about subpixel averaging, refer to this lecture.

The local subpixel averaging feature requires Tidy3D extras. You can install it with:

pip install "tidy3d[extras]"

The notebook builds a three-layer dielectric stack and evaluates the local permittivity along a vertical line using Simulation.epsilon(). The result is then compared against a full FDTD run using a PermittivityMonitor.

Key concepts:

  • Without subpixel averaging, each interface is represented as a staircase on the Yee grid.
  • With subpixel averaging, the local effective permittivity near a dielectric interface is smoothed to better represent the true filling fraction inside each cell.
  • The largest difference between the two approaches appears near the waveguide boundaries; deep inside homogeneous regions both methods agree closely.
import matplotlib.pyplot as plt
import numpy as np
import tidy3d as td
import tidy3d.web as web

td.config.logging.level = "ERROR"

Geometry and Physical Parameters

A three-layer dielectric stack is embedded in a silica-like background (Medium with n = 1.44). The stack is oriented along z with three 100 nm layers of increasing refractive index (n = 2, 3, 4). The line probe runs along z through the stack center and is used to query the local permittivity.

# Central wavelength and frequency used for all epsilon queries and monitors
wavelength = 1.55
freq0 = td.C_0 / wavelength

# Background medium
cladding_medium = td.Medium(permittivity=1.44**2)

# Layer media defined by refractive index
bottom_layer_medium = td.Medium(permittivity=2.0**2)
middle_layer_medium = td.Medium(permittivity=3.0**2)
top_layer_medium = td.Medium(permittivity=4.0**2)

# Lateral dimensions in micrometers
waveguide_width = 0.50
waveguide_length = 1e3

# Vertical stack definition in micrometers
layer_thickness = 0.10
stack_thickness = 3 * layer_thickness

# Simulation region dimensions in micrometers
sim_size_x = 4.0
sim_size_y = 2.0
sim_size_z = 4.0

# Mesh target
min_steps_per_wvl = 10

# Three stacked layers along z
bottom_layer_box = td.Box(
    center=(0.0, 0.0, -layer_thickness),
    size=(waveguide_length, waveguide_width, layer_thickness),
)

middle_layer_box = td.Box(
    center=(0.0, 0.0, 0.0),
    size=(waveguide_length, waveguide_width, layer_thickness),
)

top_layer_box = td.Box(
    center=(0.0, 0.0, layer_thickness),
    size=(waveguide_length, waveguide_width, layer_thickness),
)

bottom_layer = td.Structure(
    geometry=bottom_layer_box,
    medium=bottom_layer_medium,
    name="bottom_layer_n2",
)

middle_layer = td.Structure(
    geometry=middle_layer_box,
    medium=middle_layer_medium,
    name="middle_layer_n3",
)

top_layer = td.Structure(
    geometry=top_layer_box,
    medium=top_layer_medium,
    name="top_layer_n4",
)

stack_structures = [bottom_layer, middle_layer, top_layer]

# Box used for the local epsilon extraction.
# It is a line along z located at x=0 and y=0, crossing the stack vertically.
line_box = td.Box(
    center=(0.0, 0.0, 0.0),
    size=(0.0, 0.0, 1.0),
)

layer_boundaries = np.array(
    [
        -stack_thickness / 2,
        -stack_thickness / 2 + layer_thickness,
        -stack_thickness / 2 + 2 * layer_thickness,
        stack_thickness / 2,
    ]
)

Helper to Build Simulations

The helper function keeps the geometry fixed and only toggles the subpixel flag and td.config.simulation.use_local_subpixel. When include_source_and_monitor=True, a dummy PlaneWave source and a PermittivityMonitor are added to enable the FDTD run in Section 4.

def make_simulation(include_source_and_monitor=False):
    monitors = []
    sources = []

    if include_source_and_monitor:
        source_time = td.GaussianPulse(
            freq0=freq0,
            fwidth=0.2 * freq0,
        )

        plane_wave = td.PlaneWave(
            center=(0.0, 0.0, -0.6),
            size=(sim_size_x, sim_size_y, 0.0),
            source_time=source_time,
            direction="+",
        )

        permittivity_monitor = td.PermittivityMonitor(
            center=line_box.center,
            size=line_box.size,
            freqs=[freq0],
            name="eps_line_monitor",
        )

        sources = [plane_wave]
        monitors = [permittivity_monitor]

    sim = td.Simulation(
        size=(sim_size_x, sim_size_y, sim_size_z),
        center=(0.0, 0.0, 0.0),
        medium=cladding_medium,
        structures=stack_structures,
        sources=sources,
        monitors=monitors,
        grid_spec=td.GridSpec.auto(
            wavelength=wavelength,
            min_steps_per_wvl=min_steps_per_wvl,
        ),
        run_time=1e-14,
        boundary_spec=td.BoundarySpec.all_sides(td.PML()),
    )
    return sim

Local Epsilon without and with Local Subpixel Averaging

Two Simulation objects are created, with and without local subpixel.

coord_key options:

  • "centers" — samples at cell centers; returns the average of the diagonal permittivity components.
  • "Ex", "Ey", "Ez" — samples at the corresponding Yee-grid field locations; returns eps_xx, eps_yy, eps_zz, respectively.

Here coord_key="Ex" is used so the local epsilon query is sampled at the same Yee-grid location as PermittivityMonitor.eps_xx, enabling a direct comparison in Section 4.

# Local epsilon without local subpixel averaging
td.config.simulation.use_local_subpixel = False
sim_no_subpixel = make_simulation(include_source_and_monitor=False)

eps_line_no_subpixel = sim_no_subpixel.epsilon(
    box=line_box,
    coord_key="Ex",
    freq=freq0,
)

# Local epsilon with local subpixel averaging
td.config.simulation.use_local_subpixel = True

sim_with_subpixel = make_simulation(include_source_and_monitor=False)

eps_line_with_subpixel = sim_with_subpixel.epsilon(
    box=line_box,
    coord_key="Ex",
    freq=freq0,
)
def extract_line_profile(eps_data):
    # For a line box, keep the non-singleton spatial axis.
    # The returned object can carry tensor components such as eps_xx, eps_yy, eps_zz.
    if hasattr(eps_data, "eps_xx"):
        data = eps_data.eps_xx
    else:
        data = eps_data

    squeezed = data.squeeze(drop=True)

    for axis_name in ("x", "y", "z"):
        if axis_name in squeezed.coords and squeezed.coords[axis_name].size > 1:
            coord = squeezed.coords[axis_name].values
            values = np.real(squeezed.values)
            return axis_name, coord, values

    raise RuntimeError("Could not identify the varying spatial coordinate in the epsilon data.")


axis_no_subpixel, coord_no_subpixel, values_no_subpixel = extract_line_profile(eps_line_no_subpixel)

axis_with_subpixel, coord_with_subpixel, values_with_subpixel = extract_line_profile(
    eps_line_with_subpixel
)
fig, ax = plt.subplots(figsize=(8, 5))

ax.plot(coord_no_subpixel, values_no_subpixel, "o-", label="epsilon(box), no subpixel")

ax.plot(
    coord_with_subpixel,
    values_with_subpixel,
    "o-",
    label="epsilon(box), with subpixel",
)

for boundary_index, boundary_position in enumerate(layer_boundaries):
    label = "layer boundary" if boundary_index == 0 else None
    ax.axvline(boundary_position, linestyle=":", alpha=0.5, label=label)

ax.set_xlabel(f"{axis_no_subpixel} (µm)")
ax.set_ylabel("Local permittivity")
ax.set_title("Local permittivity across the three-layer stack")
ax.legend()
ax.set_xlim(-0.2, 0.2)
plt.show()

The curves agree inside each homogeneous layer and differ mainly at the dielectric boundaries.

  • The non-subpixel result transitions abruptly — each interface maps directly onto the nearest Yee cell edge.
  • The subpixel result transitions smoothly — it approximates the effective filling fraction at each interface.
  • The three 100 nm layers allow multiple interface transitions to be inspected in a single profile.

FDTD Run with PermittivityMonitor

A minimal FDTD job (run_time = 1e-14 s) is run to sample the permittivity via a PermittivityMonitor. The result is compared against the local epsilon computed in Section 3.

The very short run_time is intentional — only the discretized permittivity is needed, not converged field data. A field-decay warning is expected.

sim_monitor = make_simulation(include_source_and_monitor=True)

sim_data = web.run(
    sim_monitor,
    task_name="subpixel_local_epsilon_dummy_monitor",
)
10:26:59 UTC Created task 'subpixel_local_epsilon_dummy_monitor' with           
             resource_id 'fdve-b01bd810-d5ca-4d99-b82c-5e74e164db81' and        
             task_type 'FDTD'.                                                  
             Task folder: 'default'.                                            

10:27:01 UTC Estimated FlexCredit cost: 0.025. This assumes the FDTD solver runs
             for the full simulation time; if early shutoff is reached, the     
             billed cost can be lower. Use 'web.real_cost(task_id)' to get the  
             billed FlexCredit cost after a simulation run.                     
10:27:03 UTC status = success                                                   

10:27:04 UTC Loading results from simulation_data.hdf5                          
# Grab the monitored permittivity directly along the z line.
monitor_eps_xx = sim_data["eps_line_monitor"].eps_xx.squeeze(drop=True)
monitor_coord = monitor_eps_xx.coords["z"].values
monitor_values = np.real(monitor_eps_xx.values)

fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(
    coord_with_subpixel,
    values_with_subpixel,
    "o-",
    label="local epsilon with subpixel",
)
ax.plot(monitor_coord, monitor_values, "s--", label="PermittivityMonitor")

for boundary_index, boundary_position in enumerate(layer_boundaries):
    label = "layer boundary" if boundary_index == 0 else None
    ax.axvline(boundary_position, linestyle=":", alpha=0.5, label=label)

ax.set_xlabel("z (µm)")
ax.set_ylabel("Permittivity")
ax.set_title("Local epsilon vs PermittivityMonitor for the three-layer stack")
ax.legend()
plt.show()

As we can see, the local subpixel matches exactly the grid used in cloud computation.