TIDY3D
LEARNING CENTER

Complex mode sorting

A mode solver returns its modes as an ordered list, but the position in that list is not a physical label. As a geometry parameter is swept, the mode sitting at a given index can change identity: when two branches cross, the largest-\(n_\mathrm{eff}\) mode can switch from TE-like to TM-like. Blindly following a fixed mode_index across the sweep therefore risks stitching together different physical branches and drawing the wrong conclusions.

In this tutorial we sweep the slot width of a silicon slot waveguide and keep each polarization branch cleanly separated:

  1. run a naive sweep and see how a fixed mode index mixes polarization branches;
  2. use ModeSortSpec to sort each branch (the TE-like slot mode and the TM-like mode) directly from the modes already computed.
from __future__ import annotations

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import tidy3d as td

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

Setup

We sweep the air slot width while keeping the total silicon waveguide width fixed. As the slot narrows, the dominant TE-like and TM-like modes approach each other and the default returned order changes branch identity, exactly the situation we want to handle.

Parameters

Sweep range, waveguide dimensions, refractive indices, and mode-solver settings.

wavelength = 1.55
freq = td.C_0 / wavelength

core_thickness = 0.22
width = 0.55
slot_widths = np.round(np.arange(0.04, 0.161, 0.02), 3)

n_core = 3.48
n_subs = 1.44
n_clad = 1.00
n_slot = n_clad

num_modes = 4
te_threshold = 0.5

sim_size = (5.0, 5.0, 1.0)
mode_plane_size = (5.0, 5.0, 0.0)

TE_CMAP = "jet"

Materials

Non-dispersive dielectrics for the core, substrate, cladding, and slot.

clad = td.Medium(permittivity=n_clad**2)
substrate_medium = td.Medium(permittivity=n_subs**2)
core_medium = td.Medium(permittivity=n_core**2)
slot_medium = td.Medium(permittivity=n_slot**2)

Simulation Builder

The helper below builds the slot-waveguide cross section and wraps it in a ModeSimulation. The same geometry is reused at every step; only the ModeSpec or the sorting rule changes.

def make_slot_waveguide_mode_sim(slot_width, mode_spec):
    rail_width = (width - slot_width) / 2

    structures = [
        td.Structure(
            geometry=td.Box(center=(0, -0.5, 0), size=(td.inf, 1.0, td.inf)),
            medium=substrate_medium,
            name="substrate",
        ),
        td.Structure(
            geometry=td.Box(
                center=(-(slot_width / 2 + rail_width / 2), core_thickness / 2, 0),
                size=(rail_width, core_thickness, td.inf),
            ),
            medium=core_medium,
            name="left_rail",
        ),
        td.Structure(
            geometry=td.Box(
                center=((slot_width / 2 + rail_width / 2), core_thickness / 2, 0),
                size=(rail_width, core_thickness, td.inf),
            ),
            medium=core_medium,
            name="right_rail",
        ),
        td.Structure(
            geometry=td.Box(
                center=(0, core_thickness / 2, 0),
                size=(slot_width, core_thickness, td.inf),
            ),
            medium=slot_medium,
            name="slot_region",
        ),
    ]

    return td.ModeSimulation(
        size=sim_size,
        medium=clad,
        structures=structures,
        boundary_spec=td.BoundarySpec.all_sides(boundary=td.PML()),
        grid_spec=td.GridSpec.auto(wavelength=wavelength, min_steps_per_wvl=40),
        mode_spec=mode_spec,
        freqs=[freq],
        plane=td.Box(center=(0, core_thickness / 2, 0), size=mode_plane_size),
    )


def make_simulations(mode_spec):
    simulations = {}

    for slot_width in slot_widths:
        name = f"slot={slot_width:.3f}"
        simulations[name] = make_slot_waveguide_mode_sim(slot_width, mode_spec)

    return simulations

Helper Functions

mode_dataframe collects the effective index and TE fraction of every mode into a single table, which the plots reuse. In the mode-path plots the line color encodes the TE fraction: blue is TM-like, red is TE-like.

def mode_dataframe(results):
    mode_tables = []

    for slot_width in slot_widths:
        name = f"slot={slot_width:.3f}"
        sim_data = results[name]

        mode_table = sim_data.modes.to_dataframe().reset_index()
        mode_table = mode_table.rename(
            columns={"n eff": "n_eff", "TE (Ex) fraction": "TE_fraction"}
        )
        mode_table["key"] = name
        mode_table["slot_width_um"] = slot_width

        mode_tables.append(
            mode_table[["key", "slot_width_um", "mode_index", "n_eff", "TE_fraction"]]
        )

    return (
        pd.concat(mode_tables, ignore_index=True)
        .sort_values(["slot_width_um", "mode_index"])
        .reset_index(drop=True)
    )


