Skip to content

Shape-optimized waveguide taper

A photonic chip carries light in waveguides, silicon strips that hold light by total internal reflection. Light travels along one as modes, field patterns that keep their shape; circuits use the simplest, TE0. A taper joins a narrow waveguide to a wide one, such as a 0.5 µm waveguide to a grating coupler that sends light to a fibre. If the width grows quickly, TE0 cannot keep up: part of the light turns into higher-order modes or leaves the guide. A linear taper must be adiabatic, so gradual that the mode adapts, which takes hundreds of micrometres. Shape optimization instead moves the vertices of a short taper's outline to raise its transmission T, the fraction of the power reaching the wide guide's TE0. One simulation gives T; a second, the adjoint, gives the derivative of T with respect to every vertex. The figure of merit is T, or 10 log₁₀ T in decibels (dB); a good taper loses under 0.1 dB.

This notebook optimizes the 18 µm long, 0.5 to 9 µm taper of Michaels and Yablonovitch, Opt. Express 26, 31717 (2018) in 2D: T rises from 0.4978 for the linear taper to 0.99229 (-0.034 dB), against the paper's 0.51 and just over 0.99 (-0.041 dB).

Parameters

As in the paper, the model is 2D: the 220 nm silicon slab becomes a core of index n_core, the index the slab's TE0 travels with (its effective index), which the paper does not state. The mesh is the paper's 25 nm. Every run lasts exactly n_steps, with no early stop, so the gradient is exact for the T each run computes.

import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import minimize

import photonhub as ph
from photonhub.analysis import mode_launch, solve_mode_on_cross_section
# design, the paper's symbols (Sec. 4)
L = 18.0  # taper length (µm)
w_in = 0.5  # input guide width (µm)
w_out = 9.0  # output guide width (µm)
n_vertices = 200  # vertices on the taper's top edge
R0 = 0.15  # smallest radius of curvature wanted (µm)
wlen0 = 1.55  # wavelength (µm)
n_core = 2.847485541364588  # assumed: TE0 index of the 220 nm Si slab
n_clad = 1.444  # SiO2 index
paper_start_T = 0.51  # paper, stated linear taper T
paper_final_T = 0.99  # paper, stated final T (just over)
paper_final_db = -0.041  # paper, stated final T (dB)
paper_iterations = 97  # paper, stated iterations to the stop
# simulation choices: the mesh and the run
dl = 0.025  # uniform mesh step, the paper's grid (µm)
pml = 12  # PML layers (0.3 µm)
n_steps = 12000  # fixed run length, no early stop
fwidth = 0.1  # pulse bandwidth, as a fraction of f0

# where things sit (µm)
x_src = 1.0  # source plane, from the input wall
x_in = 1.5  # input port, from the input wall
lead = 2.0  # input wall to the taper's narrow end
past = 1.5  # output port, past the taper's wide end
tail = 2.8  # taper's wide end to the output wall
through = 1.0  # guides run on past each wall
reach = 1.0  # fixed polygon end in the wide guide, past the taper's wide end
overhang = 0.2  # design region past each taper end
margin = 0.3  # design region beside the wide guide, in y
clearance = 1.0  # oxide in y from design region to PML
half_in = 1.5  # narrow guide's mode window, half-width in y
half_out = 5.5  # wide guide's mode window, half-width in y

# the optimizer
pad = 2  # fixed vertices added at each chain end
alpha = 0.01  # assumed: penalty per vertex sharper than R0
kappa = 50.0  # assumed: penalty sharpness (1/µm)
dx_max = 0.04  # largest x move, under half the vertex spacing (µm)
gap = 0.05  # closest a vertex comes to axis or edge (µm)
ftol = 1e-4  # stop when the merit changes less (paper)
gtol = 1e-12  # no stop on a small gradient

f0 = ph.c0 / (wlen0 * 1e-6)  # frequency (Hz)

Geometry

The optimizer's pixels, one per mesh cell, have faces a quarter cell off the cell boundaries, clear of every field sample. pixel_face puts the guide axis and the taper's ends on such faces, so guides and pixels line up.

