Skip to content

nannos

Guided resonances of a photonic crystal slab with the Fourier modal method: a transmission spectrum, the fields of a resonance in 3D, and a convergence study

A worked example of the Fourier modal method (RCWA) with the nannos package. A silicon-like slab perforated by a square lattice of holes behaves as a waveguide and a grating at once, and its transmission spectrum shows sharp guided resonances. The notebook explains the method from Bloch waves to scattering matrices, checks it against the exact solution for a uniform slab, shows the structure and the resonant fields in interactive 3D views, and studies how the result converges with the number of Fourier harmonics.

Press Run live to edit and re-run any cell in your browser. The code runs on a real nannos kernel, started on mybinder.org from the environment kept alongside this site. Drag the 3D figures to rotate them.

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 resonances in a photonic crystal slab

Drill a square lattice of holes through a thin slab of silicon and it becomes two things at once: a waveguide, which traps light inside the slab by total internal reflection, and a grating, which couples light between the slab and free space. Where both roles meet, light shone on the slab from outside can couple to modes guided inside it, for a narrow band of frequencies. These guided resonances show up as sharp lines in the transmission spectrum, where the slab switches from transparent to perfectly reflecting within a fraction of a percent in frequency.

This notebook computes the transmission spectrum of such a slab with nannos, a Python implementation of the Fourier modal method (also known as rigorous coupled wave analysis, RCWA). It follows the structure studied by Fan and Joannopoulos (2002), checks the solver against an exact solution, looks at the fields of a resonance in 3D, and studies how the result converges with the number of Fourier harmonics.

Periodic structures and Bloch waves

We work with time-harmonic fields, E(r,t)=Re[E(r) e−iωt]\mathbf{E}(\mathbf{r}, t) = \mathrm{Re}\left[\mathbf{E}(\mathbf{r})\, e^{-i\omega t}\right], which is the convention used by nannos (losses therefore appear as a positive imaginary part of the permittivity). Without sources, Maxwell’s equations read

∇×E=iωμ0μ H,∇×H=−iωε0ε E.\nabla\times\mathbf{E} = i\omega\mu_0\mu\,\mathbf{H}, \qquad \nabla\times\mathbf{H} = -i\omega\varepsilon_0\varepsilon\,\mathbf{E}.

The permittivity ε(x,y)\varepsilon(x, y) is periodic in the plane of the slab, on a lattice with basis vectors a1\mathbf{a}_1 and a2\mathbf{a}_2. The Bloch theorem then says that, for an incident plane wave with in-plane wavevector k∥\mathbf{k}_\parallel, the field is a sum of plane waves whose in-plane wavevectors differ by vectors G\mathbf{G} of the reciprocal lattice:

E(x,y,z)=∑GEG(z) ei(k∥+G)⋅r∥,G=m b1+n b2,\mathbf{E}(x, y, z) = \sum_{\mathbf{G}} \mathbf{E}_{\mathbf{G}}(z)\, e^{i(\mathbf{k}_\parallel + \mathbf{G})\cdot\mathbf{r}_\parallel}, \qquad \mathbf{G} = m\,\mathbf{b}_1 + n\,\mathbf{b}_2,

with ai⋅bj=2πδij\mathbf{a}_i\cdot\mathbf{b}_j = 2\pi\delta_{ij}. Each term (m,n)(m, n) is a diffraction order. Above and below the slab, an order carries energy away only if ∣k∥+G∣<k0|\mathbf{k}_\parallel + \mathbf{G}| < k_0, where k0=ω/c=2π/λk_0 = \omega/c = 2\pi/\lambda; otherwise it decays exponentially away from the slab.

Maxwell’s equations have no natural length scale, so we measure lengths in units of the lattice period aa and frequencies as a/λ=ωa/2πca/\lambda = \omega a / 2\pi c. For a square lattice at normal incidence (k∥=0\mathbf{k}_\parallel = 0), the first orders (±1,0)(\pm 1, 0) and (0,±1)(0, \pm 1) start to propagate in air at a/λ=1a/\lambda = 1. Below that, only the specular order (0,0)(0, 0) leaves the slab: everything that is not transmitted straight through is reflected straight back.

