Skip to content

Mode and polarization multiplexer couplers

A single waveguide, a strip of silicon in glass that guides light, can carry several signals at once if each travels in its own mode, a field pattern that keeps its shape along the guide. Modes are named by polarization, TE with the electric field mostly in the chip plane or TM with it mostly vertical, and by order, the number of times the field changes sign across the guide: TE0, TE1, TE2. A mode multiplexer loads each signal into its own mode of one shared bus guide, and a demultiplexer takes each back out. Each coupler must pick one mode and leave the others in the bus. An asymmetric directional coupler does this: a narrow access guide runs beside the wide bus, and light crosses between the guides only when the access guide's fundamental mode travels at the same speed as one bus mode (phase matching). The figures of merit are a channel's excess loss, the power it loses in decibels (dB; 0.1 dB is 2 %), good below 1 dB, and crosstalk, the power reaching a wrong channel, good below -15 to -20 dB.

This notebook builds the six couplers of Table 1 of Wang, He and Dai, Laser Photonics Rev. 8, L18 (2014) and follows the TE3 channel of the demultiplexer through the TM2 coupler it passes and its own: 0.407 dB of loss at 1550 nm (0.344 dB at 32 cells per wavelength) against the paper's about 0.4 dB.

Parameters

The design is the paper's Table 1. The paper states neither the indices nor the bends, so both are assumed: constant indices that phase-match all five of its TE width pairs, and raised-cosine bends measured on its Fig. 2. The band is the paper's simulated range. The graded mesh puts a line on every straight guide edge. Oxide above and below makes each coupler mirror symmetric through the silicon's middle, which halves the run.

import matplotlib.pyplot as plt
import numpy as np

import photonhub as ph
# design, the paper's Table 1: access width, bus width, gap, coupling length (µm)
table1 = {
    "TM1": (0.400, 1.035, 0.300, 4.7),
    "TM2": (0.400, 1.695, 0.300, 6.7),
    "TM3": (0.400, 2.363, 0.300, 9.0),
    "TE1": (0.400, 0.835, 0.200, 15.5),
    "TE2": (0.406, 1.290, 0.200, 21.3),
    "TE3": (0.379, 1.631, 0.200, 17.6),
}
h = 0.22  # silicon thickness (µm)
n_si = 3.48  # assumed: silicon index, constant
n_sio2 = 1.444  # assumed: oxide index, constant
bend_len = 10.0  # assumed: access bend length, Fig. 2 (µm)
bend_dy = 1.6  # assumed: access bend offset, Fig. 2 (µm)
paper_loss_db = 0.4  # paper, stated TE3 channel loss (dB)
paper_leak_db = -11.0  # paper, stated TE3 into the TM2 port (dB)
# simulation choices
wlen_min = 1.50  # band start (µm)
wlen_max = 1.60  # band end (µm)
n_wlen = 11  # wavelengths read out
cells_per_wlen = 16  # cells per wavelength, in each material
lead = 2.0  # straight guide from each bend to its port (µm)
clearance = 1.2  # silicon to PML, beside and above (µm)
pml = 12  # PML layers
shutoff = 1e-5  # field decay that ends the run

wlen = np.linspace(wlen_min, wlen_max, n_wlen)  # wavelengths (µm)
wlen0 = 0.5 * (wlen_min + wlen_max)  # band centre (µm)
f0 = ph.c0 / (wlen0 * 1e-6)  # band-centre frequency (Hz)

Geometry

A ph.Box is a block given by its centre and size; a ph.Polygon is an outline in the chip plane (vertices_um, counter-clockwise) extruded through the silicon (slab_bounds_um); ph.Structure gives each shape its medium. The bus runs along y = 0, the access guide one gap below it, then at each end a raised-cosine bend and a lead to the port. The runs draw different rows of Table 1, so the coupler is a function of its row.

