A hands-on introduction to finite element scattering simulations: a silicon rod, computed live with gyptis and checked against the exact solution
A worked example of a frequency-domain scattering simulation with the gyptis finite element package. It goes from Maxwell’s equations to the scattering, absorption and extinction cross sections of a silicon rod, introducing perfectly matched layers, meshing and the weak form along the way, and checks every number against the exact Lorenz–Mie solution.
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 in benvial/gyptis-binder.
Light scattering by a silicon rod
A silicon nanowire, a fibre, a pillar in a metasurface: many objects in photonics are long cylinders, and the first question about any of them is how strongly they interact with light. This notebook computes how much light a silicon rod scatters and absorbs, using the finite element package gyptis, and checks the result against the exact solution.
Along the way we meet the main ingredients of a frequency-domain scattering simulation: the reduction to two dimensions, the scattered-field formulation, perfectly matched layers, meshing, the weak form, and the energy balance that cross sections must satisfy.
From Maxwell to a scalar Helmholtz equation
We work with time-harmonic fields, , which is the convention used by gyptis. In a medium with relative permittivity and permeability , Maxwell’s equations read
The rod is infinite along and the light arrives perpendicular to it, so nothing depends on . The problem then splits into two independent polarizations, each described by a single scalar field :
- TM: the electric field points along the rod, ;
- TE: the magnetic field points along the rod, .
Eliminating the other field components gives
where is the vacuum wavenumber. Both are Helmholtz equations of the form
with for TM and for TE. This is the equation gyptis solves.
import numpy as np
import matplotlib.pyplot as plt
import gyptis as gy
plt.rcParams.update({"figure.dpi": 110, "axes.titlesize": "medium"})The problem
A silicon rod of radius nm sits in vacuum and is lit by a plane wave of wavelength nm. At this wavelength silicon has a refractive index ; the small imaginary part means the rod absorbs some light. With the convention, losses show up as a negative imaginary part.
Two dimensionless numbers tell us what to expect: the size parameter compares the rod to the wavelength, and is the wavelength inside silicon, which sets how finely the rod must be meshed.
wl = 500 # wavelength (nm)
R = 300 # rod radius (nm)
n_si = 4.2992 - 0.070425j # refractive index of silicon at 500 nm
eps_rod = n_si**2 # relative permittivity of the rod
eps_bg = 1 # background: vacuum
k0 = 2 * np.pi / wl
print(f"size parameter k0 R = {k0 * R:.2f}")
print(f"wavelength in silicon = {wl / n_si.real:.0f} nm")size parameter k0 R = 3.77 wavelength in silicon = 116 nm
The size parameter is close to 4: the rod is comparable to the wavelength, too large for a quasi-static (dipole) picture, and the wavelength inside it is only about 116 nm. We are in the resonant regime, where an exact or numerical treatment is needed.
Truncating an infinite domain
The scattering problem lives in the whole plane, but a computer only holds a finite box. Two ideas make this work.
Scattered-field formulation. Write the total field as the known incident field plus an unknown scattered field, . Substituting into the Helmholtz equation gives
where are the background values. The right-hand side is a source that only lives inside the rod, where the material differs from vacuum. Far from the rod, is a purely outgoing wave.
Perfectly matched layers (PMLs). To let leave the box without reflection, the box is surrounded by layers in which space is stretched into the complex plane, with . An outgoing wave becomes and dies out before reaching the outer edge. In practice this stretching is equivalent to filling the layers with an anisotropic material, with (and likewise for ), so the same equation holds everywhere.
BoxPML builds a square box surrounded by PMLs. Rcalc is the radius of a circle on which energy fluxes will be computed later; it must enclose the rod and stay inside the box.
geom = gy.BoxPML(
dim=2,
box_size=(5 * wl, 5 * wl), # computational box
pml_width=(wl, wl), # one wavelength of PML on each side
Rcalc=0.9 * 3 * wl / 2, # flux circle for the cross sections
)
rod = geom.add_circle(0, 0, 0, R)
rod, *bg = geom.fragment(rod, geom.box)
geom.add_physical(bg, "box")
geom.add_physical(rod, "rod");Meshing
Finite elements approximate the field by polynomials on small triangles, so the triangles must be small compared with the local wavelength . A common rule of thumb is about ten elements per local wavelength with quadratic elements. The mesh is therefore much finer in silicon than in vacuum.
pmesh = 10 # elements per local wavelength
lmin = wl / pmesh
for pml in geom.pmls:
geom.set_size(pml, lmin)
geom.set_size("box", lmin / eps_bg**0.5)
geom.set_size("rod", lmin / eps_rod.real**0.5)
geom.build()
print(f"{geom.mesh.num_cells()} triangles")
geom.plot_mesh(lw=0.3)
plt.axis("off")
plt.show()19128 triangles
The incident wave
The rod is lit by a unit-amplitude plane wave travelling along ,
In TM it is the electric field of the wave, in TE its magnetic field .
pw = gy.PlaneWave(wavelength=wl, angle=0, dim=2, domain=geom.mesh, degree=2)
from gyptis.plot import plot
V = gy.dolfin.FunctionSpace(geom.mesh, "CG", 2)
plot(gy.utils.project_iterative(pw.expression.real, V), cmap="RdBu_r")
plt.axis("off")
plt.title("incident field, real part")
plt.show()Weak form and solution (TM polarization)
Finite elements solve the equation in its weak form: multiply by a test function , integrate over the domain (box and PMLs), and integrate by parts. We look for such that, for every test function ,
Writing on a basis of quadratic polynomials (degree=2) turns this into a sparse linear system, which gyptis assembles and solves. We start with TM polarization, where .
epsilon = dict(box=eps_bg, rod=eps_rod)
mu = dict(box=1, rod=1)
sTM = gy.Scattering(geom, epsilon, mu, pw, degree=2, polarization="TM")
sTM.solve()
sTM.plot_field()
geom.plot_subdomains()
plt.xlabel("$x$ (nm)")
plt.ylabel("$y$ (nm)")
plt.title("TM: real part of $E_z$")
plt.tight_layout()
plt.show()Inside the rod the field oscillates about four times faster than outside, as expected from . Behind the rod (towards ) the stripes fade: the rod casts a shadow. On either side, the scattered wave interferes with the incident one and bends the wavefronts.
Cross sections and the energy balance
How much light does the rod remove from the beam? We measure energy fluxes through the circle of radius , using the time-averaged Poynting vector . Dividing by the incident intensity gives cross sections, which in 2D have the dimension of a length (power per unit length of rod, divided by power per unit area):
The scattering cross section counts the power carried away by the scattered field, and the absorption cross section the net power flowing into the rod. The extinction cross section is the total power taken out of the incident beam. gyptis computes it independently, from the interference between incident and scattered fields,
Energy conservation demands , so comparing the two sides is a useful check of the numerics.
cs = {}
cs["TM"] = sTM.get_cross_sections()
for name, value in cs["TM"].items():
print(f"sigma_{name[0]} = {value:7.1f} nm")
print(f"(sigma_s + sigma_a) / sigma_e = {(cs['TM']['scattering'] + cs['TM']['absorption']) / cs['TM']['extinction']:.5f}")sigma_s = 998.4 nm sigma_a = 397.1 nm sigma_e = 1394.4 nm (sigma_s + sigma_a) / sigma_e = 1.00076
The balance holds to better than 0.1 %. The extinction cross section is also more than twice the rod’s diameter (600 nm): a resonant object can intercept light from a region wider than its own geometric shadow.
Checking against the exact solution
For a circular cylinder, the scattering problem has an exact solution, the Lorenz–Mie series. The field inside the rod is expanded on Bessel functions and the scattered field on outgoing cylindrical waves, one term per angular order ; matching the fields at gives each coefficient in closed form. With our convention, outgoing waves are Hankel functions of the second kind, . With the size parameter , the relative index and primes for derivatives,
The cross sections follow by summing over all orders (with , and or ):
from scipy.special import h2vp, hankel2, jv, jvp
def mie_cylinder(wavelength, radius, eps, polarization, nmax=40):
# Exact cross sections of a cylinder in vacuum, per unit length.
x = 2 * np.pi / wavelength * radius
m = np.sqrt(eps)
n = np.arange(nmax + 1)
J, dJ = jv(n, x), jvp(n, x)
Jm, dJm = jv(n, m * x), jvp(n, m * x)
H, dH = hankel2(n, x), h2vp(n, x)
if polarization == "TM":
c = (Jm * dJ - m * dJm * J) / (Jm * dH - m * dJm * H)
else:
c = (m * dJ * Jm - J * dJm) / (m * Jm * dH - dJm * H)
weight = np.where(n == 0, 1, 2) # orders n and -n contribute equally
scattering = 2 * wavelength / np.pi * np.sum(weight * abs(c) ** 2)
extinction = 2 * wavelength / np.pi * np.sum(weight * c).real
return dict(scattering=scattering, absorption=extinction - scattering, extinction=extinction)
def compare(polarization):
exact = mie_cylinder(wl, R, eps_rod, polarization)
print(f"{polarization} FEM exact error")
for name in exact:
fem = cs[polarization][name]
print(f"{name:<12} {fem:7.1f} {exact[name]:7.1f} {100 * (fem / exact[name] - 1):+5.2f} %")
compare("TM")TM FEM exact error scattering 998.4 978.8 +2.00 % absorption 397.1 389.5 +1.94 % extinction 1394.4 1368.3 +1.91 %
The finite element result agrees with the exact series to about 2 %, with a mesh of only ten elements per local wavelength. We will see below where the remaining error comes from and how to reduce it.
TE polarization
Now the magnetic field points along the rod, , and the roles of and are swapped: , . Only the polarization argument changes.
sTE = gy.Scattering(geom, epsilon, mu, pw, degree=2, polarization="TE")
sTE.solve()
sTE.plot_field()
geom.plot_subdomains()
plt.xlabel("$x$ (nm)")
plt.ylabel("$y$ (nm)")
plt.title("TE: real part of $H_z$")
plt.tight_layout()
plt.show()
cs["TE"] = sTE.get_cross_sections()
compare("TE")TE FEM exact error scattering 732.5 716.7 +2.21 % absorption 421.9 413.0 +2.16 % extinction 1153.4 1129.7 +2.10 %
In TE the electric field lies in the plane and crosses the rod’s surface, where its normal component jumps by a factor . The pattern inside the rod is very different: the magnetic field there reaches several times the incident amplitude, a sign that this polarization excites a resonance of the rod more strongly. The cross sections differ too: a rod responds differently to the two polarizations.
A spectrum from the exact solution
The series is cheap to evaluate, so we can scan the wavelength to see where our simulation sits. We plot the extinction efficiency , the extinction cross section relative to the rod’s diameter. For simplicity the permittivity is kept at its 500 nm value; a real silicon spectrum would also include the dispersion of .
wavelengths = np.linspace(400, 1200, 801)
fig, ax = plt.subplots(figsize=(6.4, 3.6))
for polarization, color in (("TM", "tab:blue"), ("TE", "tab:red")):
Qe = [mie_cylinder(w, R, eps_rod, polarization)["extinction"] / (2 * R) for w in wavelengths]
ax.plot(wavelengths, Qe, color=color, label=f"{polarization}, exact")
ax.plot(wl, cs[polarization]["extinction"] / (2 * R), "o", color=color, mfc="none", label=f"{polarization}, FEM")
ax.set_xlabel("wavelength (nm)")
ax.set_ylabel("extinction efficiency $Q_e$")
ax.legend(frameon=False, ncol=2)
plt.tight_layout()
plt.show()The peaks and shoulders are the rod’s Mie resonances: standing waves that fit inside the silicon, each associated with one cylindrical order . At short wavelengths many orders are excited at once and the spectrum is busy. As the wavelength grows, fewer orders fit inside the rod and the features become fewer and broader, up to the strong low-order resonances in the near infrared. Our two simulations sit close to the exact curves.
How accurate is the mesh?
The 2 % difference comes from discretization. Refining the mesh should bring the finite element result towards the exact one. We rebuild the geometry with an increasing number of elements per local wavelength and track the error on the TM extinction cross section.
def extinction_tm(pmesh):
g = gy.BoxPML(dim=2, box_size=(5 * wl, 5 * wl), pml_width=(wl, wl), Rcalc=0.9 * 3 * wl / 2)
rod = g.add_circle(0, 0, 0, R)
rod, *bg = g.fragment(rod, g.box)
g.add_physical(bg, "box")
g.add_physical(rod, "rod")
for pml in g.pmls:
g.set_size(pml, wl / pmesh)
g.set_size("box", wl / pmesh)
g.set_size("rod", wl / pmesh / eps_rod.real**0.5)
g.build()
source = gy.PlaneWave(wavelength=wl, angle=0, dim=2, domain=g.mesh, degree=2)
s = gy.Scattering(g, epsilon, mu, source, degree=2, polarization="TM")
s.solve()
return s.extinction_cross_section()
exact = mie_cylinder(wl, R, eps_rod, "TM")["extinction"]
pmeshes = [4, 6, 8, 10, 12]
errors = [abs(extinction_tm(p) / exact - 1) for p in pmeshes]
p = np.array(pmeshes)
fig, ax = plt.subplots(figsize=(4.8, 3.4))
ax.loglog(p, errors, "o-", label="FEM vs exact")
ax.loglog(p, errors[0] * (p / p[0]) ** -2, "--", color="0.6", label="$\\propto h^2$")
ax.set_xticks(p, [str(x) for x in p])
ax.xaxis.set_minor_locator(plt.NullLocator())
ax.set_xlabel("elements per local wavelength")
ax.set_ylabel("relative error on $\\sigma_e$")
ax.legend(frameon=False)
plt.tight_layout()
plt.show()The error drops steadily, roughly as where is the element size. That rate points to the geometry rather than the field: the circle is represented by straight-sided triangles, so the meshed rod is a polygon whose area approaches only as , while quadratic elements approximate a smooth field faster. Curved (isoparametric) elements would remove most of this error.
Things to try
Every cell above is editable. A few ideas:
- Size: change
Rand watch the cross sections follow the resonances of the spectrum. A thin rod ( nm) is much smaller than the wavelength and scatters weakly. - Material: replace silicon by glass (
n_si = 1.5) to remove absorption, then check that vanishes. - Resonance: pick a wavelength at a peak of the spectrum and compare the field inside the rod with the one at 500 nm. Remember to rebuild the mesh, which depends on
wl. - Angle: rotate the incident wave with
angleingy.PlaneWave. For a circular rod nothing should change; for an elliptical one (geom.add_ellipse) it does. - PMLs: make
pml_widthvery thin, or the stretch weak (pml_stretch=1 - 0.1jingy.Scattering), and see how reflections from the box edges spoil the energy balance.