Skip to content

anisotropic waveguide

Guided modes and effective index of a square waveguide with a uniaxial core, for four orientations of the optic axis, with gyptis and checked against a classic benchmark

A worked example of a modal simulation with the gyptis finite element package, adapted from its anisotropic waveguide example. A square waveguide has a birefringent core whose optic axis points along the guide, along one side of the core, along its diagonal, or out of the cross section at 45°. The notebook introduces the effective index and the guiding condition, sets up the vector eigenvalue problem for the propagation constant with edge elements (a quadratic one when the optic axis is tilted out of the cross section), and shows how the orientation of the crystal splits the fundamental modes and sets their polarization. It then computes the dispersion curves of all four cases, checks them against the results of Koshiba, Hayata and Suzuki (1986), follows the modal birefringence with frequency, and studies mesh convergence.

Press Run live to edit and re-run any cell in your browser. The code runs on a real gyptis kernel, started on mybinder.org from the environment kept alongside this site. The frequency sweep takes a few minutes there.

Open in JupyterLab on Binder ↗

Outputs below come from a saved run. Running live starts a Python kernel on mybinder.org, which usually takes a minute or two.

Guided modes of an anisotropic waveguide

Lithium niobate, liquid crystals and many other optical materials are anisotropic: the refractive index that light sees depends on its polarization. Build a waveguide out of such a material and the anisotropy splits modes that would otherwise travel at the same speed, and makes their effective index depend on how the crystal is cut. This notebook computes the guided modes of a square waveguide with an anisotropic core, using the finite element package gyptis, for four orientations of the crystal (one of them tilted out of the cross section), and checks the results against a classic benchmark (Koshiba, Hayata and Suzuki, J. Lightwave Technol. 4, 121 (1986)).

Along the way we meet the main ingredients of a modal simulation: the effective index, the guiding condition, vector edge elements, an eigenvalue problem for the propagation constant, and the polarization of the modes.

Guided modes and effective index

A waveguide is invariant along its axis zz. We look for fields that keep their shape as they propagate,

E(x,y,z)=E(x,y) e−jβz,\mathbf{E}(x, y, z) = \mathbf{E}(x, y)\, e^{-j\beta z},

with the ejωte^{j\omega t} time convention used by gyptis. The propagation constant β\beta tells how fast the phase advances along the guide. Comparing it with the vacuum wavenumber k0=2π/λk_0 = 2\pi/\lambda defines the effective index

neff=βk0:n_\text{eff} = \frac{\beta}{k_0}:

the mode travels as a plane wave would in a uniform medium of index neffn_\text{eff}. Substituting this field into Maxwell’s equations (non-magnetic media, μ=1\mu = 1) gives

∇×∇×E−k02 ε E=0,∇=∇t−jβ z^,\nabla\times\nabla\times\mathbf{E} - k_0^2\,\varepsilon\,\mathbf{E} = 0, \qquad \nabla = \nabla_t - j\beta\,\hat{\mathbf{z}},

where ∇t\nabla_t acts on xx and yy only. For a given frequency, this is an eigenvalue problem for β\beta. Its solutions are a few discrete guided modes, plus a continuum of radiation modes that leak into the cladding. A mode is guided when

nclad<neff<ncore:n_\text{clad} < n_\text{eff} < n_\text{core}:

above ncladn_\text{clad} the field decays exponentially away from the core, so it carries its power along the guide without loss; and no mode can travel slower than light in the densest material it sees.

A uniaxial core

The core is a uniaxial crystal. One direction, the optic axis c^\hat{\mathbf{c}}, is special: an electric field along c^\hat{\mathbf{c}} sees the extraordinary permittivity εe\varepsilon_e, a field perpendicular to it the ordinary permittivity εo\varepsilon_o. As a tensor,

ε=εo I+(εe−εo) c^ c^T.\varepsilon = \varepsilon_o\,\mathbf{I} + (\varepsilon_e - \varepsilon_o)\,\hat{\mathbf{c}}\,\hat{\mathbf{c}}^{\mathsf{T}}.

A plane wave travelling perpendicular to c^\hat{\mathbf{c}} therefore has two indices: no=εon_o = \sqrt{\varepsilon_o} when polarized perpendicular to the axis, ne=εen_e = \sqrt{\varepsilon_e} when polarized along it. This is birefringence.