import numpy as np
import matplotlib.pyplot as plt
import plotly.graph_objects as go
import plotly.io as pio
import shapely
import nannos as nn

plt.rcParams.update({"figure.dpi": 110, "axes.titlesize": "medium"})
pio.renderers.default = "plotly_mimetype"   # interactive figures, drawn by the page

The structure

The slab has a permittivity ε=12\varepsilon = 12 (close to silicon or GaAs in the near infrared), a thickness h=0.5 ah = 0.5\,a, and holes of radius r=0.2 ar = 0.2\,a. It is suspended in air, so the media above (superstrate) and below (substrate) are vacuum.

nannos describes the structure as a stack of layers, each invariant along zz, sharing one lattice. The superstrate and substrate are semi-infinite and uniform. The patterned layer stores its permittivity on a grid over one unit cell, here 29×292^9 \times 2^9 points, from which nannos computes the Fourier coefficients. We draw the hole once with shapely: lattice.geometry_mask finds the grid points inside it, and the 3D views below reuse it.

eps_slab = 12.0     # permittivity of the slab
h = 0.5             # slab thickness (units of a)
r = 0.2             # hole radius (units of a)

lattice = nn.Lattice([[1.0, 0], [0, 1.0]], discretization=2**9)
hole = shapely.Point(0.5, 0.5).buffer(r, quad_segs=32)

superstrate = lattice.Layer("superstrate", epsilon=1)
slab = lattice.Layer("slab", thickness=h)
substrate = lattice.Layer("substrate", epsilon=1)

slab.epsilon = lattice.ones() * eps_slab
slab.epsilon[lattice.geometry_mask(hole)] = 1
stack = [superstrate, slab, substrate]

fill = np.mean(slab.epsilon == 1)
print(f"air fraction in the slab: {fill:.3f} (exact: pi r^2 = {np.pi * r**2:.3f})")
air fraction in the slab: 0.125 (exact: pi r^2 = 0.126)
fig, ax = plt.subplots(figsize=(3.6, 3))
im, = slab.plot(ax=ax, cmap="Greys", vmin=0, vmax=14)
fig.colorbar(im, ax=ax, label=r"$\varepsilon$")
ax.set_xlabel("$x/a$")
ax.set_ylabel("$y/a$")
ax.set_title("permittivity of the slab, one unit cell")
plt.tight_layout()
plt.show()
Permittivity of one unit cell of the slab: a grey square (permittivity 12) with a white circular hole of radius 0.2 in its centre.

A 3D view

The unit cell repeats in both directions. nannos draws structures in 3D with pyvista (sim.plot_structure), which needs a running kernel, so for this page we build the solid ourselves with plotly. We cut the holes out of a 3×33\times3 block of cells and extrude the resulting polygon over the thickness of the slab: a constrained Delaunay triangulation of the polygon gives the top and bottom faces, and each edge of its outline gives a rectangle of the side walls. Drag to rotate, scroll to zoom.

We place the top surface of the slab at height 00, with light arriving from above.