def add_te_fraction_line(ax, df, label, linewidth=2.0):
    df = df.sort_values("slot_width_um")
    x = df["slot_width_um"].to_numpy()
    y = df["n_eff"].to_numpy()
    c = df["TE_fraction"].to_numpy()

    ax.plot(x, y, color="black", linestyle="--", linewidth=linewidth, alpha=0.55)
    points = ax.scatter(x, y, c=c, cmap=TE_CMAP, vmin=0, vmax=1)

    ax.text(x[-1] + 0.002, y[-1], label, fontsize=9, va="center")
    return points


def plot_mode_paths(df, group_column, title, label_prefix="mode "):
    fig, ax = plt.subplots(figsize=(7.4, 4.8))

    mappable = None
    for group_value, group_df in df.groupby(group_column, sort=True):
        label = f"{label_prefix}{group_value}" if label_prefix else str(group_value)
        mappable = add_te_fraction_line(ax, group_df, label=label)

    ax.autoscale()
    ax.margins(x=0.12, y=0.08)
    ax.set_xlabel("Slot width (µm)")
    ax.set_ylabel(r"$n_\mathrm{eff}$")
    ax.set_title(title)
    ax.grid(True, alpha=0.22)

    cbar = fig.colorbar(mappable, ax=ax)
    cbar.set_label("TE fraction")

    plt.show()


def plot_mode_fields(results, modes_df, mode_indices, slot_values, title):
    fig, axes = plt.subplots(
        len(mode_indices),
        len(slot_values),
        figsize=(4.2 * len(slot_values), 3.4 * len(mode_indices)),
        tight_layout=True,
    )
    axes = np.atleast_2d(axes)

    for row_index, mode_index in enumerate(mode_indices):
        for col_index, slot_width in enumerate(slot_values):
            ax = axes[row_index, col_index]
            row = modes_df[
                (modes_df["slot_width_um"] == slot_width) & (modes_df["mode_index"] == mode_index)
            ].iloc[0]

            sim_data = results[row["key"]]
            sim_data.plot_field(
                "E",
                "abs",
                mode_index=int(row["mode_index"]),
                f=freq,
                ax=ax,
                robust=False,
            )

            ax.set_title(
                f"mode {mode_index}, slot={slot_width:.2f} µm\n"
                rf"$n_\mathrm{{eff}}$={row['n_eff']:.4f}, TE={row['TE_fraction']:.3f}",
                fontsize=9,
            )
            ax.set_xlim(-0.8, 0.8)
            ax.set_ylim(-0.5, 0.7)

    fig.suptitle(title, y=1.02, fontsize=12)
    plt.show()


def plot_branch_fields(results_by_branch, branches, branch_names, slot_values, title):
    fig, axes = plt.subplots(
        len(branch_names),
        len(slot_values),
        figsize=(4.2 * len(slot_values), 3.4 * len(branch_names)),
        tight_layout=True,
    )
    axes = np.atleast_2d(axes)

    for row_index, branch_name in enumerate(branch_names):
        branch = branches[branches["branch"] == branch_name].set_index("slot_width_um")
        results = results_by_branch[branch_name]

        for col_index, slot_width in enumerate(slot_values):
            ax = axes[row_index, col_index]
            row = branch.loc[slot_width]

            sim_data = results[row["key"]]
            mode_index = int(row["mode_index"])
            sim_data.plot_field("E", "abs", mode_index=mode_index, f=freq, ax=ax, robust=False)

            ax.set_title(
                f"{branch_name}, slot={slot_width:.2f} µm\n"
                rf"mode {mode_index}, $n_\mathrm{{eff}}$={row['n_eff']:.4f}",
                fontsize=9,
            )
            ax.set_xlim(-0.8, 0.8)
            ax.set_ylim(-0.5, 0.7)

    fig.suptitle(title, y=1.02, fontsize=12)
    plt.show()

Step 1: Run a Naive Sweep

Without a sorting rule, Tidy3D returns the modes ordered by decreasing real part of the effective index, so mode_index=0 is simply the mode with the largest \(n_\mathrm{eff}\) at each slot width.

That ordering is not a physical label: when two branches cross and exchange their effective-index ranking, the mode_index=0 path jumps from TE-like to TM-like. The plots below show the effective-index paths and the field profiles at the two ends of the sweep.

naive_mode_spec = td.ModeSpec(
    num_modes=num_modes,
    target_neff=n_core,
)


simulations = make_simulations(naive_mode_spec)
naive_results = {}
for name, sim in simulations.items():
    naive_results[name] = sim.run_local()

