Skip to content

Mode Solver — TFLN Ridge Waveguide (meep)

Fundamental TE mode of thin-film lithium niobate ridge waveguide at lambda=1.55 µm. Reference: Ying Li et al., ACS Omega 2023, 8(10), 9644–9651.

Design: SiO2 cladding / LiNbO3 slab 220 nm / LiNbO3 ridge 180 nm (total 400 nm), ridge width 1.1 µm, sidewall angle 17°.

Expected: n_eff ~ 1.85, n_group ~ 2.20. background_material="sio2" fills unpatterned space with SiO2.

Imports

import gdsfactory as gf
import matplotlib.pyplot as plt
import numpy as np

import gsim.meep as gm
from gsim.common.stack.extractor import Layer, LayerStack

plt.close()

gf.gpdk.PDK.activate()

LiNbO3 material (Zelmon 1997)

LiNbO3 is birefringent and is now registered as a uniaxial material in gsim.common.stack.materials.MATERIALS_DB (notebook no longer manually registers it). Both ordinary and extraordinary Sellmeier models from Zelmon et al. (JOSA B 14(12), 3319--3322, 1997) are included:

Ordinary: n_o^2 = 1 + 2.6734 lam2/(lam2 - 0.01764) + 1.2290 lam2/(lam2 - 0.05914) + 12.614 lam2/(lam2 - 474.60)

Extraordinary: n_e^2 = 1 + 2.9804 lam2/(lam2 - 0.02047) + 0.5981 lam2/(lam2 - 0.0666) + 8.9543 lam2/(lam2 - 416.08)

For x-cut TFLN the TE mode's dominant E-field aligns with the extraordinary axis (zz).

Build the GDS component

SLAB_WIDTH = 5.0  # um --- wide slab
CORE_WIDTH = 1.1  # um --- w0 from reference design
LENGTH = 10.0  # um --- waveguide length (arbitrary for mode solving)

c = gf.Component()

# LiNbO3 ridge (layer 2) --- narrow core on top
c.add_polygon(
    [
        (-LENGTH / 2, -CORE_WIDTH / 2),
        (LENGTH / 2, -CORE_WIDTH / 2),
        (LENGTH / 2, CORE_WIDTH / 2),
        (-LENGTH / 2, CORE_WIDTH / 2),
    ],
    layer=(2, 0),
)

# Ports at both ends
c.add_port(
    name="o1",
    center=(-LENGTH / 2, 0),
    width=CORE_WIDTH,
    orientation=180,
    layer=(1, 0),
)
c.add_port(
    name="o2",
    center=(LENGTH / 2, 0),
    width=CORE_WIDTH,
    orientation=0,
    layer=(1, 0),
)

print(f"Component: {c.name}")
print(f"  Ports:  {[p.name for p in c.ports]}")
print(f"  Layers: {list(c.layers)}")
Component: Unnamed_0
  Ports:  ['o1', 'o2']
  Layers: [(2, 0)]

Layer stack

SiO2 fills background via background_material="sio2".

SLAB_THICKNESS = 0.22  # um  (h3)
CORE_THICKNESS = 0.40  # um --- total LiNbO3 thickness
RIDGE_THICKNESS = CORE_THICKNESS - SLAB_THICKNESS  # 0.18 um

layers = {
    "box": Layer(
        name="box",
        gds_layer=(0, 0),
        zmin=-1.0,
        zmax=0.0,
        thickness=1.0,
        material="sio2",
        layer_type="dielectric",
    ),
    "slab": Layer(
        name="slab",
        gds_layer=(1, 0),
        zmin=0.0,
        zmax=SLAB_THICKNESS,
        thickness=SLAB_THICKNESS,
        material="linbo3",
        layer_type="dielectric",
    ),
    "ridge": Layer(
        name="ridge",
        gds_layer=(2, 0),
        zmin=SLAB_THICKNESS,
        zmax=CORE_THICKNESS,
        thickness=RIDGE_THICKNESS,
        material="linbo3",
        layer_type="dielectric",
        sidewall_angle=17.0,
    ),
}
stack = LayerStack(layers=layers)

print("Layer stack (+ SiO2 background):")
for name, l in stack.layers.items():
    print(
        f"  {name:6s}  z=[{l.zmin:+.3f}, {l.zmax:+.3f}]  "
        f"t={l.thickness:.3f}  material={l.material}  gds={l.gds_layer}"
    )