def extrude(shape, z0, z1, **style):
    # A plotly solid with cross-section `shape` (a shapely polygon), between heights z0 and z1.
    vertices, index, faces = [], {}, []

    def vertex(x, y, z):
        key = (round(x, 9), round(y, 9), z)
        if key not in index:
            index[key] = len(vertices)
            vertices.append(key)
        return index[key]

    # Faces wind counterclockwise seen from outside, so that plotly lights them from the right side.
    shape = shapely.orient_polygons(shape)          # exterior counterclockwise, holes clockwise
    for tri in shapely.get_parts(shapely.constrained_delaunay_triangles(shape)):
        corners = shapely.orient_polygons(tri).exterior.coords[:3]
        faces.append([vertex(x, y, z1) for x, y in corners])         # top
        faces.append([vertex(x, y, z0) for x, y in corners[::-1]])   # bottom
    for ring in (shape.exterior, *shape.interiors):
        pts = ring.coords
        for (xa, ya), (xb, yb) in zip(pts[:-1], pts[1:]):
            a, b = vertex(xa, ya, z0), vertex(xb, yb, z0)
            c, d = vertex(xb, yb, z1), vertex(xa, ya, z1)
            faces += [[a, b, c], [a, c, d]]
    x, y, z = np.array(vertices, dtype=np.float32).T
    i, j, k = np.array(faces).T
    # plotly skips triangles with tiny normals by default, which would leave gaps around the holes
    lighting = dict(facenormalsepsilon=0, **style.pop("lighting", {}))
    return go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k, flatshading=True, hoverinfo="skip", lighting=lighting, **style)


def scene(fig, title):
    fig.update_layout(
        title=title, height=480, margin=dict(l=0, r=0, t=40, b=0), template="simple_white",
        scene=dict(aspectmode="data", xaxis_title="x/a", yaxis_title="y/a", zaxis_title="height/a",
                   camera=dict(eye=dict(x=1.5, y=-1.7, z=1.1))),
    )
    return fig


ncells = 3
block = shapely.box(0, 0, ncells, ncells)
holes = [shapely.affinity.translate(hole, i, j) for i in range(ncells) for j in range(ncells)]
pattern = block.difference(shapely.union_all(holes))

solid = extrude(pattern, -h, 0, color="#b8b2aa", lighting=dict(ambient=0.55, diffuse=0.7))
scene(go.Figure(solid), "photonic crystal slab, 3 × 3 unit cells").show()

Loading the interactive figure…

Layers, Fourier series and the eigenproblem

Inside each layer, nothing depends on zz, so we expand the permittivity in a Fourier series over the unit cell,

ε(r∥)=∑Gε^G eiG⋅r∥,\varepsilon(\mathbf{r}_\parallel) = \sum_{\mathbf{G}} \hat\varepsilon_{\mathbf{G}}\, e^{i\mathbf{G}\cdot\mathbf{r}_\parallel},

and substitute it, with the Bloch expansion of the fields, into Maxwell’s equations. A product such as εE\varepsilon\mathbf{E} becomes a convolution of Fourier coefficients, that is a matrix (a Toeplitz matrix built from the ε^G\hat\varepsilon_{\mathbf{G}}) acting on the vector of field coefficients. The zz dependence then decouples: each layer supports a set of modes, solutions of an algebraic eigenproblem of size 2N2N for NN harmonics, which vary as e±iγze^{\pm i\gamma z} along the stack. In the uniform superstrate and substrate, these modes are just the plane waves of the diffraction orders.

The layers are then joined by requiring the tangential components of E\mathbf{E} and H\mathbf{H} to be continuous at each interface. nannos does this with scattering matrices, which stay numerically stable however thick the layers are, and returns the amplitudes of every order in reflection and transmission.

The only approximation is the truncation to a finite set of NN harmonics. Here is what the permittivity looks like when rebuilt from the harmonics kept with nh=100, as returned by sim.get_epsilon:

sim = nn.Simulation(stack, nn.PlaneWave(wavelength=1 / 0.45), nh=100)
print(f"harmonics kept: {sim.nh}, orders m, n from {sim.harmonics.min()} to {sim.harmonics.max()}")

eps_truncated = sim.get_epsilon("slab").real
x, y = lattice.grid

fig, ax = plt.subplots(figsize=(6.4, 2.6))
ax.plot(x[:, 0], slab.epsilon.real[:, 256], color="0.6", label="exact")
ax.plot(x[:, 0], eps_truncated[:, 256], label=f"{sim.nh} harmonics")
ax.set_xlabel("$x/a$  (cut through the hole centre)")
ax.set_ylabel(r"$\varepsilon$")
ax.legend(frameon=False, loc="lower left")
plt.tight_layout()
plt.show()
harmonics kept: 97, orders m, n from -5 to 5
Cut of the permittivity through the hole centre: the exact profile steps from 12 down to 1 and back; the profile rebuilt from 97 Fourier harmonics follows it with visible ringing near the steps.