# helpers that place things on the mesh, whose cells are dl wide
def round_to_cells(length):  # rounded to a whole number of cells (µm)
    return round(length / dl) * dl


def pixel_face(x):  # a quarter cell past the cell boundary nearest x (µm)
    return (round(x / dl) + 0.25) * dl


def cell_centre(x):  # half a cell past the nearest cell boundary (µm)
    return (round(x / dl) + 0.5) * dl


size_x = round_to_cells(lead + L + tail)  # domain length, PML included (µm)
size_y = round_to_cells(2 * (w_out / 2 + margin + clearance + pml * dl))  # domain width (µm)
yc = pixel_face(size_y / 2)  # the guide axis (µm)
x_start = pixel_face(lead)  # the taper's narrow end (µm)
x_end = pixel_face(lead + L)  # its wide end (µm)
x_in_port = cell_centre(x_in)  # where the input port is placed (µm)
x_out_port = cell_centre(lead + L + past)  # where the output port is placed (µm)

guide builds a straight guide on the axis, a ph.Box that ph.Structure fills with the core index.

def guide(x_from, x_to, width):  # a straight guide on the axis
    box = ph.Box(
        center_um=(0.5 * (x_from + x_to), yc, 0.5 * dl),
        size_um=(x_to - x_from, width, 3 * dl),  # z: through the one cell and past it
    )
    return ph.Structure(geometry=box, medium=ph.Medium(permittivity=n_core**2))


guide_in = guide(-through, x_start, w_in)  # from past the input wall, through the PML, to the taper
guide_out = guide(x_end, size_x + through, w_out)  # from the taper, through the PML, past the wall
# no taper: for the launched power and the modes of the source and the input port
straight = guide(-through, size_x + through, w_in)

The design is the taper's top edge, n_vertices points mirrored about the axis; the parameters are their x and y moves. ph.DesignRegion holds the pixels the optimizer may change. ph.PolygonProfile sets each pixel to the fraction of its area inside the polygon. A vertex moved a nanometre then changes the permittivity a little instead of flipping a pixel, so a gradient method can move edges on a fixed grid. pad_ends adds fixed vertices on the guides so the curvature penalty below sees where the taper meets them.

region_length = L + 2 * overhang  # along x (µm)
region_width = w_out + 2 * margin  # across, in y (µm)
region = ph.DesignRegion.on_grid(
    center_um=((x_start + x_end) / 2, yc, 0.0),  # centred on the taper
    size_um=(region_length, region_width, 2 * dl),  # z: two cells, around the one simulated cell
    dl_um=dl,
    shape=(round(region_length / dl), round(region_width / dl), 1),  # a pixel per cell in x and y
    eps_min=n_clad**2,  # an empty pixel is oxide
    eps_max=n_core**2,  # a full one is core
    merge_boxes=True,  # fewer boxes for the solver to set up
)
edge_x = np.linspace(x_start, x_end, n_vertices)  # the top edge's vertices
edge_y = yc + w_in / 2 + (w_out - w_in) / 2 * (edge_x - x_start) / L  # on the linear taper
first = (x_src, yc + w_in / 2)  # a fixed vertex in the narrow guide; any x before the region does
last = (x_end + reach, yc + w_out / 2)  # and one inside the wide guide
taper = ph.PolygonProfile(
    region=region,
    vertices_um=(first, *zip(edge_x, edge_y), last),
    movable=tuple(range(1, n_vertices + 1)),  # all but first and last
    mirror=("y", yc),  # the bottom edge mirrors the top
).pad_ends(spacing_um=edge_x[1] - edge_x[0], count=pad)
p0 = np.zeros(taper.num_params)  # no vertex moved: the linear taper

Simulation

ph.Simulation holds the setup: a uniform dl grid, oxide background, PML on the x and y walls, and one periodic cell along z, which makes the run 2D.

sim = ph.Simulation(
    size_um=(size_x, size_y, dl),  # one cell along z: a 2D run
    grid=ph.UniformMesh(dl_um=dl),
    run=ph.RunSpec(n_steps=n_steps, shutoff=0, dft_shutoff=0),  # no early stop
    background=ph.Background(permittivity=n_clad**2),  # oxide
    boundaries=ph.Boundaries(x="pml", y="pml", z="periodic"),
    pml_num_layers=pml,
    subpixel=False,  # the gradient needs it off; the polygon is area-averaged per pixel instead
)

