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.
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 . We look for fields that keep their shape as they propagate,
with the time convention used by gyptis. The propagation constant tells how fast the phase advances along the guide. Comparing it with the vacuum wavenumber defines the effective index
the mode travels as a plane wave would in a uniform medium of index . Substituting this field into Maxwell’s equations (non-magnetic media, ) gives
where acts on and only. For a given frequency, this is an eigenvalue problem for . Its solutions are a few discrete guided modes, plus a continuum of radiation modes that leak into the cladding. A mode is guided when
above 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 , is special: an electric field along sees the extraordinary permittivity , a field perpendicular to it the ordinary permittivity . As a tensor,
A plane wave travelling perpendicular to therefore has two indices: when polarized perpendicular to the axis, when polarized along it. This is birefringence.
We take the values of the benchmark: a square core of side with and , buried in an isotropic cladding with . Only the orientation of the optic axis changes:
- along , the axis of the guide;
- along , parallel to one side of the core;
- diagonal, along in the cross section;
- tilted, along , halfway between the cross section and the axis of the guide.
All lengths are in units of , so the frequency enters through the dimensionless product .
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 , the cross section is isotropic: both transverse components see . With the axis along , the tensor is diagonal but and see different permittivities. With the diagonal axis, an off-diagonal term couples and . With the tilted axis, the off-diagonal term couples a transverse component, , to the longitudinal one, : this case needs more care.
Finite element formulation
We split the field into its transverse part and its longitudinal part . They call for different finite elements.
- 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 . Nodal elements, which force all components to be continuous, produce spurious non-physical modes in this problem.
- is continuous everywhere and uses ordinary nodal (Lagrange) elements.
Write the permittivity tensor in blocks: , the transverse block, , and the coupling column . With the scaled unknowns and (Lee, Sun and Cendes, IEEE Trans. Microw. Theory Tech. 39, 1262 (1991)), the weak form is a polynomial in : for all test functions ,
with
Each form becomes a sparse matrix. When the coupling column vanishes, so does , and the problem is a generalized eigenvalue problem linear in . 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 , 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 , the largest 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 in the core and 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 geomgeom = 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
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 , and sorts them from the most to the least confined. polarization measures which way a mode’s transverse field points: the fraction of carried by the component along an in-plane unit vector , integrated over the physical domain (PMLs excluded). It assembles the overlaps once, so that the fraction along any is .
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 with the optic axis along . 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 onto one polarized along . The two fundamental modes, traditionally called and 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 , 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 and . 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 , 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 , the same order as the bulk birefringence , though smaller, because part of each mode travels in the isotropic cladding. The two in-plane orientations give the same pair to within . The plots below show why: in both cases one mode is polarized perpendicular to the optic axis and sees , the other along it and sees . 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 in proportion to the overlap of the mode with the core, weighted by . 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 , perpendicular to the optic axis, a wave sees whatever its direction in the plane. Polarized in the plane, it is more subtle. A wave travelling exactly along must have a transverse displacement, , so its electric field gains a longitudinal component and it sees
the familiar extraordinary index at from the optic axis. But its electric field is then no longer perpendicular to , and its energy does not flow along : 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 plane, with a phase varying as , , and its propagation constant is , the largest of all directions. So the bulk reference for the guide is , not . The coupling term 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 .
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: sits halfway between and . The guide keeps the same fraction of the bulk birefringence as before, against . Dropping the coupling shifts the effective indices by only and : enters at second order, and here it is small (). With a strongly birefringent crystal, the coupling would no longer be a small correction.
To draw the transverse field, we project and onto nodal elements, remove the arbitrary global phase of the eigenvector, and plot 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()With the optic axis in the cross section, the mode with the higher index is polarized perpendicular to the axis (ordinary-like, it sees ), the one with the lower index along it (extraordinary-like, ). 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 : the mode polarized along is ordinary and keeps the higher index, the one polarized along is extraordinary, with 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 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 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 ( when the axis points along ). 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 against . 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()The computed modes fall on the reference curves of all four cases. Every curve starts near the cladding line , 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 , the fundamental pair is degenerate: its two members (polarized along and ) 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: , and (axis in the cross section) or (tilted axis).
Modal birefringence
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()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 . As the frequency grows, the modes concentrate in the core and the birefringence climbs towards the bulk value , 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, instead of , 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 for the optic axis along . 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 ]
The error on the effective index falls roughly as , as expected for lowest-order elements on a smooth field. With ten elements across the core, as in the sweep, it is of order , 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 , . Change
eps_o,eps_eand the cladding (silica, ), 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 anglea. - 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 withquadratic=False: dropping the coupling matters most ata = π/4, since the error grows with . - Stronger tilt effect: with a strongly birefringent crystal (say , ), 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
Land the box to follow the curves closer to the cladding line, and see how far the field reaches into the cladding there.