We take the values of the benchmark: a square core of side tt with εo=2.31\varepsilon_o = 2.31 and εe=2.19\varepsilon_e = 2.19, buried in an isotropic cladding with ε=2.05\varepsilon = 2.05. Only the orientation of the optic axis changes:

  • along zz, the axis of the guide;
  • along xx, parallel to one side of the core;
  • diagonal, along (x^+y^)/2(\hat{\mathbf{x}} + \hat{\mathbf{y}})/\sqrt{2} in the cross section;
  • tilted, along (y^+z^)/2(\hat{\mathbf{y}} + \hat{\mathbf{z}})/\sqrt{2}, halfway between the cross section and the axis of the guide.

All lengths are in units of tt, so the frequency enters through the dimensionless product k0tk_0 t.

import urllib.request
from collections import OrderedDict

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.tri import LinearTriInterpolator, Triangulation

import gyptis as gy

plt.rcParams.update({"figure.dpi": 110, "axes.titlesize": "medium"})
t = 1                          # side of the core: the unit of length
eps_o, eps_e = 2.31, 2.19      # ordinary and extraordinary permittivities of the core
eps_clad = 2.05                # isotropic cladding


def uniaxial(c):
    # Permittivity tensor of the core for an optic axis along c.
    c = np.asarray(c, float) / np.linalg.norm(c)
    return eps_o * np.eye(3) + (eps_e - eps_o) * np.outer(c, c)


axes = {"along z": [0, 0, 1], "along x": [1, 0, 0], "diagonal": [1, 1, 0], "tilted": [0, 1, 1]}

print(f"n_o = {eps_o**0.5:.4f}, n_e = {eps_e**0.5:.4f}, n_clad = {eps_clad**0.5:.4f}\n")
for name, c in axes.items():
    print(f"optic axis {name}:\n{uniaxial(c).round(3)}\n")
n_o = 1.5199, n_e = 1.4799, n_clad = 1.4318

optic axis along z:
[[2.31 0.   0.  ]
 [0.   2.31 0.  ]
 [0.   0.   2.19]]

optic axis along x:
[[2.19 0.   0.  ]
 [0.   2.31 0.  ]
 [0.   0.   2.31]]

optic axis diagonal:
[[ 2.25 -0.06  0.  ]
 [-0.06  2.25  0.  ]
 [ 0.    0.    2.31]]

optic axis tilted:
[[ 2.31  0.    0.  ]
 [ 0.    2.25 -0.06]
 [ 0.   -0.06  2.25]]

The four tensors share the same eigenvalues, only their principal axes turn. With the axis along zz, the cross section is isotropic: both transverse components see εo\varepsilon_o. With the axis along xx, the tensor is diagonal but ExE_x and EyE_y see different permittivities. With the diagonal axis, an off-diagonal term εxy\varepsilon_{xy} couples ExE_x and EyE_y. With the tilted axis, the off-diagonal term εyz\varepsilon_{yz} couples a transverse component, EyE_y, to the longitudinal one, EzE_z: this case needs more care.

Finite element formulation

We split the field into its transverse part Et=(Ex,Ey)\mathbf{E}_t = (E_x, E_y) and its longitudinal part EzE_z. They call for different finite elements.

  • Et\mathbf{E}_t uses edge (Nédélec) elements, whose degrees of freedom are tangential components along the triangle edges. They enforce continuity of the tangential field across element boundaries and nothing more, which is exactly what Maxwell’s equations demand at a material interface, where the normal component jumps with ε\varepsilon. Nodal elements, which force all components to be continuous, produce spurious non-physical modes in this problem.
  • EzE_z is continuous everywhere and uses ordinary nodal (Lagrange) elements.

Write the permittivity tensor in blocks: εtt\varepsilon_{tt}, the 2×22\times2 transverse block, εzz\varepsilon_{zz}, and the coupling column εtz=(εxz,εyz)\boldsymbol{\varepsilon}_{tz} = (\varepsilon_{xz}, \varepsilon_{yz}). With the scaled unknowns et=β Et\mathbf{e}_t = \beta\,\mathbf{E}_t and ez=−jEze_z = -j E_z (Lee, Sun and Cendes, IEEE Trans. Microw. Theory Tech. 39, 1262 (1991)), the weak form is a polynomial in β\beta: for all test functions (vt,vz)(\mathbf{v}_t, v_z),

a(e,v)+β c(e,v)+β2 b(e,v)=0,a(\mathbf{e}, \mathbf{v}) + \beta\, c(\mathbf{e}, \mathbf{v}) + \beta^2\, b(\mathbf{e}, \mathbf{v}) = 0,

with