With a few dozen harmonics per direction, the steep edges of the hole are smoothed and ring (the Gibbs phenomenon). This sets how fast the method converges, and we will come back to it at the end.

Transmission at one frequency

The excitation is a plane wave of unit amplitude. Its direction is given by the polar and azimuthal angles (θ,φ)(\theta, \varphi) and its polarization by the angle ψ\psi: at normal incidence, ψ=0\psi = 0 gives an electric field along xx. For the square lattice and circular holes the two polarizations are equivalent at normal incidence.

diffraction_efficiencies returns the reflected and transmitted powers RR and TT, summed over the propagating orders and normalized by the incident power. The slab is lossless, so energy conservation requires R+T=1R + T = 1.

f = 0.45     # frequency a / lambda
sim = nn.Simulation(stack, nn.PlaneWave(wavelength=1 / f, angles=(0, 0, 0)), nh=100)
R, T = sim.diffraction_efficiencies()
print(f"R = {R:.5f}   T = {T:.5f}   R + T = {R + T:.8f}")
R = 0.69889   T = 0.30111   R + T = 1.00000000

Checking against a uniform slab

Before trusting the spectrum, we check the solver on a case with a known answer: the same slab without holes. A uniform layer only couples each order to itself, so the Fourier modal method is exact here, and the transmission must match the Fabry–Pérot (Airy) formula for a slab of index n=εn = \sqrt{\varepsilon} and thickness hh at normal incidence,

T=11+14(n−1n)2sin⁡2(k0nh).T = \frac{1}{1 + \frac14\left(n - \frac1n\right)^2 \sin^2(k_0 n h)}.

Multiple reflections between the two faces interfere, and the slab becomes fully transparent each time its optical thickness nhn h is a multiple of half a wavelength.

def airy(f, eps, h):
    n = np.sqrt(eps)
    return 1 / (1 + 0.25 * (n - 1 / n) ** 2 * np.sin(2 * np.pi * f * n * h) ** 2)


uniform = lattice.Layer("uniform slab", epsilon=eps_slab, thickness=h)
print("  a/lambda   nannos      Airy        difference")
for f in (0.25, 0.35, 0.45, 0.55):
    s = nn.Simulation([superstrate, uniform, substrate], nn.PlaneWave(wavelength=1 / f))
    T_rcwa = s.diffraction_efficiencies()[1]
    print(f"  {f:.2f}       {T_rcwa:.8f}  {airy(f, eps_slab, h):.8f}  {abs(T_rcwa - airy(f, eps_slab, h)):.1e}")
  a/lambda   nannos      Airy        difference
  0.25       0.70382182  0.70382182  0.0e+00
  0.35       0.50872927  0.50872927  2.2e-16
  0.45       0.29106517  0.29106517  0.0e+00
  0.55       0.82181909  0.82181909  6.7e-16

The two agree to machine precision.

The transmission spectrum

Now the patterned slab. We sweep the frequency between a/λ=0.25a/\lambda = 0.25 and 0.60.6. The resonances are very narrow, so a uniform grid would miss most of them: nn.adaptive_sampler starts from a coarse grid and adds points where the curve bends sharply.

As a guide, we also plot the Airy formula for a uniform slab with the average permittivity of the patterned one, εˉ=(1−fair) ε+fair\bar\varepsilon = (1 - f_\text{air})\,\varepsilon + f_\text{air}.

def transmission(f):
    s = nn.Simulation(stack, nn.PlaneWave(wavelength=1 / f), nh=100)
    return s.diffraction_efficiencies()[1]


