Band structure calculation of a photonic crystal slab#
In this notebook, we simulate a photonic crystal slab consisting of a square lattice of air holes in a dielectric slab. Our goal is to compute the band structure of this photonic crystal slab, as found in:
Shanhui Fan and J. D. Joannopoulos, βAnalysis of guided resonances in photonic crystal slabs,β Phys. Rev. B 65, 235112 (2002).
To this end, we excite the structure with several PointDipole
sources, and we measure the response with several FieldTimeMonitor
monitors. We excite modes with a fixed Bloch wavevector by using Bloch boundary conditions. We then use the ResonanceFinder
to find the resonant frequencies. By sweeping the Bloch wavevector, we obtain the full band structure of the photonic crystal slab.
See also the api reference for ResonanceFinder
here.
If you are new to the finite-difference time-domain (FDTD) method, we highly recommend going through our FDTD101 tutorials.
[1]:
# standard python imports
import numpy as np
import matplotlib.pyplot as plt
import xarray as xr
import tidy3d as td
from tidy3d import web
from tidy3d.plugins.resonance import ResonanceFinder
We will randomly position our sources and monitors. Here, we seed the random number generator to guarantee reproducible results.
[2]:
rng = np.random.default_rng(12345)
Now we define the parameters for the simulation, structure, sources, and monitors.
We take the dipole polarization to be Hz
and the symmetry to be (0,0,1)
in order to excite only modes which are even with respect to the xy mirror plane.
[3]:
# Simulation parameters
runtime_fwidth = 200.0 # in units of 1/frequency bandwidth of the source
t_start_fwidth = 5.0 # time to start monitoring after source has decayed, units of 1/frequency bandwidth
dPML = 1.0 # space between PhC slabs and PML, in unit of longest wavelength of interest
# Structure parameters (um)
a_lattice = 1 # lattice constant "a"
r_hole = 0.2 * a_lattice # radius of the air holes
t_slab = 0.5 * a_lattice # slab thickness
ep_slab = 12 # dielectric constant of the slab
ep_hole = 1 # dielectric constant of the holes
# Frequency range of interest (Hz)
freq_range_unitless = np.array((0.1, 0.43)) # in units of c/a
freq_scale = (
td.constants.C_0 / a_lattice
) # frequency scale determined by the lattice constant
freq_range = freq_range_unitless * freq_scale
lambda_range = (td.constants.C_0 / freq_range[1], td.constants.C_0 / freq_range[0])
# Gaussian pulse parameters
freq0 = np.sum(freq_range) / 2 # central frequency
freqw = 0.3 * (freq_range[1] - freq_range[0]) # pulse width
# Runtime
run_time = runtime_fwidth / freqw
print(f"Total runtime = {(run_time*1e12):.2f} ps")
t_start = t_start_fwidth / freqw
# Simulation size
spacing = dPML * lambda_range[-1] # space between PhC slabs and PML
sim_size = Lx, Ly, Lz = (a_lattice, a_lattice, 2 * spacing + t_slab)
# Number of k values to sample, per edge of the irreducible Brillouin zone
Nk = 4
# Number of dipoles and monitors
num_dipoles = 7
num_monitors = 2
# Dipole polarization and symmetry
polarization = "Hz"
symmetry = (0, 0, 1)
Total runtime = 6.74 ps
We define the materials and structures in terms of the above parameters. We take the photonic crystal slab to be lying in the xy plane. Because the photonic crystal slab is periodic in the x and y directions, we only need to simulate a single unit cell, containing the dielectric slab and a single air hole.
[4]:
mat_slab = td.Medium(permittivity=ep_slab, name="mat_slab")
mat_hole = td.Medium(permittivity=ep_hole, name="mat_hole")
slab = td.Structure(
geometry=td.Box(
center=(0, 0, 0),
size=(td.inf, td.inf, t_slab),
),
medium=mat_slab,
name="slab",
)
hole = td.Structure(
geometry=td.Cylinder(
center=(0, 0, 0),
axis=2,
radius=r_hole,
length=t_slab,
),
medium=mat_hole,
name="hole",
)
structures = [slab, hole]
We will excite the photonic crystal slab with several PointDipole
sources. Each dipole will have a random position and phase.
[5]:
dipole_positions = rng.uniform(
[-Lx / 2, -Ly / 2, 0], [Lx / 2, Ly / 2, 0], [num_dipoles, 3]
)
dipole_phases = rng.uniform(0, 2 * np.pi, num_dipoles)
pulses = []
dipoles = []
for i in range(num_dipoles):
pulse = td.GaussianPulse(freq0=freq0, fwidth=freqw, phase=dipole_phases[i])
pulses.append(pulse)
dipoles.append(
td.PointDipole(
source_time=pulse,
center=tuple(dipole_positions[i]),
polarization=polarization,
name="dipole_" + str(i),
)
)
We create FieldTimeMonitors
to record the field as a function of time at several random locations within the photonic crystal slab. Crucially, we start the monitors after the source pulse has decayed.
[6]:
monitor_positions = rng.uniform(
[-Lx / 2, -Ly / 2, 0], [Lx / 2, Ly / 2, 0], [num_monitors, 3]
)
monitors_time = []
for i in range(num_monitors):
monitors_time.append(
td.FieldTimeMonitor(
fields=["Hz"],
center=tuple(monitor_positions[i]),
size=(0, 0, 0),
start=t_start,
name="monitor_time_" + str(i),
)
)
[10:06:14] WARNING: Default value for the field monitor monitor.py:261 'colocate' setting has changed to 'True' in Tidy3D 2.4.0. All field components will be colocated to the grid boundaries. Set to 'False' to get the raw fields on the Yee grid instead.
WARNING: Default value for the field monitor monitor.py:261 'colocate' setting has changed to 'True' in Tidy3D 2.4.0. All field components will be colocated to the grid boundaries. Set to 'False' to get the raw fields on the Yee grid instead.
We will perform 3*Nk
different simulations, each with different Bloch boundary conditions, as we sweep the Bloch wavevector over the boundary of the irreducible Brillouin zone. We sweep over three lines, namely \(\Gamma X\), \(XM\), and \(M\Gamma\). We use a PML in the z direction.
Here, we simply define all of the boundary conditions we will use and put them into a single array.
[7]:
bspecs_gammax = []
bspecs_xm = []
bspecs_mgamma = []
for i in range(Nk):
bspecs_gammax.append(
td.BoundarySpec(
x=td.Boundary.bloch((1 / 2) * i / Nk),
y=td.Boundary.periodic(),
z=td.Boundary.pml(),
)
)
bspecs_xm.append(
td.BoundarySpec(
x=td.Boundary.bloch(1 / 2),
y=td.Boundary.bloch((1 / 2) * i / Nk),
z=td.Boundary.pml(),
)
)
bspecs_mgamma.append(
td.BoundarySpec(
x=td.Boundary.bloch((1 / 2) * (1 - i / Nk)),
y=td.Boundary.bloch((1 / 2) * (1 - i / Nk)),
z=td.Boundary.pml(),
)
)
bspecs = bspecs_gammax + bspecs_xm + bspecs_mgamma
Now we define the simulations we want to run.
[8]:
sims = {}
for i in range(3 * Nk):
sims[f"sim_{i}"] = td.Simulation(
center=(0, 0, 0),
size=sim_size,
grid_spec=td.GridSpec.auto(),
structures=structures,
sources=dipoles,
monitors=monitors_time,
run_time=run_time,
shutoff=0,
boundary_spec=bspecs[i],
normalize_index=None,
symmetry=symmetry,
)
Letβs check that the structure and source look correct. The source spectrum must fill the entire frequency range of interest.
[9]:
fig, ax = plt.subplots(1, 2, tight_layout=True, figsize=(10, 4))
sims["sim_0"].plot(z=0.0, ax=ax[0])
sims["sim_0"].plot(x=0, freq=freq0, ax=ax[1])
plt.show()
f, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True, figsize=(8, 4))
plot_time = 5 / freqw
ax1 = (
sims["sim_0"]
.sources[0]
.source_time.plot(times=np.linspace(0, plot_time, 1001), val="abs", ax=ax1)
)
ax1.set_xlim(0, plot_time)
ax2 = (
sims["sim_0"]
.sources[0]
.source_time.plot_spectrum(
times=np.linspace(0, sims["sim_0"].run_time, 10001), val="abs", ax=ax2
)
)
ax2.hlines(1.5e-15, freq_range[0], freq_range[1], linewidth=10, color="g", alpha=0.4)
ax2.legend(("source spectrum", "measurement"))
plt.show()
Now we run the simulations as a Batch
.
We set verbose=True
to keep track of the status of the jobs in the Batch
.
[10]:
# initialize a batch and run them all
batch = td.web.Batch(simulations=sims, verbose=True)
# run the batch and store all of the data in the `data/` dir.
batch_data = batch.run(path_dir="data")
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 6f0e6891-d719-4100-b368-de8aa51a95e6v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 11e84743-7c10-413e-a81b-cd6d73f958a9v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 7c275afb-12d7-49f8-8acc-45c7673126eav1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- c8b6d038-aecd-421e-9a90-d97cb0ecf080v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- abb0c8d9-6e3c-48af-a97e-c8ebd4283ca1v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 67eac14f-7a6d-41f9-852f-98a33d6ba772v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 851e04b1-79be-422b-b189-e4a81edb2b9fv1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- ff7e28fd-8551-465e-97ac-2852c05b2308v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 473cf792-77fe-449f-9762-850b97cf88e5v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- bcf3f7cd-ebb4-486e-bd64-e2076c4d6953v1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- e78f4f92-4559-437a-8b51-529a62e6592bv1'.
View task using web UI at webapi.py:190 'https://tidy3d.simulation.cloud/workbench?taskId=fdve- 23b401f4-1be4-4c02-87c8-c01afbd4d5bfv1'.
[10:06:30] Started working on Batch. container.py:475
[10:08:24] Maximum FlexCredit cost: 0.300 for the whole batch. container.py:479 Use 'Batch.real_cost()' to get the billed FlexCredit cost after the Batch has completed.
[10:08:27] Batch complete. container.py:522
Now that the simulations are complete, we can analyze the data. Letβs first look at one of the FieldTimeMonitors to make sure the source has decayed.
[11]:
plt.plot(
batch_data["sim_1"].monitor_data["monitor_time_0"].Hz.t,
np.real(batch_data["sim_1"].monitor_data["monitor_time_0"].Hz.squeeze()),
)
plt.title("FieldTimeMonitor data")
plt.xlabel("t")
plt.ylabel("Hz")
plt.show()
We see that the source has mostly decayed by the time we switch on the monitors, and the remaining data shows decay and oscillation due to the resonances inside the system.
Looking at the Fourier transform of this data, we can see resonances at the band frequencies.
[12]:
df = 1 / np.amax(batch_data["sim_1"].monitor_data["monitor_time_0"].Hz.t)
minn = int(freq_range[0] / df)
maxn = int(freq_range[1] / df)
spectrum = np.fft.fft(batch_data["sim_1"].monitor_data["monitor_time_0"].Hz.squeeze())
plt.plot(
np.linspace(freq_range[0], freq_range[1], maxn - minn),
np.abs(spectrum[::-1][minn:maxn]),
)
plt.title("Spectrum at single wavevector")
plt.xlabel("Frequency (Hz)")
plt.ylabel("Amplitude")
plt.show()
We use the ResonanceFinder plugin to find the band frequencies.
We first construct a ResonanceFinder
object storing our parameters, and then call run()
on our list of FieldTimeData
objects. This will add up the signals from all of the monitors before searching for resonances. The ResonanceFinder
class has additional methods in case the signal takes another form; see the api reference here.
The run()
method returns an xr.Dataset
containing the decay rate, Q factor, amplitude, phase, and estimation error for each resonance as a function of frequency.
[13]:
resonance_finder = ResonanceFinder(freq_window=tuple(freq_range))
resonance_data = resonance_finder.run(signals=batch_data["sim_1"].data)
resonance_data.to_dataframe()
[13]:
decay | Q | amplitude | phase | error | |
---|---|---|---|---|---|
freq | |||||
2.527926e+13 | 2.497317e+10 | 3180.098411 | 0.001525 | 1.529244 | 0.000312 |
9.788251e+13 | 7.422098e+11 | 414.312749 | 0.009620 | 2.030330 | 0.000019 |
1.061235e+14 | 9.526464e+10 | 3499.691370 | 0.026843 | -2.893037 | 0.000005 |
1.131264e+14 | 2.127492e+12 | 167.049766 | 0.035868 | 1.792083 | 0.000024 |
1.191562e+14 | 1.151471e+12 | 325.097634 | 0.020007 | -2.087170 | 0.000019 |
1.372938e+14 | 1.121670e+12 | 384.534850 | 0.021763 | -0.424617 | 0.000020 |
We see the four resonances from the previous figure. All four have reasonable Q factors, amplitudes, and errors, so they are likely to represent physical resonances. Note that in order to accurately obtain the Q factor for high-Q modes, it may be necessary to run the simulation for a longer time.
Now we are ready to compute the band structure. We run the resonance finder at every Bloch wavevector.
[14]:
resonance_finder = ResonanceFinder(freq_window=tuple(freq_range))
resonance_datas = []
for i in range(3 * Nk):
sim_data = batch_data[f"sim_{i}"]
resonance_datas.append(resonance_finder.run(signals=sim_data.data))
We define a function to filter resonances based on their Q, amplitude, and error.
[15]:
def filter_resonances(resonance_data, minQ, minamp, maxerr):
resonance_data = resonance_data.where(abs(resonance_data.Q) > minQ, drop=True)
resonance_data = resonance_data.where(resonance_data.amplitude > minamp, drop=True)
resonance_data = resonance_data.where(resonance_data.error < maxerr, drop=True)
return resonance_data
We plot the band structure with the light line overlaid.
[16]:
for i in range(3 * Nk):
resonance_data = resonance_datas[i]
resonance_data = filter_resonances(
resonance_data=resonance_data, minQ=0, minamp=0.001, maxerr=100
)
freqs = resonance_data.freq.to_numpy()
Qs = resonance_data.Q.to_numpy()
plt.scatter(np.full(len(freqs), (1 / 2) * i / Nk), freqs / 3e14, color="blue")
lightx = np.linspace(0, 0.5, 100)
lighty1 = lightx
lighty3 = (0.5 - lightx) * np.sqrt(2)
plt.plot(lightx, lighty1, color="blue", alpha=0.2)
plt.plot(1 + lightx, lighty3, color="blue", alpha=0.2)
plt.ylim(0, freq_range_unitless[1])
plt.title("Band diagram")
plt.ylabel("Frequency (c/a)")
plt.xlabel("Wavevector")
plt.xticks([0, 0.5, 1, 1.5], ["$\Gamma$", "X", "M", "$\Gamma$"])
plt.xlim(0, 1.5)
plt.show()
The bandstructure we obtained matches the expected result from the paper. If we were seeing too many resonances, we could change the parameters to our filter_resonances function to eliminate the spurious ones. If we were seeing too few resonances even before filtering, we might have to change the parameters of the ResonanceFinder, for example decreasing rcond
or increasing init_num_freqs
. If the ResonanceFinder takes too long, we can decrease init_num_freqs
. There can also be
resonances on the light line associated with Woodβs anomaly; we filter those out here based on their small amplitude.