a=∫Ω[(∇t×et)(∇t×vt)−k02 εtt et⋅vt]dΩ,b=∫Ω[(et+∇tez)⋅(vt+∇tvz)−k02 εzz ez vz]dΩ,c=jk02∫Ω[(εtz⋅et) vz−(εtz⋅vt) ez]dΩ.\begin{aligned} a &= \int_\Omega \left[(\nabla_t\times\mathbf{e}_t)(\nabla_t\times\mathbf{v}_t) - k_0^2\,\varepsilon_{tt}\,\mathbf{e}_t\cdot\mathbf{v}_t\right]\mathrm{d}\Omega, \\ b &= \int_\Omega \left[(\mathbf{e}_t + \nabla_t e_z)\cdot(\mathbf{v}_t + \nabla_t v_z) - k_0^2\,\varepsilon_{zz}\,e_z\,v_z\right]\mathrm{d}\Omega, \\ c &= j k_0^2 \int_\Omega \left[(\boldsymbol{\varepsilon}_{tz}\cdot\mathbf{e}_t)\,v_z - (\boldsymbol{\varepsilon}_{tz}\cdot\mathbf{v}_t)\,e_z\right]\mathrm{d}\Omega. \end{aligned}

Each form becomes a sparse matrix. When the coupling column vanishes, so does cc, and the problem is a generalized eigenvalue problem linear in β2\beta^2. That is the case when the optic axis lies in the cross section or along the guide. A tilted axis keeps the term linear in β\beta, and the problem is a quadratic eigenvalue problem, which SLEPc solves with a polynomial eigensolver. gyptis inspects the tensors and picks the right one. In both cases, an iterative (Krylov–Schur) eigensolver with a shift-and-invert transform finds the eigenvalues closest to a chosen target; we aim just above nok0n_o k_0, the largest β\beta a guided mode can have.

Geometry and mesh

gyptis builds the cross section as a layered box surrounded by perfectly matched layers (PMLs). We give both layers the cladding permittivity, so the core sits in a uniform cladding. Guided modes decay away from the core and barely reach the PMLs; radiation modes run into them and get absorbed, which turns their eigenvalues complex. That is how we will tell them apart.

The fundamental modes vary slowly across the core, so a moderate mesh suffices: triangles of size t/10t/10 in the core and t/3t/3 in the cladding. We check this choice at the end.

L = 3                          # cladding around the core
pml = 2                        # thickness of the PMLs


def make_geometry(h_core, h_clad):
    geom = gy.geometry.LayeredBoxPML2D(
        t + 2 * L, thicknesses=OrderedDict(substrate=L, superstrate=L + t), pml_width=(pml, pml)
    )
    core = geom.add_rectangle(-t / 2, geom.y_position["superstrate"], 0, t, t)
    core, sup, sub = geom.fragment(core, [geom.layers["superstrate"], geom.layers["substrate"]])
    geom.add_physical(core, "core")
    geom.add_physical(sub, "substrate")
    geom.add_physical(sup, "superstrate")
    for p in geom.pmls:
        geom.set_size(p, 2 * h_clad)
    geom.set_size("substrate", h_clad)
    geom.set_size("superstrate", h_clad)
    geom.set_size("core", h_core)
    geom.build()
    return geom
geom = make_geometry(t / 10, t / 3)
print(f"{geom.mesh.num_cells()} triangles")

fig, ax = plt.subplots(figsize=(4.4, 4.4))
geom.plot_mesh(lw=0.3)
geom.plot_subdomains(c="tab:red", lw=1)
ax.set_aspect("equal")
plt.axis("off")
plt.show()
3374 triangles
Triangular mesh of the waveguide cross section: a fine square core of side t at the centre, a coarser uniform cladding around it, and perfectly matched layers along the edges of the box.

Solving for the modes

guided_modes sets up the problem for one orientation of the optic axis and one frequency (with quadratic=None, gyptis decides whether the problem is linear or quadratic), solves it, keeps the modes with a real effective index above ncladn_\text{clad}, and sorts them from the most to the least confined. polarization measures which way a mode’s transverse field points: the fraction of ∫∣Et∣2\int|\mathbf{E}_t|^2 carried by the component along an in-plane unit vector u^\hat{\mathbf{u}}, integrated over the physical domain (PMLs excluded). It assembles the overlaps Mij=Re∫EiEj∗ dΩM_{ij} = \mathrm{Re}\int E_i E_j^*\,\mathrm{d}\Omega once, so that the fraction along any u^\hat{\mathbf{u}} is u^TMu^/tr M\hat{\mathbf{u}}^{\mathsf{T}} M \hat{\mathbf{u}} / \mathrm{tr}\,M.