freqs, T_spectrum = nn.adaptive_sampler(transmission, np.linspace(0.25, 0.6, 100))
T_spectrum = np.ravel(T_spectrum)
eps_avg = (1 - np.pi * r**2) * eps_slab + np.pi * r**2
print(f"{len(freqs)} frequencies computed")

fig, ax = plt.subplots(figsize=(6.4, 3.4))
ax.plot(freqs, airy(freqs, eps_avg, h), "--", color="0.6", label="uniform slab, average permittivity")
ax.plot(freqs, T_spectrum, color="tab:blue", label="photonic crystal slab")
ax.set_xlim(0.25, 0.6)
ax.set_ylim(0, 1.05)
ax.set_xlabel(r"frequency $a/\lambda$")
ax.set_ylabel("transmission $T$")
ax.legend(frameon=False, loc="lower left")
plt.tight_layout()
plt.show()
221 frequencies computed
Transmission spectrum from a/lambda = 0.25 to 0.6: a smooth background close to the dashed curve of a uniform slab, interrupted by several very sharp lines where the transmission swings between 0 and 1, near 0.38, 0.41 and between 0.5 and 0.55.

The spectrum has two parts. The smooth background follows the Fabry–Pérot oscillation of a uniform slab with the average permittivity: at these wavelengths, the light crossing the slab mostly sees an effective medium. On top of it sit sharp, asymmetric lines, the guided resonances. Each one corresponds to a mode guided in the slab whose wavevector, folded back by the lattice, lands at k∥=0\mathbf{k}_\parallel = 0, so that the incident wave can excite it. The mode leaks back out through the same grating, and its re-radiated field interferes with the direct transmission through the slab. The result is a Fano line shape, where the transmission drops to exactly zero and climbs back to one within a tiny range of frequency. This spectrum reproduces Fig. 12(c) of Fan and Joannopoulos.

The fields at a guided resonance

We zoom on the first sharp line near a/λ=0.382a/\lambda = 0.382 and locate the frequency of total reflection with a bounded scalar minimization of TT.

from scipy.optimize import minimize_scalar

found = minimize_scalar(transmission, bounds=(0.378, 0.385), method="bounded", options=dict(xatol=1e-8))
f_res = found.x
print(f"total reflection at a/lambda = {f_res:.6f}, T = {found.fun:.1e}")
total reflection at a/lambda = 0.381874, T = 1.9e-13

get_field_grid rebuilds the fields in real space from their Fourier coefficients, in a given layer and at given heights zz measured from the top of that layer. We compare the electric field intensity ∣E∣2|\mathbf{E}|^2 in the middle plane of the slab at the resonance and at a/λ=0.45a/\lambda = 0.45, away from any resonance. The incident wave has unit amplitude, so ∣E∣2|\mathbf{E}|^2 directly measures the enhancement.

npts = 64     # points per period for the field maps


def field_intensity(f, layer, z, n=npts):
    s = nn.Simulation(stack, nn.PlaneWave(wavelength=1 / f), nh=100)
    E = s.get_field_grid(layer, z=np.atleast_1d(z), shape=(n, n), field="E")
    return np.sum(abs(E) ** 2, axis=0)      # shape (n, n, len(z))


u = np.arange(npts) / npts
fig, axes = plt.subplots(1, 2, figsize=(6.6, 3))
for ax, f, title in zip(axes, (0.45, f_res), ("away from resonance", "at the guided resonance")):
    I = field_intensity(f, "slab", h / 2)[:, :, 0]
    im = ax.pcolormesh(u, u, I.T, cmap="inferno", shading="nearest")
    ax.contour(x, y, slab.epsilon.real, levels=[(1 + eps_slab) / 2], colors="w", linewidths=0.8, linestyles=":")
    fig.colorbar(im, ax=ax, shrink=0.85)
    ax.set_aspect("equal")
    ax.set_axis_off()
    ax.set_title(f"{title}\n$a/\\lambda = {f:.4f}$, max $|E|^2$ = {I.max():.1f}")