te0_mode solves one guide's TE0 mode on this grid with solve_mode_on_cross_section. Its electric field lies in the chip plane; the paper calls the same mode TM, with the magnetic field Hz out of the plane.

def te0_mode(waveguide, x, half_width):  # TE0 of one guide alone, on the plane x
    return solve_mode_on_cross_section(
        sim.with_changes(structures=[waveguide]),
        axis="x",
        plane_value_um=x,
        wlen_um=wlen0,
        pol="TE",  # E in the chip plane: the paper's TM
        mode_index=0,  # the fundamental mode
        h_center_um=yc,  # on the guide axis
        v_center_um=0.0,
        half_w_um=half_width,  # the mode window's half-width
        half_v_um=dl,  # the one cell along z
        dl_um=dl,
        supersample=1,  # the guide as the solver samples it
    )


mode_src = te0_mode(straight, x_src, half_in)  # launched by the source
mode_in = te0_mode(straight, x_in_port, half_in)  # read at the input port
mode_out = te0_mode(guide_out, x_out_port, half_out)  # read at the output port

A ph.TransmissionPort is a plane across a guide where a mode is read; readout, a ph.ModeTransmission between the two, gives their monitors.

port_in = ph.TransmissionPort(
    mode=mode_in,
    axis="x",
    position_um=x_in_port,
    out_direction="-",  # pointing away from the taper
    name="input",
    center_um=(yc, 0.0),  # (y, z) of the guide axis
)
port_out = ph.TransmissionPort(
    mode=mode_out,
    axis="x",
    position_um=x_out_port,
    out_direction="+",  # pointing away from the taper
    name="output",
    center_um=(yc, 0.0),
)
# the ports' monitors here, and below the launched amplitude for objective
readout = ph.ModeTransmission(input_port=port_in, output_port=port_out, freq_hz=f0)
ports = readout.monitors(sim)  # the two port planes as monitors

mode_launch sends a TE0 pulse around f0 into the narrow guide. The design region's monitor records the field the gradient needs. The plot shows the start, the linear taper's pixels rho0.

pulse = ph.GaussianPulse(freq0_hz=f0, fwidth_hz=fwidth * f0)
source = mode_launch(
    sim,
    mode_src,
    axis="x",
    position_um=x_src,
    source_time=pulse,
    direction="+",  # toward the taper
    center_um=(yc, 0.0),
)
design_field = region.monitor(f0, simulation=sim)  # the field over the region
rho0 = taper.density(p0)  # the linear taper as pixel densities
sim = sim.with_changes(
    sources=tuple(source),
    structures=(guide_in, guide_out, *region.structures(rho0)),
    monitors=(design_field, *ports),
)
sim.plot_index(z=0.0)
plt.show()

figure 1 of shape-optimized-taper

One run of no_taper, the straight guide alone, gives the launched power; objective, the T to raise, divides by it.

no_taper = sim.with_changes(structures=(straight,), monitors=ports)  # straight guide, ports only
reference = ph.run_local(no_taper, quiet=True)
objective = ph.ModeTransmission(
    input_port=port_in,
    output_port=port_out,
    freq_hz=f0,
    # the launched amplitude, held fixed so a reflection off the taper cannot change it
    incident_amplitude=readout.reference_incident_amplitude(reference),
)

The optimizer raises the merit: T minus a curvature penalty, about alpha for each vertex bent well tighter than R0. The paper gives the penalty's form but not alpha or kappa, and the penalty is not a fabrication check. Each call of merit is a forward and an adjoint run, giving T and its derivative for all 400 vertex moves (ph.parametric_value_and_gradient).

def build(rho):  # the simulation with the region's pixels at densities rho
    return sim.with_changes(structures=(guide_in, guide_out, *region.structures(rho)))