def guided_modes(c, k0t, geom=geom, n_eig=8, degree=(1, 1), quadratic=None):
    k0 = k0t / t
    epsilon = dict(core=uniaxial(c), substrate=eps_clad, superstrate=eps_clad)
    simu = gy.Waveguide(geom, epsilon=epsilon, wavenumber=k0, degree=degree, quadratic=quadratic)
    simu.eigensolve(n_eig=n_eig, target=1.02 * k0 * eps_o**0.5, tol=1e-6, maximum_iterations=30)
    neff = np.array(simu.solution["eigenvalues"]) / k0
    guided = [(n.real, E) for n, E in zip(neff, simu.solution["eigenvectors"])
              if abs(n.imag) < 1e-4 and n.real > eps_clad**0.5]
    guided.sort(key=lambda m: -m[0])
    return np.array([n for n, _ in guided]), [E for _, E in guided]


dx = geom.measure["dx"]
dx_phys = dx("core") + dx("substrate") + dx("superstrate")


def polarization(E, u):
    # Overlaps M_ij = Re of the integral of E_i E_j^*, then u^T M u / trace(M).
    M = np.array([[gy.dolfin.assemble((E[i].real * E[j].real + E[i].imag * E[j].imag) * dx_phys)
                   for j in range(2)] for i in range(2)])
    u = np.asarray(u, float)[:2] / np.linalg.norm(u[:2])
    return u @ M @ u / np.trace(M)

Optic axis along the guide

We start at k0t=10k_0 t = 10 with the optic axis along zz. The cross section is then isotropic for transverse fields, and the square core is symmetric under a quarter turn, which maps a mode polarized along xx onto one polarized along yy. The two fundamental modes, traditionally called E11xE^x_{11} and E11yE^y_{11} after their dominant field component, must share the same effective index.

k0t = 10
neff_z, modes_z = guided_modes(axes["along z"], k0t)
for n, E in zip(neff_z, modes_z):
    print(f"n_eff = {n:.5f}   fraction of |E_t|^2 along x = {polarization(E, [1, 0]):.3f}")
print(f"\nsplitting of the fundamental pair: {neff_z[0] - neff_z[1]:.1e}")
n_eff = 1.48567   fraction of |E_t|^2 along x = 0.010
n_eff = 1.48565   fraction of |E_t|^2 along x = 0.990
n_eff = 1.44334   fraction of |E_t|^2 along x = 0.492
n_eff = 1.44205   fraction of |E_t|^2 along x = 0.508
n_eff = 1.44075   fraction of |E_t|^2 along x = 0.403
n_eff = 1.44061   fraction of |E_t|^2 along x = 0.594

splitting of the fundamental pair: 1.7e-05

The fundamental pair is degenerate up to a splitting of order 10−510^{-5}, which comes from the mesh: it is unstructured and does not share the exact symmetry of the square. Any combination of two degenerate modes is also a mode, so in principle the pair has no preferred polarization; here the tiny asymmetry of the mesh happens to select modes polarized almost purely along xx and yy. The next four modes form the second-order family, split into close pairs in the same way.

Optic axis in the cross section

Now we turn the optic axis into the cross section, first along xx, then along the diagonal.

modes_at_10 = {"along z": (neff_z, modes_z)}
for name in ("along x", "diagonal"):
    modes_at_10[name] = guided_modes(axes[name], k0t)

print(f"k0 t = {k0t}         fundamental pair         splitting")
for name, (neff, modes) in modes_at_10.items():
    print(f"optic axis {name:<9} {neff[0]:.5f}  {neff[1]:.5f}      {neff[0] - neff[1]:.5f}")
print(f"bulk crystal            n_o - n_e =          {eps_o**0.5 - eps_e**0.5:.5f}")
k0 t = 10         fundamental pair         splitting
optic axis along z   1.48567  1.48565      0.00002
optic axis along x   1.48604  1.45296      0.03309
optic axis diagonal  1.48602  1.45297      0.03305
bulk crystal            n_o - n_e =          0.04000

The degeneracy is lifted: the two fundamental modes now differ in effective index by a few 10−210^{-2}, the same order as the bulk birefringence no−nen_o - n_e, though smaller, because part of each mode travels in the isotropic cladding. The two in-plane orientations give the same pair to within 2×10−52\times10^{-5}. The plots below show why: in both cases one mode is polarized perpendicular to the optic axis and sees εo\varepsilon_o, the other along it and sees εe\varepsilon_e. The orientation of the axis relative to the sides of the core hardly matters, and symmetry explains it. Treat the anisotropy as a small perturbation of an isotropic core: it shifts the effective index of a mode polarized along u^\hat{\mathbf{u}} in proportion to the overlap of the mode with the core, weighted by u^Tc^\hat{\mathbf{u}}^{\mathsf{T}}\hat{\mathbf{c}}. The quarter-turn symmetry of the square makes that overlap the same for every in-plane direction, so to first order the core cannot tell a side from a diagonal. Only the vector corrections near the corners of the core, a weaker effect, separate the two cases.