plt.tight_layout()
plt.show()
Two maps of the electric field intensity in the middle plane of one unit cell. Away from resonance the intensity stays below 1 everywhere. At the guided resonance it reaches several tens, concentrated in the high-permittivity material on both sides of the hole.

At the resonance the peak intensity is more than a hundred times larger than away from it: the guided mode stores energy, and the narrower the resonance, the larger the build-up. The brightest spots sit inside the hole, against its edges along xx, the direction of the incident electric field. There the field is normal to the edge, and since εEn\varepsilon E_n is continuous, the field on the air side is 1212 times stronger than on the silicon side.

The fields in 3D

To see how the mode sits in the slab, we combine two cuts in the 3D view: the middle plane of the slab, repeated over the 3×33\times3 cells, and a vertical cut through the back row of hole centres that extends one period above and below the slab. The slab itself is drawn transparent. The colour shows the field amplitude ∣E∣|\mathbf{E}| rather than the intensity, so that the much weaker field outside the slab remains visible. Hover over a cut to read its value.

n3d = 32       # points per period in the 3D view, to keep the figure light
xs = np.arange(ncells * n3d, dtype=np.float32) / n3d

# horizontal cut: middle plane of the slab, repeated over the 3 x 3 cells
E_mid = np.sqrt(np.tile(field_intensity(f_res, "slab", h / 2, n3d)[:, :, 0], (ncells, ncells)))

# vertical cut through the back row of hole centres, from one period above to one period below
z_above = np.linspace(-1, 0, 41)            # heights in the superstrate, measured down from the slab
z_in = np.linspace(0, h, 21)
z_below = np.linspace(0, 1, 41)
row = n3d // 2
cut = [field_intensity(f_res, layer, z, n3d)[:, row, :] for layer, z in
       (("superstrate", z_above), ("slab", z_in), ("substrate", z_below))]
E_cut = np.sqrt(np.tile(np.concatenate(cut, axis=1), (ncells, 1)))
height = np.concatenate([-z_above, -z_in, -h - z_below]).astype(np.float32)

f32 = lambda a: np.asarray(a, dtype=np.float32)
common = dict(colorscale="Inferno", cmin=0, cmax=float(E_mid.max()),
              hovertemplate="|E| = %{surfacecolor:.2f}<extra></extra>")
fig = go.Figure([
    # surfaces take z[i, j] at (x[j], y[i])
    go.Surface(x=xs, y=xs, z=f32(np.full(E_mid.shape, -h / 2)), surfacecolor=f32(E_mid.T),
               colorbar=dict(title="|E|", len=0.7), **common),
    go.Surface(x=xs, y=f32(np.full(height.shape, ncells - 0.5)), z=f32(np.broadcast_to(height[:, None], E_cut.T.shape)),
               surfacecolor=f32(E_cut.T), showscale=False, **common),
    extrude(pattern, -h, 0, color="#b8b2aa", opacity=0.18),
])
scene(fig, f"|E| at the guided resonance, a/λ = {f_res:.4f}").show()

Loading the interactive figure…

The vertical cut shows the signature of a guided mode: the field is confined to the slab and decays evanescently on both sides. Above the slab, the incident and fully reflected waves also form a standing wave, with an amplitude reaching 22 at its antinodes, weak next to the field inside the slab. Below the slab the field dies out within about half a period: nothing is transmitted.

How many harmonics?

We used nh=100 harmonics so far. How close is the result to the converged one? We track the transmission at a/λ=0.45a/\lambda = 0.45 as the number of harmonics grows, for two formulations of the method.

The truncation matters most where ε\varepsilon jumps. At the edge of the hole, the tangential component of E\mathbf{E} is continuous, while for the normal component it is εEn\varepsilon E_n that is continuous. The two products εE\varepsilon\mathbf{E} must therefore be truncated differently: with the Toeplitz matrix of ε\varepsilon for the tangential component (Laurent’s rule) and with the inverse of the Toeplitz matrix of 1/ε1/\varepsilon for the normal one (the inverse rule, Li 1996). The original formulation applies Laurent’s rule everywhere. The tangent formulation builds a vector field tangent to the hole edges and applies the appropriate rule to each component.