Layer stack (+ SiO2 background):
  box     z=[-1.000, +0.000]  t=1.000  material=sio2  gds=(0, 0)
  slab    z=[+0.000, +0.220]  t=0.220  material=linbo3  gds=(1, 0)
  ridge   z=[+0.220, +0.400]  t=0.180  material=linbo3  gds=(2, 0)

Solve

WAVELENGTH = 1.55  # um
RESOLUTION = 64  # grid points per um
PML_THICKNESS = WAVELENGTH  # um

sim = gm.Simulation(
    geometry=gm.Geometry(component=c, stack=stack),
    domain=gm.Domain(
        pml=PML_THICKNESS,
        margin_z=(0.0, 0.5),
    ),
)
sim.mode_solver.wavelengths = [WAVELENGTH]
sim.mode_solver.fundamental().at_port("o1")
sim.mode_solver.y_span = SLAB_WIDTH
sim.mode_solver.n_field_y = 1000
sim.mode_solver.n_field_z = 1000
sim.mode_solver.background_material = "sio2"

sweep = sim.solve_modes()
mode = sweep.at(WAVELENGTH).band(1)

print(f"n_eff     = {mode.n_eff}")
print(f"n_group   = {mode.n_group}")
print(f"kdom      = {[f'{k:.6f}' for k in mode.kdom]}")
print(f"band      = {mode.band_num}, parity = {mode.parity}")
print(f"fields    = {list(mode.fields.keys())}")
for comp, arr in mode.fields.items():
    print(f"  {comp}: shape={arr.shape}  |max|={np.abs(arr).max():.6f}")
  meep-f3d9f05a  completed  0m 55s


Extracting results.tar.gz...


Downloaded 10 files to sim-data-meep-f3d9f05a
n_eff     = 1.8600558281393749
n_group   = 2.259414906519319
kdom      = ['1.200036', '-0.000000', '0.000000']
band      = 1, parity = NO_PARITY
fields    = ['Ex', 'Ey', 'Ez', 'Hx', 'Hy', 'Hz']
  Ex: shape=(1000, 1000)  |max|=0.597531
  Ey: shape=(1000, 1000)  |max|=2.475644
  Ez: shape=(1000, 1000)  |max|=0.372790
  Hx: shape=(1000, 1000)  |max|=1.995995
  Hy: shape=(1000, 1000)  |max|=0.413430
  Hz: shape=(1000, 1000)  |max|=4.933792

Index profile

mode.plot_index(show=True)

png

(<Figure size 700x500 with 2 Axes>,
 <Axes: title={'center': 'Refractive index  (lambda=1.55 um)'}, xlabel='y (µm)', ylabel='z (µm)'>)

Mode profile (interactive)

|field| maps for every component with zoom / pan / hover. The view is auto-cropped to the mode region and axes use equal aspect.

import plotly.graph_objects as go
from plotly.subplots import make_subplots