Optic axis tilted out of the cross section

Before solving, consider plane waves in the bulk tilted crystal. Polarized along xx, perpendicular to the optic axis, a wave sees εo\varepsilon_o whatever its direction in the yzyz plane. Polarized in the yzyz plane, it is more subtle. A wave travelling exactly along zz must have a transverse displacement, Dz=εzyEy+εzzEz=0D_z = \varepsilon_{zy}E_y + \varepsilon_{zz}E_z = 0, so its electric field gains a longitudinal component Ez=−(εzy/εzz) EyE_z = -(\varepsilon_{zy}/\varepsilon_{zz})\,E_y and it sees

εy=εyy−εyz2εzz,\varepsilon_y = \varepsilon_{yy} - \frac{\varepsilon_{yz}^2}{\varepsilon_{zz}},

the familiar extraordinary index at 45°45° from the optic axis. But its electric field is then no longer perpendicular to zz, and its energy does not flow along zz: it walks off. A guided mode, on the contrary, must carry its energy along the guide. The plane wave that does so has a wavevector tilted in the yzyz plane, with a phase varying as e−j(py+βz)e^{-j(py + \beta z)}, p=−β εyz/εyyp = -\beta\,\varepsilon_{yz}/\varepsilon_{yy}, and its propagation constant is β=k0εyy\beta = k_0\sqrt{\varepsilon_{yy}}, the largest of all directions. So the bulk reference for the guide is εyy\varepsilon_{yy}, not εy\varepsilon_y. The coupling term cc of the weak form is what lets a mode tilt its phase fronts and build its longitudinal field. To see its effect, we also solve the same problem with quadratic=False, which keeps the linear eigenproblem and drops cc.

modes_at_10["tilted"] = guided_modes(axes["tilted"], k0t)
neff_tilt = modes_at_10["tilted"][0]
neff_drop = guided_modes(axes["tilted"], k0t, quadratic=False)[0]

eps_tilt = uniaxial(axes["tilted"])
eps_y = eps_tilt[1, 1] - eps_tilt[1, 2] ** 2 / eps_tilt[2, 2]
print(f"k0 t = {k0t}, optic axis tilted   fundamental pair     splitting")
print(f"full (quadratic) problem          {neff_tilt[0]:.5f}  {neff_tilt[1]:.5f}    {neff_tilt[0] - neff_tilt[1]:.5f}")
print(f"coupling dropped                  {neff_drop[0]:.5f}  {neff_drop[1]:.5f}    {neff_drop[0] - neff_drop[1]:.5f}")
print(f"bulk: eps_yy = {eps_tilt[1, 1]:.4f} (energy along z), eps_y = {eps_y:.4f} (wavevector along z)")
print(f"bulk birefringence for the guide: n_o - sqrt(eps_yy) = {eps_o**0.5 - eps_tilt[1, 1]**0.5:.5f}")
k0 t = 10, optic axis tilted   fundamental pair     splitting
full (quadratic) problem          1.48589  1.46913    0.01675
coupling dropped                  1.48584  1.46891    0.01693
bulk: eps_yy = 2.2500 (energy along z), eps_y = 2.2484 (wavevector along z)
bulk birefringence for the guide: n_o - sqrt(eps_yy) = 0.01987

The fundamental pair splits again, by about half as much as with the axis in the cross section, because only half of the anisotropy shows up in the cross section: εyy=2.25\varepsilon_{yy} = 2.25 sits halfway between εe\varepsilon_e and εo\varepsilon_o. The guide keeps the same fraction of the bulk birefringence as before, 0.0168/0.0199≈0.840.0168/0.0199 \approx 0.84 against 0.0331/0.0400≈0.830.0331/0.0400 \approx 0.83. Dropping the coupling shifts the effective indices by only 5×10−55\times10^{-5} and 2×10−42\times10^{-4}: εyz\varepsilon_{yz} enters at second order, and here it is small (0.060.06). With a strongly birefringent crystal, the coupling would no longer be a small correction.

To draw the transverse field, we project ExE_x and EyE_y onto nodal elements, remove the arbitrary global phase of the eigenvector, and plot ∣Et∣|\mathbf{E}_t| with arrows for its direction. The optic axis is marked in green: a double arrow for its projection on the cross section (shorter when the axis is tilted), a dot for its component along the guide.

V = gy.dolfin.FunctionSpace(geom.mesh, "CG", 1)
tri = Triangulation(*geom.mesh.coordinates().T, geom.mesh.cells())