import time

nhs = [25, 50, 100, 150, 200, 300]
results = {}
for formulation in ("original", "tangent"):
    results[formulation] = []
    for nh in nhs:
        t0 = time.perf_counter()
        s = nn.Simulation(stack, nn.PlaneWave(wavelength=1 / 0.45), nh=nh, formulation=formulation)
        results[formulation].append((s.nh, s.diffraction_efficiencies()[1]))
        print(f"{formulation:>8}  {s.nh:4d} harmonics   T = {results[formulation][-1][1]:.5f}   ({time.perf_counter() - t0:.1f} s)")

fig, ax = plt.subplots(figsize=(5.2, 3.4))
for formulation, marker in (("original", "o"), ("tangent", "s")):
    n, T_n = np.array(results[formulation]).T
    ax.plot(n, T_n, marker + "-", label=formulation)
ax.set_xlabel("number of harmonics")
ax.set_ylabel(r"$T$ at $a/\lambda = 0.45$")
ax.legend(frameon=False)
plt.tight_layout()
plt.show()
original    21 harmonics   T = 0.30143   (0.0 s)
original    49 harmonics   T = 0.30130   (0.0 s)
original    97 harmonics   T = 0.30111   (0.1 s)
original   149 harmonics   T = 0.30111   (0.3 s)
original   197 harmonics   T = 0.30110   (0.6 s)
original   293 harmonics   T = 0.30108   (1.8 s)
 tangent    21 harmonics   T = 0.33241   (0.1 s)
 tangent    49 harmonics   T = 0.30931   (0.1 s)
 tangent    97 harmonics   T = 0.30676   (0.2 s)
 tangent   149 harmonics   T = 0.30541   (0.5 s)
 tangent   197 harmonics   T = 0.30478   (0.8 s)
 tangent   293 harmonics   T = 0.30422   (2.1 s)
Transmission at a/lambda = 0.45 against the number of harmonics for the original and tangent formulations: the original one stays flat near 0.301 from about 100 harmonics on, while the tangent one starts at 0.332 and decreases slowly towards it.

Here the original formulation settles first: from about 100 harmonics on, its transmission changes only in the fourth decimal. The tangent formulation approaches the same value from above, more slowly, and still differs by about 1 % at 300 harmonics. Which formulation converges faster depends on the structure, the polarization and the quantity of interest, so comparing them, as here, is a cheap way to estimate the error. The cost grows quickly, since the eigenproblem scales with the cube of the number of harmonics.

Sharp resonances are more demanding than the smooth background: with the original formulation, the frequency of total reflection found above moves from 0.38180.3818 to 0.38200.3820 between 100 and 200 harmonics. That is small compared with the spacing between resonances, but comparable with the width of the narrowest ones.

Things to try

Every cell above is editable. A few ideas:

  • Oblique incidence: set angles=(10, 0, 0) in nn.PlaneWave. The resonances shift and split, and modes that a normally incident wave cannot excite by symmetry appear as new lines.
  • Hole size: change r and watch the resonances move. Smaller holes give a weaker grating, hence narrower resonances, and the spectrum tends to the Airy curve.
  • Losses: give the slab a small absorption, eps_slab = 12 + 0.05j (positive imaginary part with the e−iωte^{-i\omega t} convention). The sharpest resonances are the most affected, since light stays there longest.
  • Shape: replace the circle by an ellipse, hole = shapely.affinity.scale(hole, 1.5, 0.75), and compare the two polarizations, ψ=0\psi = 0 and ψ=90\psi = 90.
  • Higher frequencies: extend the spectrum beyond a/λ=1a/\lambda = 1, where diffracted orders start to propagate, and print sim.diffraction_efficiencies(orders=True).