Microring resonator add-drop filter in BeamZ
Unknown · 2026-09-21
Microring resonator add-drop filter in BeamZ
A BeamZ-native recreation of Tidy3D's ring-resonator add-drop filter: two bus waveguides coupled to a silicon ring, broadband modal excitation, through/drop spectra, and resonance analysis.
import os
import matplotlib.pyplot as plt
import numpy as np
from scipy.signal import find_peaks
from IPython.display import display
import beamz as bz
from beamz.analysis import mode_data_to_dataframe
from beamz.analysis.plotting import plot_mode_field_components
um = bz.umBeamZ 0.5.0 | reduced test mode: False
1. Materials and device parameters
The reference uses Palik dispersive silicon and silica. BeamZ uses their lossless indices at 1550 nm here, so the local geometry, source, and monitor workflow remains explicit.
lambda0 = 1.55 * um
freq0 = bz.LIGHT_SPEED / lambda0
wavelengths = np.linspace(1.50, 1.60, 201) * um
freqs = bz.LIGHT_SPEED / wavelengths
fwidth = 0.5 * (np.max(freqs) - np.min(freqs))
n_si, n_sio2 = 3.48, 1.444
mat_si = bz.Material(permittivity=n_si**2)
mat_sio2 = bz.Material(permittivity=n_sio2**2)
wg_height = 0.22 * um
wg_width = 0.43 * um
gap = 0.08 * um
ring_radius = 3.0 * um
buffer = 0.7 * lambda0
wg_center_y = ring_radius + wg_width + gap
print(f"Ring radius = {ring_radius / um:.2f} µm; coupling gap = {gap / um:.2f} µm")Ring radius = 3.00 µm; coupling gap = 0.08 µm
2. Build the simulation
make_sim() follows the reference notebook's assembly step.
def make_sim():
domain_x = 2 * ring_radius + wg_width + 2 * buffer
domain_y = domain_x + 2 * gap + 2 * wg_width
domain_z = wg_height + 2 * buffer
domain = (domain_x, domain_y, domain_z)
design = bz.Design(background=mat_sio2)
design += bz.Box(center=(0.0, wg_center_y, 0.0), size=(bz.inf, wg_width, wg_height), material=mat_si)
design += bz.Box(center=(0.0, -wg_center_y, 0.0), size=(bz.inf, wg_width, wg_height), material=mat_si)
# Ring.z is the lower slab coordinate for BeamZ planar geometry.
design += bz.Ring(
position=(0.0, 0.0, -0.5 * wg_height),
inner_radius=ring_radius - 0.5 * wg_width,
outer_radius=ring_radius + 0.5 * wg_width,
depth=wg_height, points=256, material=mat_si, color="#d81b60",
)
steps_per_wavelength = 10
resolution = lambda0 / (steps_per_wavelength * n_si)
grid_spec = (
bz.GridSpec.auto(
wavelength=lambda0, min_steps_per_wvl=steps_per_wavelength, max_scale=1.3
)
)
source_size = (0.0, 1.2 * um, 0.8 * um)
mode_spec = bz.ModeSpec(
num_modes=1, mode_index=0, target_neff=3.47, polarization="te",
num_freqs=1,
)
source = bz.ModeSource(
center=(-ring_radius, wg_center_y, 0.0), size=source_size, direction="+",
source_time=bz.GaussianPulse(freq0=freq0, fwidth=fwidth, offset=4.0),
mode_spec=mode_spec,
)
# The access guides are single mode here, so signed Poynting flux gives the
# through/drop power spectra without solving monitor modes at every port.
through = bz.FluxMonitor(
center=(ring_radius, wg_center_y, 0.0), size=source_size,
freqs=freqs, name="through",
)
drop = bz.FluxMonitor(
center=(-ring_radius, -wg_center_y, 0.0), size=source_size,
freqs=freqs, name="drop",
)
field = bz.FieldMonitor(
center=(0.0, 0.0, 0.0), size=(domain_x, domain_y, 0.0), freqs=[freq0],
fields=("Hz",), name="field",
)
simulation = bz.Simulation(
domain=domain, design=design, sources=(source,), monitors=(through, drop, field),
boundaries=(bz.PML(thickness=0.6 * um, formulation="cpml"),),
grid_spec=grid_spec,
run_time=5e-12,
setup_device="default",
)
return simulation, {"source": source, "resolution": resolution}
3. Inspect geometry, memory, and source mode
The Tidy3D notebook estimates cloud cost before submission. BeamZ exposes deterministic local storage before compilation.
sim, setup = make_sim()
memory = sim.memory_estimate(num_steps=sim.num_steps)
print(f"3D grid: {sim.grid.shape} | time steps: {sim.num_steps}")
print(f"Estimated known storage: {memory['total_gib']:.2f} GiB")
fig, axes = sim.plot(
z=0.0, y=0.0, figsize=(12, 4.5), width_ratios=(1.1, 1.0),
source_markers=True, monitor_markers=True, show=False,
)
for axis in np.asarray(axes).flat:
axis.grid(False)
fig.suptitle("Microring add-drop layout: xy core plane and xz centerline", y=1.03)
plt.show()3D grid: (195, 193, 29) | time steps: 61921 Estimated known storage: 0.08 GiB
source_modes = setup["source"].solve_modes(sim, freqs=[freq0])
display(mode_data_to_dataframe(source_modes))
fig, axes, neffs = plot_mode_field_components(
source_modes, field_names=("Ey", "Ez"), mode_indices=(0,), val="abs", f=freq0,
figsize=(8, 3.8), show=False,
)
for axis in np.asarray(axes).flat:
axis.grid(False)
plt.show()
| wavelength | n eff | k eff | loss (dB/cm) | mode area | ||
|---|---|---|---|---|---|---|
| f | mode_index | |||||
| 1.934145e+14 | 0 | 1.55 | 2.337626 | 0.0 | 0.0 | 0.144698 |
4. Run and plot the central-wavelength field
The practical 3D run targets BeamZ's direct CUDA streaming backend; the reduced test uses portable JAX. A longer record can be substituted later without changing the geometry or analysis workflow.
results = sim.run(progress=True, backend="cuda_streamed")* Compiling simulation...
* Running simulation: [--------------------] 0% (0/61921 steps)
* Running simulation: [#-------------------] 5% (3097/61921 steps)
* Running simulation: [##------------------] 10% (6194/61921 steps)
* Running simulation: [###-----------------] 15% (9291/61921 steps)
* Running simulation: [####----------------] 20% (12388/61921 steps)
* Running simulation: [#####---------------] 25% (15485/61921 steps)
* Running simulation: [######--------------] 30% (18582/61921 steps)
* Running simulation: [#######-------------] 35% (21679/61921 steps)
* Running simulation: [########------------] 40% (24776/61921 steps)
* Running simulation: [#########-----------] 45% (27873/61921 steps)
* Running simulation: [##########----------] 50% (30970/61921 steps)
* Running simulation: [###########---------] 55% (34067/61921 steps)
* Running simulation: [############--------] 60% (37164/61921 steps)
* Running simulation: [#############-------] 65% (40261/61921 steps)
* Running simulation: [##############------] 70% (43358/61921 steps)
* Running simulation: [###############-----] 75% (46455/61921 steps)
* Running simulation: [################----] 80% (49552/61921 steps)
* Running simulation: [#################---] 85% (52649/61921 steps)
* Running simulation: [##################--] 90% (55746/61921 steps)
* Running simulation: [###################-] 95% (58843/61921 steps)
* Running simulation: [####################] 100% (61921/61921 steps)
Simulation runtime: 75.62 s GCUPS: 0.894 Launched power: 1 | backend: cuda_streamed
results.plot_field("field", "Hz", frequency=freq0, val="real", cmap="RdBu", show_grid=False)
plt.title("Hz at the central wavelength")
plt.show()
5. Through/drop spectrum and resonance analysis
The through plane carries positive-x power and the drop plane carries negative-x power. Flux-monitor spectra are already normalized by the source spectrum; dividing by the compiled launched power retains the requested source-power convention.
launched_power = max(float(results.launched_power()), 1e-18)
T_through = np.maximum(np.asarray(results["through"].flux, dtype=float), 0.0) / launched_power
T_drop = np.maximum(-np.asarray(results["drop"].flux, dtype=float), 0.0) / launched_power
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(wavelengths / um, 10 * np.log10(np.maximum(T_through, 1e-12)), label="Through", color="#d81b60")
ax.plot(wavelengths / um, 10 * np.log10(np.maximum(T_drop, 1e-12)), label="Drop", color="#1f77b4")
ax.set(xlabel="Wavelength (µm)", ylabel="Transmission (dB)", title="Microring through/drop spectrum")
ax.legend(frameon=False)
ax.grid(False)
plt.show()wavelength_step_um = float(np.mean(np.diff(wavelengths / um)))
minimum_peak_spacing = max(1, int(round(0.012 / wavelength_step_um)))
peak_indices, peak_properties = find_peaks(
T_drop, prominence=0.05, distance=minimum_peak_spacing
)
if len(peak_indices) >= 2:
resonance_wavelengths = wavelengths[peak_indices] / um
fsr_nm = 1e3 * np.diff(resonance_wavelengths)
print(f"Resolved resonances (µm): {resonance_wavelengths}")
print(f"Mean free spectral range: {np.mean(fsr_nm):.3f} nm")
else:
print("Fewer than two drop-port peaks are resolved; increase run time and/or spectral sampling.")
if len(peak_indices):
peak = peak_indices[np.argmax(T_drop[peak_indices])]
half_power = 0.5 * T_drop[peak]
left = np.flatnonzero(T_drop[:peak] <= half_power)
right = np.flatnonzero(T_drop[peak + 1:] <= half_power)
if len(left) and len(right):
linewidth = wavelengths[peak + 1 + right[0]] - wavelengths[left[-1]]
print(f"Approximate loaded Q: {wavelengths[peak] / linewidth:.0f}")Resolved resonances (µm): [1.5245 1.5545 1.5855] Mean free spectral range: 30.500 nm Approximate loaded Q: 634