naive_modes_df = mode_dataframe(naive_results)
15:31:32 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:31:42 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:31:51 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:32:01 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:32:10 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:32:20 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
15:32:29 UTC WARNING: Mode field at frequency index 0, mode index 2 does not    
             decay at the plane boundaries.                                     
             WARNING: Mode field at frequency index 0, mode index 3 does not    
             decay at the plane boundaries.                                     
plot_mode_paths(
    naive_modes_df,
    group_column="mode_index",
    title="Naive returned order: fixed mode indices mix branches",
)

plot_mode_fields(
    naive_results,
    naive_modes_df,
    mode_indices=[0, 1],
    slot_values=[slot_widths[0], slot_widths[-1]],
    title="Naive field check: the same returned index changes physical branch",
)

Step 2: Sort Each Polarization Branch

The naive solve already computed every requested mode, so we do not solve again; we only reorder. We apply a ModeSortSpec to each ModeSimulationData with sort_modes(sort_spec) in post-processing, once for each polarization branch.

Sort the TE-Like Slot Mode

The rule keeps modes whose TE_fraction is above the threshold (filter_order="over") and sorts them by the field fill fraction inside the slot box, so the TE-like slot mode lands at mode_index=0.

def make_sort_spec(slot_width, filter_order="over"):
    slot_box = td.Box(
        center=(0, core_thickness / 2, 0),
        size=(slot_width, core_thickness, 0),
    )

    return td.ModeSortSpec(
        filter_key="TE_fraction",
        filter_reference=te_threshold,
        filter_order=filter_order,
        sort_key="fill_fraction_box",
        bounding_box=slot_box,
    )


sorted_results = {}

for slot_width in slot_widths:
    name = f"slot={slot_width:.3f}"
    sorted_results[name] = naive_results[name].sort_modes(make_sort_spec(slot_width))

sorted_modes_df = mode_dataframe(sorted_results)
plot_mode_paths(
    sorted_modes_df,
    group_column="mode_index",
    title="Sorted result: TE-like modes are placed first",
)

The Next Mode Is Not Necessarily TM

Putting the TE-like slot mode first does not make mode_index=1 the TM mode. It is simply the next mode in the sorted list, and with keep_modes="all" other TE-like modes can still appear before the first TM-like one. The two plots below confirm that both mode_index=0 and mode_index=1 are TE-like here.

first_two_modes_df = sorted_modes_df[sorted_modes_df["mode_index"].isin([0, 1])]

plot_mode_paths(
    first_two_modes_df,
    group_column="mode_index",
    title="First two sorted modes: mode_index=1 is still not TM-like",
)

plot_mode_fields(
    sorted_results,
    sorted_modes_df,
    mode_indices=[0, 1],
    slot_values=[slot_widths[0], slot_widths[-1]],
    title="Sorted field check: mode_index=0 and mode_index=1 are both TE-like here",
)

Sort the TM-Like Branch

To follow the TM-like branch, we sort the same computed modes with the opposite filter (filter_order="under") and take mode_index=0. Together with the TE-like branch, we now track both physical branches cleanly across the sweep, with no extra mode solves.

tm_sorted_results = {
    f"slot={slot_width:.3f}": naive_results[f"slot={slot_width:.3f}"].sort_modes(
        make_sort_spec(slot_width, filter_order="under")
    )
    for slot_width in slot_widths
}

te_branch_df = sorted_modes_df[sorted_modes_df["mode_index"] == 0].assign(branch="TE-like")

tm_branch_df = mode_dataframe(tm_sorted_results)
tm_branch_df = tm_branch_df[tm_branch_df["mode_index"] == 0].assign(branch="TM-like")

branches_df = (
    pd.concat([te_branch_df, tm_branch_df], ignore_index=True)
    .sort_values(["branch", "slot_width_um"])
    .reset_index(drop=True)
)
plot_mode_paths(
    branches_df,
    group_column="branch",
    title="Physical branches selected from the same sorted run",
    label_prefix="",
)

plot_branch_fields(
    {"TE-like": sorted_results, "TM-like": tm_sorted_results},
    branches_df,
    branch_names=["TE-like", "TM-like"],
    slot_values=[slot_widths[0], slot_widths[-1]],
    title="Final field check: selected TE-like and TM-like branches",
)

Summary

A robust sorting workflow separates two ideas:

  1. How the solver returns modes: the default order is numerical and can mix physical branches during a sweep.
  2. How you identify the mode you need: use modal diagnostics such as TE fraction, effective index, and field profiles.

Here, ModeSortSpec sorts the same computed modes once per polarization: filter_order="over" brings the TE-like slot mode to mode_index=0, and filter_order="under" does the same for the TM-like branch. Both come from a single mode solve via ModeSimulationData.sort_modes(sort_spec), with no duplicate solves needed.