def coupler(name):
    wa, wb, gap, Lc = table1[name]
    y_c = -(wb / 2 + gap + wa / 2)  # access guide's axis beside the bus (µm)
    x_end = Lc / 2 + bend_len  # where each bend ends (µm)
    t = np.linspace(0.0, 1.0, 161)
    x = Lc / 2 + bend_len * t
    y = y_c - bend_dy * (1 - np.cos(np.pi * t)) / 2  # a raised cosine
    a = np.arctan2(np.gradient(y), np.gradient(x))  # direction of travel
    right = list(zip(x + wa / 2 * np.sin(a), y - wa / 2 * np.cos(a)))
    left = list(zip(x - wa / 2 * np.sin(a), y + wa / 2 * np.cos(a)))
    bend = right + left[::-1]  # the right-hand bend
    mirror = [(-u, v) for u, v in reversed(bend)]  # the left-hand bend
    slab = (-h / 2, h / 2)
    shapes = [
        ph.Box(center_um=(0.0, 0.0, 0.0), size_um=(2 * (x_end + lead), wb, h)),  # bus
        ph.Box(center_um=(0.0, y_c, 0.0), size_um=(Lc, wa, h)),  # access, beside the bus
        ph.Polygon(axis="z", vertices_um=bend, slab_bounds_um=slab),
        ph.Polygon(axis="z", vertices_um=mirror, slab_bounds_um=slab),
        ph.Box(center_um=(x_end + lead / 2, y_c - bend_dy, 0.0), size_um=(lead, wa, h)),
        ph.Box(center_um=(-x_end - lead / 2, y_c - bend_dy, 0.0), size_um=(lead, wa, h)),
    ]
    si = ph.Medium(permittivity=n_si**2)  # silicon, constant index
    return [ph.Structure(geometry=g, medium=si) for g in shapes]


structures = coupler("TE3")

Simulation

A ph.Port is a plane across a guide: its centre, the axis the light travels along, the guide's width and thickness, and the mode it launches or reads. The bus mode arrives from the left at in, as in a demultiplexer; on the right, drop reads the access guide's fundamental mode and bus what the coupler passes on, as modal power over the launched power. The SDK sizes the window each mode is solved and read in so the mode is 30 dB down at its edge, with a 20 % margin.

def ports(name, mode):
    wa, wb, gap, Lc = table1[name]
    x_port = Lc / 2 + bend_len + lead  # the port planes (µm)
    y_drop = -(wb / 2 + gap + wa / 2) - bend_dy  # the access guide's axis there (µm)
    return [
        ph.Port("in", (-x_port, 0.0, 0.0), "x", wb, thickness_um=h, mode=mode),
        ph.Port("bus", (x_port, 0.0, 0.0), "x", wb, thickness_um=h, mode=mode),
        ph.Port("drop", (x_port, y_drop, 0.0), "x", wa, thickness_um=h, mode=mode[:2] + "0"),
    ]

ph.Simulation holds the setup. ph.Domain fits the box around the coupler and continues each guide through the PML; ph.Mesh grades the mesh by cells_per_wlen; the run ends when the field has decayed to shutoff. The two printed indices are the phase matching the coupler rests on.

field = ph.ProfileMonitor.plane("field", "z", 0.0, wlens_um=wlen0)  # the mid-plane
sim = ph.Simulation(
    domain=ph.Domain(clearance_um=clearance),
    mesh=ph.Mesh(cells_per_wlen=cells_per_wlen),
    background=ph.Medium(permittivity=n_sio2**2),  # oxide, constant index
    pml_num_layers=pml,
    structures=structures,
    symmetry=(0, 0, 1),  # TE modes are even about the mid-plane
    wlens_um=wlen,  # wavelengths read out at the ports
    ports=ports("TE3", "TE3"),
    source="in",  # the port that launches the bus mode
    run=ph.RunSpec(shutoff=shutoff),
    monitors=[field],
)
n_bus = float(sim.port_monitors["bus"].mode.n_eff)
n_drop = float(sim.port_monitors["drop"].mode.n_eff)
print(f"TE3 in the bus: n_eff {n_bus:.4f} | TE0 in the access guide: n_eff {n_drop:.4f}")
sim.plot_index(z=0.0)
plt.show()
output
TE3 in the bus: n_eff 2.1608 | TE0 in the access guide: n_eff 2.1650

figure 1 of mode-polarization-multiplexer

ph.run_local runs the solver on this computer; ph.cloud.run_quoted runs it on a cloud GPU. The outputs on this page come from a GPU run of these exact simulations.

data = ph.run_local(sim, quiet=True)
# On a cloud GPU instead, with GPU and SOLVER from your closed-test guide:
# data = ph.cloud.run_quoted(sim, device=GPU, solver=SOLVER)