def transverse_field(E):
    ex, ey = [gy.utils.project_iterative(E[j].real, V).compute_vertex_values()
              + 1j * gy.utils.project_iterative(E[j].imag, V).compute_vertex_values() for j in range(2)]
    i = np.argmax(abs(ex) ** 2 + abs(ey) ** 2)
    phase = np.angle(ex[i] if abs(ex[i]) > abs(ey[i]) else ey[i])
    ex, ey = (ex * np.exp(-1j * phase)).real, (ey * np.exp(-1j * phase)).real
    norm = np.hypot(ex, ey).max()
    return ex / norm, ey / norm


def plot_mode(ax, E, c, title):
    ex, ey = transverse_field(E)
    ax.tripcolor(tri, np.hypot(ex, ey), shading="gouraud", cmap="inferno", vmin=0, vmax=1)
    X, Y = np.meshgrid(np.linspace(-1.2, 1.2, 13), np.linspace(-1.2, 1.2, 13))
    U, W = (LinearTriInterpolator(tri, f)(X, Y) for f in (ex, ey))
    ax.quiver(X, Y, U, W, color="w", scale=12, width=0.006)
    ax.add_patch(plt.Rectangle((-t / 2, -t / 2), t, t, fill=False, ec="w", lw=0.8, ls="--"))
    c = np.asarray(c, float) / np.linalg.norm(c)
    if c[2]:
        ax.plot(1.25, 1.25, "o", mfc="none", mec="limegreen", ms=9, mew=1.5)
        ax.plot(1.25, 1.25, ".", c="limegreen", ms=5)
    if np.any(c[:2]):
        u = c[:2] * 0.35
        ax.annotate("", xy=(1.25 + u[0], 1.25 + u[1]), xytext=(1.25 - u[0], 1.25 - u[1]),
                    arrowprops=dict(arrowstyle="<->", color="limegreen", lw=1.5))
    ax.set(xlim=(-1.7, 1.7), ylim=(-1.7, 1.7), aspect="equal", title=title)
    ax.axis("off")


fig, axs = plt.subplots(2, 4, figsize=(11.5, 6.2))
for col, (name, (neff, modes)) in enumerate(modes_at_10.items()):
    for row in range(2):
        plot_mode(axs[row, col], modes[row], axes[name],
                  f"axis {name}\n$n_\\mathrm{{eff}} = {neff[row]:.4f}$" if row == 0 else f"$n_\\mathrm{{eff}} = {neff[row]:.4f}$")
plt.tight_layout()
plt.show()
Eight maps of the transverse electric field magnitude with arrows, for the two fundamental modes (rows) of the four orientations (columns) at k0 t = 10. Optic axis along z: two modes with the same effective index, polarized along x and y. Optic axis along x: the higher-index mode is polarized along y, the lower-index one along x. Diagonal axis: the higher-index mode is polarized across the diagonal, the lower-index one along it. Tilted axis, whose projection points along y: the higher-index mode is polarized along x, the lower-index one along y.

With the optic axis in the cross section, the mode with the higher index is polarized perpendicular to the axis (ordinary-like, it sees εo=2.31\varepsilon_o = 2.31), the one with the lower index along it (extraordinary-like, εe=2.19\varepsilon_e = 2.19). For the diagonal axis, the modes are polarized along the diagonals of the square: the crystal, not the shape of the core, sets their polarization. With the tilted axis the same rule holds with the projection of the axis on the cross section, here y^\hat{\mathbf{y}}: the mode polarized along xx is ordinary and keeps the higher index, the one polarized along yy is extraordinary, with εyy\varepsilon_{yy} as its bulk reference. Both modes are almost linearly polarized, but not exactly: near the corners of the core the field bends, and the small EzE_z component (not shown) gives the modes a hybrid character that no scalar model captures.

Dispersion curves

Waveguides are characterized by how the effective index changes with frequency. We sweep k0tk_0 t from 3.5 to 15 for the four orientations and record each guided mode together with its polarization: the fraction of the transverse field along the projection of the optic axis on the cross section (xx when the axis points along zz). The sweep takes a few minutes.

k0ts = np.linspace(3.5, 15, 20)
sweep = {}
for name, c in axes.items():
    u = c if np.any(c[:2]) else [1, 0, 0]
    points = []
    for k0t in k0ts:
        neff, modes = guided_modes(c, k0t)
        points += [(k0t, n, polarization(E, u)) for n, E in zip(neff, modes)]
    sweep[name] = np.array(points)
    print(f"optic axis {name}: {len(points)} guided modes found")
optic axis along z: 90 guided modes found
optic axis along x: 76 guided modes found
optic axis diagonal: 76 guided modes found
optic axis tilted: 83 guided modes found

