Uniform grating coupler in BeamZ
Unknown · 2026-09-21
Uniform grating coupler in BeamZ
This notebook reproduces the workflow of Flexcompute's Tidy3D uniform grating coupler example using BeamZ geometry, sources, monitors, mode solving, and local execution.
BeamZ's tilted GaussianBeamSource is currently a 3D source. The fast design-loop
model therefore uses a two-cell-wide 3D strip with PEC sidewalls, which is the
native TE-equivalent of the reference notebook's y-periodic 2D model. The final
model is fully 3D.
import gc
import os
import matplotlib.pyplot as plt
import numpy as np
from IPython.display import display
import beamz as bz
from beamz.analysis import mode_data_to_dataframe
um = bz.um1. Initial design
We target 1.50–1.60 µm on an SOI stack. As in the reference notebook, silicon and silica are treated as nondispersive over this narrow band. The 260 nm silicon layer is etched by 160 nm, leaving a 100 nm slab. The uniform grating has 20 teeth and an 80% silicon fill fraction.
lambda0 = 1.55 * um
freq0 = bz.LIGHT_SPEED / lambda0
wavelengths = np.linspace(1.50, 1.60, 101) * um
freqs = bz.LIGHT_SPEED / wavelengths
fwidth = 0.5 * (np.max(freqs) - np.min(freqs))
n_si = 3.47
n_sio2 = 1.44
mat_air = bz.Material(permittivity=1.0)
mat_si = bz.Material(permittivity=n_si**2)
mat_sio2 = bz.Material(permittivity=n_sio2**2)
t_si = 0.26 * um
etch_depth = 0.16 * um
fill_fraction = 0.80
t_top_oxide = 0.68 * um
t_box = 2.0 * um
num_teeth = 20
theta = np.deg2rad(14.5)
mode_field_diameter = 10.8 * um
buffer = 0.60 * lambda0
source_gap = 0.50 * um
source_time = bz.GaussianPulse(freq0=freq0, fwidth=fwidth, offset=4.0)
mode_spec = bz.ModeSpec(num_modes=1, target_neff=n_si, polarization="te")
print(f"Silicon slab remaining after etch: {(t_si - etch_depth) / um:.2f} µm")Silicon slab remaining after etch: 0.10 µm
Geometry helpers
The objects are added in painter's order: oxide and substrate first, the residual silicon slab second, and the teeth last. Each tooth restores the full 260 nm silicon thickness over its filled part of the period.
def box_from_bounds(lower, upper, material):
lower = np.asarray(lower, dtype=float)
upper = np.asarray(upper, dtype=float)
return bz.Box(
center=tuple(0.5 * (lower + upper)),
size=tuple(upper - lower),
material=material,
)
def add_vertical_stack(design, bounds, *, z_offset=0.0):
x_min, x_max, y_min, y_max, z_min, z_max = bounds
design += box_from_bounds(
(x_min, y_min, z_offset),
(x_max, y_max, z_offset + t_top_oxide),
mat_sio2,
)
design += box_from_bounds(
(x_min, y_min, z_offset - t_box),
(x_max, y_max, z_offset),
mat_sio2,
)
design += box_from_bounds(
(x_min, y_min, z_min),
(x_max, y_max, z_offset - t_box),
mat_si,
)
return design
def add_grating_teeth(
design, period, y_min, y_max, *, x_offset=0.0, z_offset=0.0
):
for index in range(num_teeth):
x0 = x_offset + index * period
x1 = x0 + fill_fraction * period
design += box_from_bounds(
(x0, y_min, z_offset + t_si - etch_depth),
(x1, y_max, z_offset + t_si),
mat_si,
)
return design
Parameterized design-loop simulation
The source is a 1 W Gaussian beam tilted by 14.5° toward −x and polarized along
y. A mode monitor on the input slab measures the power coupled into the
fundamental mode travelling toward −x. In BeamZ, angle_phi=np.pi selects that negative-x tangential wavevector for a -z beam.
def make_design_loop_sim(period, source_x):
grating_length = num_teeth * period
steps_per_wavelength = 25
resolution = lambda0 / (steps_per_wavelength * n_si)
# Keep at least two y cells: GaussianBeamSource is a 3D TF/SF source.
strip_width = max(2.1 * resolution, 0.12 * um)
reference_lower = np.array(
(-buffer, -0.5 * strip_width, -t_box - buffer), dtype=float
)
reference_upper = np.array(
(grating_length + buffer, 0.5 * strip_width, t_si + t_top_oxide + buffer),
dtype=float,
)
# BeamZ domains are centered at the origin. Translate every geometry,
# source, and monitor coordinate together; Tidy3D expresses this as
# Simulation(center=sim_box.center, size=sim_box.size).
shift = -0.5 * (reference_lower + reference_upper)
lower = reference_lower + shift
upper = reference_upper + shift
x_min, y_min, z_min = lower
x_max, y_max, z_max = upper
x0, _, z0 = shift
design = add_vertical_stack(
bz.Design(background=mat_air),
(x_min, x_max, y_min, y_max, z_min, z_max),
z_offset=z0,
)
# Unetched input slab (reference x < 0).
design += box_from_bounds(
(x_min, y_min, z0), (x0, y_max, z0 + t_si), mat_si
)
# Residual 100 nm slab beneath the grating (reference x > 0).
design += box_from_bounds(
(x0, y_min, z0),
(x_max, y_max, z0 + t_si - etch_depth),
mat_si,
)
design = add_grating_teeth(
design, period, y_min, y_max, x_offset=x0, z_offset=z0
)
pml_thickness = 12 * resolution
source_half_width = min(
mode_field_diameter,
source_x - reference_lower[0] - pml_thickness,
reference_upper[0] - pml_thickness - source_x,
)
source = bz.GaussianBeamSource(
center=(source_x + x0, 0.0, t_top_oxide + source_gap + z0),
size=(2 * source_half_width, strip_width, 0.0),
source_time=source_time,
direction="-z",
angle_theta=theta,
angle_phi=np.pi,
pol_angle=np.pi / 2,
waist_radius=0.5 * mode_field_diameter,
background_index=1.0,
wavelength=lambda0,
power=1.0,
)
monitor = bz.ModeMonitor(
center=(reference_lower[0] + pml_thickness + 2 * resolution + x0, 0.0, 0.5 * t_si + z0),
size=(0.0, strip_width, 6 * t_si),
freqs=freqs,
mode_spec=mode_spec,
name="mode",
)
simulation = bz.Simulation(
domain=tuple(reference_upper - reference_lower),
design=design,
sources=(source,),
monitors=(monitor,),
boundaries=(bz.PML(formulation="cpml"),),
grid_spec=bz.GridSpec.uniform(resolution),
run_time=2.0e-12,
)
return simulation, {
"source": source,
"monitor": monitor,
"bounds": (x_min, x_max, y_min, y_max, z_min, z_max),
"shift": shift,
}
sim0, preview = make_design_loop_sim(
period=0.50 * um, source_x=(5.0) * um
)
print(f"Design-loop grid: {sim0.grid.shape} | time steps: {sim0.num_steps}")
# sim.plot() returns the standard xy/xz pair. The thin-strip xy view is not
# informative, so retain the reference notebook's single xz setup view.
fig, axes = sim0.plot(
y=0.0,
z=t_si - 0.5 * etch_depth,
figsize=(10.5, 4.8),
width_ratios=(0.001, 1.0),
source_markers=True,
monitor_markers=True,
show=False,
)
axes = np.asarray(axes).flat
axes[0].set_visible(False)
axes[1].grid(False)
axes[1].set_title("Design-loop grating: xz cross-section at y=0")
plt.show()
Design-loop grid: (664, 7, 269) | time steps: 58711
2. Estimate the grating period from slab modes
For first-order coupling,
$$ \Lambda = \frac{\lambda_0} {n_{\mathrm{eff}} - n_{\mathrm{clad}}\sin\theta_{\mathrm{clad}}}, \qquad n_{\mathrm{eff}} \approx f n_{\mathrm{unetched}} + (1-f)n_{\mathrm{etched}}. $$
Like ModeSimulation.from_simulation in the reference, the two BeamZ
ModeSource.solve_modes calls reuse the exact simulation geometry and grid but
move the solve plane to an unetched and an etched cross-section. These objects
are solve requests only; they are not attached as FDTD sources.
shift_2d = np.asarray(preview["shift"])
unetched_probe = bz.ModeSource(
center=(-0.10 * um + shift_2d[0], 0.0, 0.5 * t_si + shift_2d[2]),
size=(0.0, sim0.design.height, 5 * t_si),
source_time=source_time,
direction="-",
mode_spec=mode_spec,
)
etched_probe = unetched_probe.updated_copy(
center=(
num_teeth * 0.50 * um + 0.10 * um + shift_2d[0],
0.0,
0.5 * t_si + shift_2d[2],
)
)
unetched_modes = unetched_probe.solve_modes(sim0, freqs=[freq0])
etched_modes = etched_probe.solve_modes(sim0, freqs=[freq0])
display(mode_data_to_dataframe(unetched_modes))
display(mode_data_to_dataframe(etched_modes))
| wavelength | n eff | k eff | loss (dB/cm) | mode area | ||
|---|---|---|---|---|---|---|
| f | mode_index | |||||
| 1.934145e+14 | 0 | 1.55 | 2.954398 | 0.0 | 0.0 | 0.039351 |
| wavelength | n eff | k eff | loss (dB/cm) | mode area | ||
|---|---|---|---|---|---|---|
| f | mode_index | |||||
| 1.934145e+14 | 0 | 1.55 | 2.188442 | 0.0 | 0.0 | 0.040381 |
neff_unetched = float(np.real(np.asarray(unetched_modes.neffs)[0, 0]))
neff_etched = float(np.real(np.asarray(etched_modes.neffs)[0, 0]))
# Snell's law converts the 14.5° air angle to the angle inside the oxide.
theta_cladding = np.arcsin(np.sin(theta) / n_sio2)
weighted_neff = (
fill_fraction * neff_unetched + (1 - fill_fraction) * neff_etched
)
estimated_period = lambda0 / (
weighted_neff - n_sio2 * np.sin(theta_cladding)
)
print(f"n_eff unetched = {neff_unetched:.4f}")
print(f"n_eff etched = {neff_etched:.4f}")
print(f"Estimated grating period = {estimated_period / bz.nm:.0f} nm")
n_eff unetched = 2.9544 n_eff etched = 2.1884 Estimated grating period = 608 nm
3. Sweep period and fiber position
To keep local execution practical, the sweep samples 3 periods × 3 source positions, for 9 simulations total. This preserves the reference workflow's two design variables while bounding the runtime. BeamZ executes locally, so the dictionary of immutable simulations is followed by an explicit loop rather than a cloud batch.
periods = np.linspace(0.600, 0.630, 3) * um
source_positions = (np.linspace(3.0, 5.0, 3)) * um
simulations_2d = {
f"p={period / um:.4f};source_x={source_x / um:.4f}": make_design_loop_sim(
period, source_x
)[0]
for period in periods
for source_x in source_positions
}
print(f"Prepared {len(simulations_2d)} immutable design-loop simulations.")
Prepared 9 immutable design-loop simulations.
sweep_results = {}
for index, (name, simulation) in enumerate(simulations_2d.items(), start=1):
print(f"[{index:02d}/{len(simulations_2d):02d}] {name}")
sweep_results[name] = simulation.run(
progress=False,
backend="cuda_streamed",
performance=False,
)
# Each period realizes a slightly different domain shape. Evict compiled
# plans as we go so a local sweep does not retain every device allocation.
simulation.clear_compiled_cache()
gc.collect()
[01/09] p=0.6000;source_x=3.0000
[02/09] p=0.6000;source_x=4.0000
[03/09] p=0.6000;source_x=5.0000
[04/09] p=0.6150;source_x=3.0000
[05/09] p=0.6150;source_x=4.0000
[06/09] p=0.6150;source_x=5.0000
[07/09] p=0.6300;source_x=3.0000
[08/09] p=0.6300;source_x=4.0000
[09/09] p=0.6300;source_x=5.0000
coupling_efficiency = np.empty((len(periods), len(source_positions)))
for i, period in enumerate(periods):
for j, source_x in enumerate(source_positions):
name = f"p={period / um:.4f};source_x={source_x / um:.4f}"
mode_data = sweep_results[name].mode("mode")
amplitude = (
mode_data.amps.sel(direction="-", mode_index=0)
.sel(f=freq0, method="nearest")
.item()
)
coupling_efficiency[i, j] = np.abs(amplitude) ** 2
best_index = np.unravel_index(
np.nanargmax(coupling_efficiency), coupling_efficiency.shape
)
best_period = periods[best_index[0]]
best_source_x = source_positions[best_index[1]]
best_efficiency = coupling_efficiency[best_index]
fig, ax = plt.subplots(figsize=(7.2, 4.6), constrained_layout=True)
mesh = ax.pcolormesh(
source_positions / um,
periods / bz.nm,
100 * coupling_efficiency,
shading="auto",
cmap="RdBu_r",
)
colorbar = fig.colorbar(mesh, ax=ax)
colorbar.set_label("Coupling efficiency (%)")
ax.plot(best_source_x / um, best_period / bz.nm, "ko", ms=5, label="best sample")
ax.set(
title="Fiber-to-waveguide coupling sweep",
xlabel="Gaussian-beam center x (µm)",
ylabel="Grating period (nm)",
)
ax.grid(False)
ax.legend(frameon=False)
plt.show()
print(f"Optimal period = {best_period / bz.nm:.1f} nm")
print(f"Optimal source x = {best_source_x / um:.2f} µm")
print(
f"Optimal coupling = {100 * best_efficiency:.2f}% "
f"({10 * np.log10(max(best_efficiency, 1e-15)):.2f} dB)"
)
# The 12-points/λ 3D grid is large. Release every design-loop result and
# compiled plan before rasterizing it so notebook execution stays within
# the host-memory budget. The compact sweep arrays and selected optimum remain.
sim0.clear_compiled_cache()
for simulation in simulations_2d.values():
simulation.clear_compiled_cache()
del sweep_results, simulations_2d, mode_data
del unetched_modes, etched_modes, sim0, preview
plt.close('all')
gc.collect()
Optimal period = 615.0 nm Optimal source x = 4.00 µm Optimal coupling = 26.47% (-5.77 dB)
21
4. Three-dimensional grating coupler
This is the same finite-width geometry as the Tidy3D reference: a 10.8 µm-wide grating connected to a 500 nm waveguide by a 50 µm linear taper. The mode monitor is halfway through the straight output buffer, exactly as in the reference.
Tidy3D's GridSpec.auto(min_steps_per_wvl=15) is a nonuniform material-aware
mesh. BeamZ currently offers a uniform grid here; using 15 steps per silicon
wavelength everywhere would create about 149 million cells and is not safe on a
local 24 GB GPU. The BeamZ run therefore uses 8 uniform steps per silicon
wavelength with subpixel material averaging. This changes numerical resolution,
not device geometry.
waveguide_width = 0.50 * um
grating_width = mode_field_diameter
taper_length = 50.0 * um
def make_3d_sim(period, source_x):
grating_length = num_teeth * period
reference_lower = np.array(
(-buffer - taper_length, -0.5 * grating_width - buffer, -t_box - buffer),
dtype=float,
)
reference_upper = np.array(
(grating_length + buffer, 0.5 * grating_width + buffer, t_si + t_top_oxide + buffer),
dtype=float,
)
shift = -0.5 * (reference_lower + reference_upper)
lower = reference_lower + shift
upper = reference_upper + shift
x_min, y_min, z_min = lower
x_max, y_max, z_max = upper
x0, _, z0 = shift # reference coordinates x=0 and z=0 after translation
bounds = (x_min, x_max, y_min, y_max, z_min, z_max)
design = add_vertical_stack(
bz.Design(background=mat_air), bounds, z_offset=z0
)
# Residual etched slab across the grating region, matching the reference.
design += box_from_bounds(
(x0, y_min, z0),
(x_max, y_max, z0 + t_si - etch_depth),
mat_si,
)
design = add_grating_teeth(
design,
period,
-0.5 * grating_width,
0.5 * grating_width,
x_offset=x0,
z_offset=z0,
)
# Exact six-vertex Tidy3D taper, including its straight output extension.
taper_vertices = (
(x0, 0.5 * grating_width),
(x0, -0.5 * grating_width),
(x0 - taper_length, -0.5 * waveguide_width),
(x0 - taper_length - 2 * buffer, -0.5 * waveguide_width),
(x0 - taper_length - 2 * buffer, 0.5 * waveguide_width),
(x0 - taper_length, 0.5 * waveguide_width),
)
design += bz.Polygon(
vertices=taper_vertices,
depth=t_si,
z=z0,
material=mat_si,
)
steps_per_wavelength = 12
resolution = lambda0 / (steps_per_wavelength * n_si)
source = bz.GaussianBeamSource(
center=(source_x + x0, 0.0, t_top_oxide + source_gap + z0),
size=(2 * mode_field_diameter, 2 * mode_field_diameter, 0.0),
source_time=source_time,
direction="-z",
angle_theta=theta,
angle_phi=np.pi,
pol_angle=np.pi / 2,
waist_radius=0.5 * mode_field_diameter,
background_index=1.0,
wavelength=lambda0,
power=1.0,
)
monitor = bz.ModeMonitor(
center=(x0 - taper_length - 0.5 * buffer, 0.0, 0.5 * t_si + z0),
size=(0.0, 4 * waveguide_width, 6 * t_si),
freqs=freqs,
mode_spec=mode_spec,
name="mode",
)
domain = reference_upper - reference_lower
field_xz = bz.FieldMonitor(
center=(0.0, 0.0, 0.0),
size=(domain[0], 0.0, domain[2]),
freqs=[freq0],
fields=("Ex", "Ey", "Ez"),
name="field_xz",
)
simulation = bz.Simulation(
domain=tuple(domain),
design=design,
sources=(source,),
monitors=(monitor, field_xz),
boundaries=(bz.PML(formulation="cpml"),),
grid_spec=bz.GridSpec.uniform(
resolution,
max_total_cells=100_000_000,
),
run_time=2.0e-12,
)
return simulation, {
"source": source,
"monitor": monitor,
"field_xz": field_xz,
"bounds": bounds,
"shift": shift,
}
sim_3d, setup_3d = make_3d_sim(best_period, best_source_x)
cell_count = int(np.prod(sim_3d.grid.shape))
print(f"3D grid: {sim_3d.grid.shape} = {cell_count / 1e6:.2f} M cells")
print(f"Time steps: {sim_3d.num_steps}")
# BeamZ uses x and y as the transverse axes of a z-normal source plane.
# For direction=-z and phi=pi, the tangential wavevector points toward -x.
beam_direction = np.array(
(-np.sin(theta), 0.0, -np.cos(theta)), dtype=float
)
print(
"Gaussian-beam propagation direction: "
f"k_hat = ({beam_direction[0]:+.4f}, {beam_direction[1]:+.4f}, "
f"{beam_direction[2]:+.4f})"
)
fig, ax = plt.subplots(figsize=(4.3, 4.3), constrained_layout=True)
ax.plot((0.0, 0.0), (0.0, -1.05), color="0.55", ls="--", lw=1.5)
ax.arrow(
0.0,
0.0,
beam_direction[0],
beam_direction[2],
width=0.012,
head_width=0.075,
head_length=0.10,
length_includes_head=True,
color="#d81b60",
)
ax.text(-0.08, -0.42, f"{np.rad2deg(theta):.1f}°", ha="right", va="center")
ax.text(-0.27, -0.93, "toward −x", ha="right", va="top", color="#d81b60")
ax.set(
title="Gaussian-beam propagation direction",
xlabel="x (normalized)",
ylabel="z (normalized)",
xlim=(-0.42, 0.15),
ylim=(-1.10, 0.10),
)
ax.set_aspect("equal")
ax.grid(False)
plt.show()
fig, axes = sim_3d.plot(
z=setup_3d["shift"][2] + t_si - 0.5 * etch_depth,
y=0.0,
figsize=(13, 5.0),
width_ratios=(1.35, 1.0),
source_markers=True,
monitor_markers=True,
show=False,
)
for axis in np.asarray(axes).flat:
axis.grid(False)
fig.suptitle("Finite-width 3D grating coupler", y=1.03)
plt.show()
3D grid: (1724, 341, 129) = 75.84 M cells Time steps: 28182 Gaussian-beam propagation direction: k_hat = (-0.2504, +0.0000, -0.9681)
results_3d = sim_3d.run(
progress=True,
backend="cuda_streamed",
performance=True,
)* Compiling simulation...
* Running simulation: [--------------------] 0% (0/28182 steps)
* Running simulation: [#-------------------] 5% (1410/28182 steps)
* Running simulation: [##------------------] 10% (2820/28182 steps)
* Running simulation: [###-----------------] 15% (4230/28182 steps)
* Running simulation: [####----------------] 20% (5640/28182 steps)
* Running simulation: [#####---------------] 25% (7050/28182 steps)
* Running simulation: [######--------------] 30% (8460/28182 steps)
* Running simulation: [#######-------------] 35% (9870/28182 steps)
* Running simulation: [########------------] 40% (11280/28182 steps)
* Running simulation: [#########-----------] 45% (12690/28182 steps)
* Running simulation: [##########----------] 50% (14100/28182 steps)
* Running simulation: [###########---------] 55% (15510/28182 steps)
* Running simulation: [############--------] 60% (16920/28182 steps)
* Running simulation: [#############-------] 65% (18330/28182 steps)
* Running simulation: [##############------] 70% (19740/28182 steps)
* Running simulation: [###############-----] 75% (21150/28182 steps)
* Running simulation: [################----] 80% (22560/28182 steps)
* Running simulation: [#################---] 85% (23970/28182 steps)
* Running simulation: [##################--] 90% (25380/28182 steps)
* Running simulation: [###################-] 95% (26790/28182 steps)
* Running simulation: [####################] 100% (28182/28182 steps)
Simulation runtime: 380.36 s GCUPS: 5.619
5. Field propagation at 1.55 µm
The center-frequency DFT field on the symmetry plane provides a direct visual check of the source convention. The real $E_y$ close-up shows wavefronts descending toward −x onto the grating. The electric-field magnitude then shows the coupled field propagating left through the 50 µm taper. Material boundaries are overlaid by BeamZ's public field-plotting helper; grid lines remain disabled.
x0_3d = setup_3d["shift"][0]
grating_length_3d = num_teeth * best_period
grating_xlim = (
(x0_3d - 2.0 * um) / um,
(x0_3d + grating_length_3d + buffer) / um,
)
domain_x_um = sim_3d.domain[0] / um
fig, ax = results_3d.plot_field(
monitor_name="field_xz",
field_name="Ey",
frequency=freq0,
val="real",
cmap="RdBu_r",
xlim=grating_xlim,
figsize=(12.5, 4.2),
show=False,
)
ax.set_title("Incident and diffracted field: Re($E_y$), grating close-up")
ax.grid(False)
plt.show()
fig, ax = results_3d.plot_field(
monitor_name="field_xz",
field_name="E",
frequency=freq0,
val="abs",
cmap="magma",
xlim=(-0.5 * domain_x_um, 0.5 * domain_x_um),
figsize=(12.5, 4.0),
show=False,
)
ax.set_title("Electric-field magnitude through the full grating and taper")
ax.set_aspect("auto")
ax.grid(False)
plt.show()
6. Coupling spectrum
The mode monitor reports source-normalized complex amplitudes. Because the Gaussian beam requests 1 W, the squared magnitude of the backward fundamental mode amplitude is the fiber-to-waveguide coupling efficiency.
mode_3d = results_3d.mode("mode")
amplitude_3d = np.asarray(mode_3d.amps.sel(direction="-", mode_index=0))
coupling_3d = np.abs(amplitude_3d) ** 2s
fig, ax = plt.subplots(figsize=(7.4, 4.5), constrained_layout=True)
ax.plot(
wavelengths / um,
10 * np.log10(np.maximum(coupling_3d, 1e-15)),
color="#d81b60",
lw=2.2,
)
ax.set(
title="3D grating-coupler spectrum",
xlabel="Wavelength (µm)",
ylabel="Coupling efficiency (dB)",
ylim=(-12, 0),
)
ax.grid(False)
plt.show()
center_index = int(np.argmin(np.abs(wavelengths - lambda0)))
print(
f"Coupling at {wavelengths[center_index] / um:.3f} µm: "
f"{100 * coupling_3d[center_index]:.2f}% "
f"({10 * np.log10(max(coupling_3d[center_index], 1e-15)):.2f} dB)"
)
Coupling at 1.550 µm: 38.64% (-4.13 dB)