Field

The silicon's mid-plane at 1550 nm. TE3, four lobes across the bus, crosses into the access guide along the coupling section and leaves as TE0.

data.plot_field("field", field="E", z=0.0)
plt.show()

figure 2 of mode-polarization-multiplexer

Result

TE3 passes the TM2 coupler before its own, and that coupler's access TE0, almost as fast as TE3, takes a share of it: the cause of the paper's -11 dB crosstalk, per the authors' follow-up, Opt. Express 22, 12799 (2014). sim.with_changes rebuilds the simulation around the TM2 coupler; the channel keeps what it passes times what the TE3 coupler drops. The dashed line is the paper's stated loss; its licence allows no re-plotting.

sim_x = sim.with_changes(structures=coupler("TM2"), ports=ports("TM2", "TE3"))
data_x = ph.run_local(sim_x, quiet=True)  # TE3 through the TM2 coupler
drop = data.transmission("drop")  # TE3 in the bus to TE0 at the TE3 port
kept = data_x.transmission("bus")  # TE3 left in the bus by the TM2 coupler
leak = data_x.transmission("drop")  # TE3 to TE0 at the TM2 port
ph.viz.plot_comparison(
    drop.wlen_um * 1e3,
    {"TE3 channel": -10 * np.log10(drop * kept), "TE3 coupler alone": -10 * np.log10(drop)},
    stated=(paper_loss_db, "paper, stated (TE3 channel loss, 3D FDTD)"),
    ylabel="loss (dB)",
    ylim=(0.0, 1.5),
)
plt.show()
d, k, x = (float(s.sel(f=f0, method="nearest")) for s in (drop, kept, leak))
loss_db = -10 * np.log10(d * k)  # the channel at 1550 nm (dB)
parts = f"TE3 coupler {-10 * np.log10(d):.3f} dB, TM2 coupler {-10 * np.log10(k):.3f} dB"
leak_db = 10 * np.log10(x)  # TE3 into the TM2 port at 1550 nm (dB)
leak_text = f"TM2 port leak {leak_db:.1f} dB (paper about {paper_leak_db:.0f} dB)"
print(f"TE3 channel at 1550 nm: {loss_db:.3f} dB ({parts}) | {leak_text}")

figure 3 of mode-polarization-multiplexer

output
TE3 channel at 1550 nm: 0.407 dB (TE3 coupler 0.049 dB, TM2 coupler 0.358 dB) | TM2 port leak -11.0 dB (paper about -11 dB)

At 16 cells per wavelength the TE3 channel loss reads 0.407 dB at 1550 nm; at 32 cells, 0.344 dB, not shown converged; the paper's 3D FDTD, about 0.4 dB. From 28 to 32 cells it moves 0.002 dB (0.011 dB across the band), but an earlier step looked as settled. The run uses a graded mesh, contour subpixel averaging, constant indices (no dispersion), 12 PML layers and one mirror plane through the silicon.

Sweep

All six couplers with their own mode in the bus, as in the paper's Fig. 2. At 1550 nm the TE couplers drop 0.989 to 1.003 of their mode, above 1 by the readout's floor. The TM couplers peak at 1560 nm, 0.979 to 0.942 at 1550 nm: with constant indices the paper's TM widths sit slightly off phase matching.

drops = {}
for name in table1:
    even = 1 if name.startswith("TE") else -1  # TM modes are odd about the mid-plane
    run = sim.with_changes(structures=coupler(name), ports=ports(name, name), symmetry=(0, 0, even))
    drops[name] = drop if name == "TE3" else ph.run_local(run, quiet=True).transmission("drop")
for pol in ("TE", "TM"):  # one figure per polarization
    ph.viz.plot_comparison(
        drop.wlen_um * 1e3,
        {f"{name} to {pol}0": T for name, T in drops.items() if name.startswith(pol)},
        ylabel="dropped power (of the bus mode)",
        ylim=(0.5, 1.05),
    )
    plt.show()
print(" | ".join(f"{name} {float(T.sel(f=f0, method='nearest')):.3f}" for name, T in drops.items()))

figure 4 of mode-polarization-multiplexer

figure 5 of mode-polarization-multiplexer

output
TM1 0.979 | TM2 0.966 | TM3 0.942 | TE1 1.003 | TE2 0.998 | TE3 0.989