Koshiba and co-workers plotted (β/k0)2=neff2(\beta/k_0)^2 = n_\text{eff}^2 against k0tk_0 t. Their curves (Figs. 3, 4, 7 and 8 of the paper, digitized for the gyptis example this notebook is based on) appear as grey dots, our modes as coloured circles.

REF = "https://gitlab.com/gyptis/gyptis/-/raw/master/examples/waveguides/data_aniso_fig{}.csv"
figures = {"along z": 7, "along x": 3, "diagonal": 4, "tilted": 8}


def reference(name):
    with urllib.request.urlopen(REF.format(figures[name])) as f:
        return np.loadtxt(f, delimiter=",", usecols=(0, 1)).T


fig, axs = plt.subplots(2, 2, figsize=(8.6, 7.2), sharex=True, sharey=True)
for ax, (name, points) in zip(axs.flat, sweep.items()):
    ref = reference(name)
    ax.plot(*ref, ".", c="0.75", ms=1.5, label=f"Koshiba et al., Fig. {figures[name]}")
    sc = ax.scatter(points[:, 0], points[:, 1] ** 2, c=points[:, 2], cmap="coolwarm",
                    vmin=0, vmax=1, s=22, edgecolors="k", linewidths=0.3, zorder=3, label="gyptis")
    for eps, label in ((eps_o, r"$\varepsilon_o$"), (eps_e, r"$\varepsilon_e$")):
        ax.axhline(eps, c="0.5", lw=0.6, ls="--")
        ax.text(15.2, eps, label, va="center", fontsize="small", color="0.4")
    ax.set(xlim=(2, 15), ylim=(eps_clad, 2.32), title=f"optic axis {name}")
for ax in axs[1]:
    ax.set_xlabel("$k_0 t$")
for ax in axs[:, 0]:
    ax.set_ylabel(r"$(\beta/k_0)^2$")
for ax in axs.flat:
    ax.legend(frameon=False, loc="upper left", fontsize="small", markerscale=1)
cb = fig.colorbar(sc, ax=axs, shrink=0.6, pad=0.04)
cb.set_label("fraction of $|\\mathbf{E}_t|^2$ along the projection\nof the optic axis ($x$ if along $z$)")
plt.show()
Four panels of squared effective index against k0 t from 2 to 15, for the optic axis along z, along x, diagonal and tilted. Computed modes, coloured by polarization, fall on the digitized reference curves. Along z, the fundamental curve is a degenerate pair; in the three other cases it splits into a blue upper curve polarized across the projection of the axis and a red lower one polarized along it, with a smaller split for the tilted axis.

The computed modes fall on the reference curves of all four cases. Every curve starts near the cladding line (β/k0)2=εclad(\beta/k_0)^2 = \varepsilon_\text{clad}, where the mode spreads far into the cladding, and rises towards the core permittivity as the frequency grows and the mode concentrates in the core. Our curves follow the reference almost down to the cladding line; closer still, the mode reaches the PMLs, which give its effective index an imaginary part, and our filter discards it.

The colours show the polarization. With the optic axis along zz, the fundamental pair is degenerate: its two members (polarized along xx and yy) overlap on the plot. In the three other cases, each family of modes splits into a branch polarized across the projection of the axis (blue, upper) and a branch polarized along it (red, lower), each heading towards its own bulk permittivity: εo\varepsilon_o, and εe\varepsilon_e (axis in the cross section) or εyy\varepsilon_{yy} (tilted axis).

The difference between the effective indices of the two fundamental modes, the modal birefringence, is what sets the length over which a polarization state beats in the guide. We extract it from the sweep: at each frequency, the most confined mode polarized mostly across the axis and the most confined one polarized mostly along it.

fig, ax = plt.subplots(figsize=(5.4, 3.6))
for name, marker in (("along x", "o"), ("diagonal", "s"), ("tilted", "^")):
    points = sweep[name]
    k, dn = [], []
    for k0t in k0ts:
        at = points[points[:, 0] == k0t]
        across, along = at[at[:, 2] < 0.5, 1], at[at[:, 2] > 0.5, 1]
        if len(across) and len(along):
            k.append(k0t)
            dn.append(across.max() - along.max())
    ax.plot(k, dn, marker + "-", mfc="none", label=f"optic axis {name}")
for dn_bulk, label, x in ((eps_o**0.5 - eps_e**0.5, "bulk $n_o - n_e$", 3.6),
                         (eps_o**0.5 - eps_tilt[1, 1]**0.5, "bulk, tilted: $n_o - \\sqrt{\\varepsilon_{yy}}$", 9.5)):
    ax.axhline(dn_bulk, c="0.5", ls="--", lw=0.8)
    ax.text(x, dn_bulk + 0.001, label, color="0.4", fontsize="small")