comps = [c for c in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz") if c in mode.fields]

# Crop to the (small, centered) mode region and downsample so the embedded
# heatmaps stay light-weight for the docs while remaining smooth on zoom.
energy = sum(np.abs(mode.fields[c]) ** 2 for c in comps)
thr = 0.02 * energy.max()
yi = np.where(energy.max(axis=0) > thr)[0]
zi = np.where(energy.max(axis=1) > thr)[0]
dy, dz = mode.y_grid[1] - mode.y_grid[0], mode.z_grid[1] - mode.z_grid[0]
pad = 0.4  # µm margin around the mode
y0, y1 = (
    max(yi.min() - int(pad / dy), 0),
    min(yi.max() + int(pad / dy) + 1, mode.y_grid.size),
)
z0, z1 = (
    max(zi.min() - int(pad / dz), 0),
    min(zi.max() + int(pad / dz) + 1, mode.z_grid.size),
)
max_pts = 140  # per axis
sy, sz = max(1, (y1 - y0) // max_pts), max(1, (z1 - z0) // max_pts)
y_sub, z_sub = mode.y_grid[y0:y1:sy], mode.z_grid[z0:z1:sz]

fig = make_subplots(
    rows=2,
    cols=3,
    subplot_titles=[f"|{c}|" for c in comps],
    horizontal_spacing=0.08,
    vertical_spacing=0.2,
)

for i, comp in enumerate(comps):
    row, col = i // 3 + 1, i % 3 + 1
    # Mode fields are in arbitrary units; normalize each panel to its own max
    # and quantize to 8-bit (0..255). Plotly embeds arrays as binary, so this
    # keeps the notebook small while staying visually identical.
    panel = np.abs(mode.fields[comp][z0:z1:sz, y0:y1:sy])
    panel = np.round(panel / panel.max() * 255).astype(np.uint8)
    fig.add_trace(
        go.Heatmap(
            x=y_sub,
            y=z_sub,
            z=panel,
            colorscale="Inferno",
            showscale=False,
            zmin=0,
            zmax=255,
            hovertemplate=(
                "y=%{x:.3f} µm<br>z=%{y:.3f} µm<br>"
                "|" + comp + "| (rel.)=%{z}<extra></extra>"
            ),
        ),
        row=row,
        col=col,
    )
    x_anchor = "x" if i == 0 else f"x{i + 1}"
    fig.update_xaxes(title_text="y (µm)", row=row, col=col)
    fig.update_yaxes(
        title_text="z (µm)", scaleanchor=x_anchor, scaleratio=1, row=row, col=col
    )

fig.update_layout(
    height=650,
    autosize=True,
    margin=dict(t=70, b=60),
    title=dict(
        text=(
            f"lambda={WAVELENGTH:.2f} µm, w0={CORE_WIDTH:.1f} µm, "
            f"n_eff={mode.n_eff:.4f}"
        ),
        x=0.5,
        xanchor="center",
        font=dict(size=13),
    ),
)
fig.show(config={"responsive": True})

Width sweep — band diagram

Sweep the ridge width w0 and track n_eff of the first few bands. Narrow ridges guide fewer modes, so higher-band curves appear only once the width is wide enough to support them. Modes below the SiO2 cladding index are radiative and are dropped (dashed line = cladding cutoff).

Each width is an independent cloud mode-solve. Jobs are submitted in batches (up to MAX_CONCURRENT in flight) and waited on concurrently, so the whole sweep finishes in a few minutes rather than running one width at a time.

import shutil
import tempfile
from pathlib import Path

from gsim import gcloud
from gsim.common.stack.materials import resolve_material_at_wavelength

# Snap widths to the 0.002 µm grid so gdsfactory ports stay grid-aligned.
W0_VALUES = np.round(np.linspace(0.3, 3.0, 25) / 0.002) * 0.002  # um
N_SWEEP_BANDS = 8
MAX_CONCURRENT = 10  # cloud jobs in flight at once

# SiO2 cladding index at this wavelength — guided modes must lie above it.
_clad = resolve_material_at_wavelength("sio2", WAVELENGTH)
_eps = _clad.permittivity
n_clad = float(np.sqrt(_eps if np.isscalar(_eps) else np.mean(_eps)))


def tfln_component(core_width: float) -> gf.Component:
    """Build the TFLN ridge component for a given ridge width."""
    comp = gf.Component()
    comp.add_polygon(
        [
            (-LENGTH / 2, -core_width / 2),
            (LENGTH / 2, -core_width / 2),
            (LENGTH / 2, core_width / 2),
            (-LENGTH / 2, core_width / 2),
        ],
        layer=(2, 0),
    )
    comp.add_port(
        name="o1",
        center=(-LENGTH / 2, 0),
        width=core_width,
        orientation=180,
        layer=(1, 0),
    )
    comp.add_port(
        name="o2",
        center=(LENGTH / 2, 0),
        width=core_width,
        orientation=0,
        layer=(1, 0),
    )
    return comp


def submit_mode_solve(core_width: float) -> str:
    """Upload + start one cloud mode-solve job (non-blocking). Returns job_id."""
    sim = gm.Simulation(
        geometry=gm.Geometry(component=tfln_component(core_width), stack=stack),
        domain=gm.Domain(pml=PML_THICKNESS, margin_z=(0.0, 0.5)),
    )
    sim.mode_solver.wavelengths = [WAVELENGTH]
    sim.mode_solver.first(N_SWEEP_BANDS).at_port("o1")
    sim.mode_solver.y_span = SLAB_WIDTH
    sim.mode_solver.background_material = "sio2"  # n_field=0 -> n_eff only

    tmp = Path(tempfile.mkdtemp(prefix="meep_mode_sweep_"))
    try:
        sim.write_mode_solver_config(tmp)
        job_id = gcloud.upload(tmp, "meep", verbose=False)
        gcloud.start(job_id, verbose=False)
        return job_id
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


# Submit jobs in batches so at most MAX_CONCURRENT run on the cloud at once,
# then wait for the whole batch concurrently. n_eff only (no field download).
sweep_results = []  # ModeSweepResult per width, in W0_VALUES order
for i in range(0, len(W0_VALUES), MAX_CONCURRENT):
    batch = W0_VALUES[i : i + MAX_CONCURRENT]
    job_ids = [submit_mode_solve(w0) for w0 in batch]
    print(f"batch {i // MAX_CONCURRENT + 1}: started {len(job_ids)} jobs")
    batch_results = gcloud.wait_for_results(job_ids, verbose="quiet")
    if not isinstance(batch_results, list):  # single-job batch returns one result
        batch_results = [batch_results]
    sweep_results.extend(batch_results)

# n_eff per band across the width sweep (NaN where the band is not guided).
neff_by_band = {b: [] for b in range(1, N_SWEEP_BANDS + 1)}
for w0, res in zip(W0_VALUES, sweep_results, strict=True):
    found = {r.band_num: r.n_eff for r in res.at(WAVELENGTH).results}
    for b in range(1, N_SWEEP_BANDS + 1):
        neff = found.get(b, np.nan)
        # drop radiative modes below the cladding index
        neff_by_band[b].append(neff if neff > n_clad else np.nan)
    guided = [b for b in range(1, N_SWEEP_BANDS + 1) if found.get(b, 0) > n_clad]
    summary = ", ".join(f"b{b}={found[b]:.4f}" for b in guided)
    print(f"w0={w0:.2f} µm -> {len(guided)} guided mode(s): {summary}")
batch 1: started 10 jobs


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


batch 2: started 10 jobs


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


batch 3: started 5 jobs


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...


Extracting results.tar.gz...
w0=0.30 µm -> 8 guided mode(s): b1=1.7447, b2=1.7259, b3=1.7129, b4=1.7262, b5=1.7211, b6=1.7270, b7=1.7201, b8=1.5921
w0=0.41 µm -> 8 guided mode(s): b1=1.7635, b2=1.7142, b3=1.7141, b4=1.7270, b5=1.7233, b6=1.7298, b7=1.7226, b8=1.6149
w0=0.52 µm -> 8 guided mode(s): b1=1.7816, b2=1.7147, b3=1.7146, b4=1.7281, b5=1.7246, b6=1.6424, b7=1.7256, b8=1.7243
w0=0.64 µm -> 8 guided mode(s): b1=1.8026, b2=1.7150, b3=1.7150, b4=1.7303, b5=1.6933, b6=1.6598, b7=1.7297, b8=1.7257
w0=0.75 µm -> 8 guided mode(s): b1=1.8210, b2=1.7152, b3=1.7152, b4=1.7343, b5=1.6938, b6=1.6734, b7=1.7336, b8=1.7266
w0=0.86 µm -> 8 guided mode(s): b1=1.8361, b2=1.7302, b3=1.7153, b4=1.7404, b5=1.6941, b6=1.7257, b7=1.7384, b8=1.7272
w0=0.98 µm -> 8 guided mode(s): b1=1.8489, b2=1.7347, b3=1.7153, b4=1.7147, b5=1.7331, b6=1.6944, b7=1.7433, b8=1.7279
w0=1.09 µm -> 8 guided mode(s): b1=1.8581, b2=1.7407, b3=1.7154, b4=1.7190, b5=1.7398, b6=1.6948, b7=1.7471, b8=1.7288
w0=1.20 µm -> 8 guided mode(s): b1=1.8671, b2=1.7509, b3=1.7155, b4=1.7237, b5=1.7455, b6=1.6953, b7=1.7504, b8=1.7302
w0=1.31 µm -> 8 guided mode(s): b1=1.8744, b2=1.7635, b3=1.7157, b4=1.7157, b5=1.7166, b6=1.6961, b7=1.7525, b8=1.7325
w0=1.42 µm -> 8 guided mode(s): b1=1.8804, b2=1.8069, b3=1.7160, b4=1.7160, b5=1.7179, b6=1.6975, b7=1.7538, b8=1.7359
w0=1.54 µm -> 8 guided mode(s): b1=1.8855, b2=1.8198, b3=1.7166, b4=1.7166, b5=1.7185, b6=1.6998, b7=1.7546, b8=1.6869
w0=1.65 µm -> 8 guided mode(s): b1=1.8894, b2=1.8302, b3=1.7174, b4=1.7173, b5=1.7188, b6=1.7024, b7=1.7552, b8=1.6914
w0=1.76 µm -> 8 guided mode(s): b1=1.8932, b2=1.8417, b3=1.7191, b4=1.7191, b5=1.7190, b6=1.7065, b7=1.7027, b8=1.6971
w0=1.88 µm -> 8 guided mode(s): b1=1.8965, b2=1.8523, b3=1.7225, b4=1.7225, b5=1.7192, b6=1.7112, b7=1.7034, b8=1.7027
w0=1.99 µm -> 8 guided mode(s): b1=1.8992, b2=1.8615, b3=1.7286, b4=1.7286, b5=1.7193, b6=1.7041, b7=1.7089, b8=1.7043
w0=2.10 µm -> 8 guided mode(s): b1=1.9016, b2=1.8698, b3=1.7369, b4=1.7369, b5=1.7196, b6=1.7063, b7=1.7158, b8=1.7059
w0=2.21 µm -> 8 guided mode(s): b1=1.9035, b2=1.8764, b3=1.7449, b4=1.7450, b5=1.7199, b6=1.7073, b7=1.7225, b8=1.7077
w0=2.32 µm -> 8 guided mode(s): b1=1.9054, b2=1.8833, b3=1.7552, b4=1.7482, b5=1.7206, b6=1.7080, b7=1.7292, b8=1.7109
w0=2.44 µm -> 8 guided mode(s): b1=1.9071, b2=1.8895, b3=1.8568, b4=1.7498, b5=1.7217, b6=1.7084, b7=1.7354, b8=1.7151
w0=2.55 µm -> 8 guided mode(s): b1=1.9085, b2=1.8949, b3=1.8662, b4=1.7512, b5=1.7237, b6=1.7086, b7=1.7406, b8=1.7200
w0=2.66 µm -> 8 guided mode(s): b1=1.9098, b2=1.8997, b3=1.8750, b4=1.7524, b5=1.7267, b6=1.7088, b7=1.7453, b8=1.7249
w0=2.78 µm -> 8 guided mode(s): b1=1.9109, b2=1.9037, b3=1.8822, b4=1.7535, b5=1.7305, b6=1.7089, b7=1.7495, b8=1.7285
w0=2.89 µm -> 8 guided mode(s): b1=1.9120, b2=1.9078, b3=1.8901, b4=1.7545, b5=1.7366, b6=1.7538, b7=1.7089, b8=1.7318
w0=3.00 µm -> 8 guided mode(s): b1=1.9129, b2=1.9115, b3=1.8975, b4=1.7555, b5=1.7439, b6=1.7573, b7=1.7092, b8=1.7339

n_eff vs w0 for each band (interactive).

band_fig = go.Figure()
for b in range(1, N_SWEEP_BANDS + 1):
    band_fig.add_trace(
        go.Scatter(
            x=W0_VALUES,
            y=neff_by_band[b],
            mode="lines+markers",
            name=f"band {b}",
            connectgaps=False,
            hovertemplate="w0=%{x:.2f} µm<br>n_eff=%{y:.4f}<extra>band "
            + str(b)
            + "</extra>",
        )
    )
band_fig.add_hline(
    y=n_clad,
    line_dash="dash",
    line_color="gray",
    annotation_text=f"SiO2 cladding (n={n_clad:.3f})",
    annotation_position="bottom right",
)
band_fig.update_layout(
    height=550,
    width=550,
    autosize=False,
    margin=dict(t=60, b=60),
    xaxis_title="ridge width w0 (µm)",
    yaxis_title="n_eff",
    title=dict(
        text=f"TFLN band diagram (lambda={WAVELENGTH:.2f} µm)",
        x=0.5,
        xanchor="center",
        font=dict(size=13),
    ),
    legend=dict(title=""),
)
# Fixed square aspect (width == height); no responsive stretch.
band_fig.show()