def merit(p):  # minus the merit (T minus the penalty) at p, and its gradient: SciPy minimizes
    evaluation = ph.parametric_value_and_gradient(
        build_forward=build,
        region=region,
        objective=objective,
        p=p,
        expand=taper.density,  # vertex moves to pixel densities
        jacobian=taper.jacobian,  # and their exact derivative
        # test that derivative on the first call only, while T_evals (made in the next
        # cell) is empty: the test re-renders the polygon along every parameter
        check_jacobian=len(T_evals) == 0,
    )
    penalty, penalty_grad = taper.curvature_penalty(p, r0_um=R0, alpha=alpha, kappa_per_um=kappa)
    T_evals.append(evaluation.value)
    return -(evaluation.value - penalty), -(evaluation.grad - penalty_grad)

SciPy's L-BFGS-B, a gradient-based optimizer, holds each parameter within bounds that keep the polygon from crossing itself. As in the paper, it stops when the merit changes by less than ftol. This cell takes most of the notebook's run time.

# reset here, so re-running this cell starts a fresh optimization
T_evals = []  # T at each evaluation; an iteration may take several
T_iter = []  # T after each iteration


def after_iteration(p):  # SciPy calls this once per iteration
    T_iter.append(T_evals[-1])  # its last evaluation is the step it took


vertices = taper.chain_um(p0)  # the linear taper's vertices (µm)
bounds = []  # (low, high) per parameter: dx, then dy, of each vertex
for k in taper.movable:
    from_axis = vertices[k, 1] - yc  # the vertex's distance from the axis
    # x moves stay within ±dx_max; y keeps each vertex at least gap from the axis, so from
    # its mirror image, and gap inside the region's edge
    bounds += [(-dx_max, dx_max), (gap - from_axis, w_out / 2 + margin - gap - from_axis)]
result = minimize(
    merit,
    p0,
    jac=True,  # merit returns its gradient too
    method="L-BFGS-B",
    bounds=bounds,
    callback=after_iteration,
    options={"ftol": ftol, "gtol": gtol},
)

Field

The optimized taper runs once more, recording the field. The light splits into beams that spread and interfere, then reach the wide guide as one broad lobe, its TE0 mode.

field = ph.ProfileMonitor.plane(
    name="field",
    axis="z",
    position_um=dl / 4,  # a quarter cell up: every component records in the one cell
    freqs_hz=[f0],
    fields=("Ex", "Ey", "Ez", "Hz"),  # Hz only for the 3D view's phase
)
rho = taper.density(result.x)  # the optimized taper as pixel densities
optimized = build(rho).with_changes(monitors=(*ports, field))  # ports and the field only
data = ph.run_local(optimized, quiet=True)
data.plot_field("field", field="E", z=dl / 4)
plt.show()

figure 2 of shape-optimized-taper

Result

The curve is T after each iteration; iteration 0 is the linear taper. The dashed and dotted lines are the paper's stated final and start values; its licence bars re-plotting its curves, and its BFGS iterations need not match ours one to one.

T = np.array([T_evals[0], *T_iter])  # the linear taper, then each iteration
iterations = np.arange(T.size)  # 0 is the linear taper
ph.viz.plot_comparison(
    iterations,
    T,
    stated=[
        (paper_final_T, "paper, stated (final T)"),
        (paper_start_T, "paper, stated (linear taper T)"),
    ],
    xlabel="iteration",
    ylabel="transmission T",
)
plt.show()
T_db = 10 * np.log10(T[-1])
print(
    f"linear taper T {T[0]:.4f} | optimized T {T[-1]:.5f} ({T_db:.3f} dB) after {result.nit} "
    f"iterations | paper: just over {paper_final_T} ({paper_final_db} dB) after {paper_iterations}"
)

figure 3 of shape-optimized-taper

output
linear taper T 0.4978 | optimized T 0.99229 (-0.034 dB) after 56 iterations | paper: just over 0.99 (-0.041 dB) after 97

On the uniform 25 nm mesh, T reads 0.99229 at 1550 nm; converged, 0.9921, for this design at 10 nm; the paper's frequency-domain method, just over 0.99. The linear taper's 0.4978 falls to 0.4931 at 10 nm. The run stops on the paper's rule, not at an optimum. The 25 nm run uses the 2D model, area-averaged pixels, subpixel averaging off, non-dispersive indices, 12 PML layers and no symmetry plane.