ax.set(xlabel="$k_0 t$", ylabel=r"$n_\mathrm{eff}^{\perp} - n_\mathrm{eff}^{\parallel}$", ylim=(0, 0.045))
ax.legend(frameon=False, loc="lower right")
plt.tight_layout()
plt.show()
Modal birefringence of the fundamental pair against k0 t for the optic axis along x, diagonal and tilted: each curve rises from small values at low frequency towards its bulk value; the two in-plane orientations overlap, and the tilted one stays about half as large.

The two in-plane orientations give the same curve, as the symmetry argument above predicts, and the tilted axis gives about half of it at every frequency. At low frequency the modes live mostly in the isotropic cladding and the birefringence is small, about a third of the bulk value at k0t≈5k_0 t \approx 5. As the frequency grows, the modes concentrate in the core and the birefringence climbs towards the bulk value no−ne≈0.040n_o - n_e \approx 0.040, which it reaches only for a core much larger than the wavelength. The extraordinary-like mode lags behind because it also sees a smaller core–cladding contrast, εe−εclad\varepsilon_e - \varepsilon_\text{clad} instead of εo−εclad\varepsilon_o - \varepsilon_\text{clad}, so it spreads further into the cladding. The size of the core, relative to the wavelength, is therefore a knob on the birefringence of the guide.

How accurate is the mesh?

We refine the mesh, keeping the ratio between cladding and core element sizes, and follow the two fundamental modes at k0t=10k_0 t = 10 for the optic axis along xx. As a reference we use quadratic elements on the finest mesh.

k0t = 10
divisions = [4, 6, 8, 10, 14]
results = []
for n in divisions:
    g = make_geometry(t / n, 10 * t / (3 * n))
    results.append(guided_modes(axes["along x"], k0t, geom=g, n_eig=4)[0][:2])
results = np.array(results)
reference_neff = guided_modes(axes["along x"], k0t, geom=g, n_eig=4, degree=(2, 2))[0][:2]
print("reference (quadratic elements):", reference_neff.round(6))

fig, ax = plt.subplots(figsize=(4.8, 3.4))
d = np.array(divisions)
for i, label in enumerate(("across the axis", "along the axis")):
    ax.loglog(d, abs(results[:, i] - reference_neff[i]), "o-", mfc="none", label=label)
ax.loglog(d, abs(results[0, 0] - reference_neff[0]) * (d / d[0]) ** -2, "--", c="0.6", label="$\\propto h^2$")
ax.set_xticks(d, [str(x) for x in d])
ax.xaxis.set_minor_locator(plt.NullLocator())
ax.set(xlabel="elements across the core", ylabel="error on $n_\\mathrm{eff}$")
ax.legend(frameon=False)
plt.tight_layout()
plt.show()
reference (quadratic elements): [1.48614 1.453  ]
Log-log plot of the error on the effective index of the two fundamental modes against the number of elements across the core: the error decreases roughly as the square of the element size.

The error on the effective index falls roughly as h2h^2, as expected for lowest-order elements on a smooth field. With ten elements across the core, as in the sweep, it is of order 10−410^{-4}, well below anything visible on the dispersion curves.

Things to try

Every cell above is editable. A few ideas:

  • Stronger anisotropy: lithium niobate at telecom wavelengths has no≈2.21n_o \approx 2.21, ne≈2.14n_e \approx 2.14. Change eps_o, eps_e and the cladding (silica, ε≈2.09\varepsilon \approx 2.09), and watch the modes become much more confined.
  • Any in-plane angle: set the optic axis to [np.cos(a), np.sin(a), 0] and check that the polarization of the fundamental modes follows the angle a.
  • Tilt angle: set the optic axis to [0, np.sin(a), np.cos(a)] and follow the birefringence from zero (axis along the guide) to its in-plane value. Compare with quadratic=False: dropping the coupling matters most at a = π/4, since the error grows with εyz2∝sin⁡2acos⁡2a\varepsilon_{yz}^2 \propto \sin^2 a \cos^2 a.
  • Stronger tilt effect: with a strongly birefringent crystal (say εo=2\varepsilon_o = 2, εe=4\varepsilon_e = 4), the coupling is no longer a small correction.
  • Rectangular core: make the core wider than it is thick (in make_geometry). Shape birefringence then adds to, or cancels, the birefringence of the crystal; find the aspect ratio where the two fundamental modes become degenerate again.
  • Near cutoff: enlarge L and the box to follow the curves closer to the cladding line, and see how far the field reaches into the cladding there.