1×4 MMI power splitter in BeamZ
Unknown · 2026-09-21
1×4 MMI power splitter in BeamZ
This BeamZ-native workflow recreates the geometry, source, monitor, spectral analysis, and length/width sweep of Tidy3D's 1×4 MMI power-splitter example. The device is a 3D Si–GaN–Si slot-waveguide MMI at 1550 nm.
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
from beamz.analysis.plotting import plot_mode_field_components
um = bz.umBeamZ 0.5.0 | reduced test mode: False
1. Materials and design parameters
The parameters match the reference example: a 5 µm-wide MMI, 11.2 µm initial length, 0.4 µm access waveguides, and a 0.1 µm GaN slot between 0.3 µm silicon layers.
n_si = 3.48
n_gan = 2.305
n_sio2 = 1.444
mat_si = bz.Material(permittivity=n_si**2)
mat_gan = bz.Material(permittivity=n_gan**2)
mat_sio2 = bz.Material(permittivity=n_sio2**2)
W_MMI = 5.0 * um
L_MMI = 11.2 * um
g1 = 0.9 * um
W1 = 0.4 * um
W2 = 0.8 * um
L1 = 2.0 * um
L2 = 5.0 * um
H_SI = 0.3 * um
H_GAN = 0.1 * um
lambda0 = 1.55 * um
freq0 = bz.LIGHT_SPEED / lambda0
wavelengths = np.linspace(1.50, 1.60, 21) * um
freqs = bz.LIGHT_SPEED / wavelengths
fwidth = 0.5 * (np.max(freqs) - np.min(freqs))
buffer_x = 1.0 * um
buffer_y = 1.5 * um
slot_stack_thickness = 2 * H_SI + H_GAN
domain_depth = 5 * (2 * H_GAN + H_SI)
output_centers = np.array([-1.5, -0.5, 0.5, 1.5]) * (g1 + W1)
print("Output centerlines (µm):", output_centers / um)Output centerlines (µm): [-1.95 -0.65 0.65 1.95]
2. Build a parameterized 3D MMI simulation
make_sim() is deliberately analogous to the reference notebook. It returns an immutable BeamZ Simulation and the named monitor geometry used later for analysis.
def trapezoid(x0, x1, width0, width1, center_y=0.0):
"""Return a longitudinal taper polygon in the xy plane."""
return (
(x0, center_y - 0.5 * width0),
(x1, center_y - 0.5 * width1),
(x1, center_y + 0.5 * width1),
(x0, center_y + 0.5 * width0),
)
def make_sim(L_mmi, W_mmi, *, include_field=True):
domain_length = L1 + L_mmi + L2 + 2 * buffer_x
domain_width = W_mmi + 2 * buffer_y
domain = (domain_length, domain_width, domain_depth)
x_left = -0.5 * domain_length
x_taper_in = x_left + buffer_x
x_mmi_in = x_taper_in + L1
x_mmi_out = x_mmi_in + L_mmi
x_taper_out = x_mmi_out + L2
# Si is continuous above and below the GaN slot. The GaN polygons are
# added second, so the slot overrides the silicon material in its slab.
silicon_polygons = [
((x_left, -W1 / 2), (x_taper_in, -W1 / 2),
(x_taper_in, W1 / 2), (x_left, W1 / 2)),
trapezoid(x_taper_in, x_mmi_in, W1, W2),
((x_mmi_in, -W_mmi / 2), (x_mmi_out, -W_mmi / 2),
(x_mmi_out, W_mmi / 2), (x_mmi_in, W_mmi / 2)),
]
for center_y in output_centers:
silicon_polygons.extend(
[
trapezoid(x_mmi_out, x_taper_out, W2, W1, center_y),
((x_taper_out, center_y - W1 / 2), (0.5 * domain_length, center_y - W1 / 2),
(0.5 * domain_length, center_y + W1 / 2), (x_taper_out, center_y + W1 / 2)),
]
)
design = bz.Design(background=mat_sio2)
for vertices in silicon_polygons:
design += bz.Polygon(
vertices=vertices, material=mat_si, depth=slot_stack_thickness,
z=-0.5 * slot_stack_thickness, color="#d81b60",
)
for vertices in silicon_polygons:
design += bz.Polygon(
vertices=vertices, material=mat_gan, depth=H_GAN, z=-0.5 * H_GAN,
color="#f6c85f",
)
steps_per_wavelength = 12
resolution = lambda0 / (steps_per_wavelength * n_si)
grid_spec = bz.GridSpec.uniform(resolution)
cpml_cells = 12
pml_thickness = cpml_cells * resolution
source_time = bz.GaussianPulse(
freq0=freq0, fwidth=fwidth, offset=4.0,
)
mode_spec = bz.ModeSpec(
num_modes=2, mode_index=0, target_neff=n_si, polarization="te",
)
source_x = x_left + 0.5 * buffer_x
output_x = 0.5 * domain_length - 0.5 * buffer_x
plane_size = (0.0, 5 * W1, domain_depth - 2 * pml_thickness)
source = bz.ModeSource(
center=(source_x, 0.0, 0.0), size=plane_size, direction="+",
source_time=source_time, mode_spec=mode_spec,
)
flux_monitors = tuple(
bz.FluxMonitor(
center=(output_x, center_y, 0.0), size=(0.0, 3 * W1, domain_depth - 2 * pml_thickness),
freqs=freqs, name=f"flux_{index}",
)
for index, center_y in enumerate(output_centers)
)
mode_monitors = (
bz.ModeMonitor(
center=(output_x, output_centers[1], 0.0),
size=(0.0, 3 * W1, domain_depth - 2 * pml_thickness),
freqs=freqs, mode_spec=mode_spec, name="mode_inner",
),
bz.ModeMonitor(
center=(output_x, output_centers[3], 0.0),
size=(0.0, 3 * W1, domain_depth - 2 * pml_thickness),
freqs=freqs, mode_spec=mode_spec, name="mode_outer",
),
)
field_monitor = bz.FieldMonitor(
center=(0.0, 0.0, 0.0), size=(domain_length, domain_width, 0.0),
freqs=[freq0], fields=("Ex", "Ey", "Ez", "Hx", "Hy", "Hz"), name="field",
)
monitors = (*flux_monitors, *mode_monitors, *((field_monitor,) if include_field else ()))
simulation = bz.Simulation(
domain=domain, design=design, sources=(source,), monitors=monitors,
boundaries=(bz.PML(thickness=pml_thickness, formulation="cpml"),),
grid_spec=grid_spec, run_time=(5_000) / freq0,
setup_device="default",
)
return simulation, {
"source": source, "output_x": output_x, "domain": domain,
"pml_thickness": pml_thickness, "mode_spec": mode_spec,
}3. Initial design and source mode
As in the reference workflow, inspect both material cross sections and the local mode before launching the FDTD simulation.
sim, setup = make_sim(L_MMI, W_MMI)
print(f"3D grid: {sim.grid.shape} | time steps: {sim.num_steps}")
fig, axes = sim.plot(
z=0.0, y=0.0, figsize=(12, 4.5), width_ratios=(1.2, 1.0),
source_markers=True, monitor_markers=True, show=False,
)
for axis in np.asarray(axes).flat:
axis.grid(False)
fig.suptitle("Initial 1×4 MMI: xy core plane and xz centerline", y=1.03)
plt.show()3D grid: (545, 216, 68) | time steps: 365306
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, 1), val="abs",
f=freq0, figsize=(10, 7), 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.651973 | 0.0 | 0.0 | 0.397128 |
| 1 | 1.55 | 2.362479 | 0.0 | 0.0 | 0.382592 |
4. Run the initial design and inspect its response
BeamZ executes locally. With the optional direct CUDA component installed, backend="cuda_streamed" can be selected here; the portable notebook uses JAX in reduced test mode.
initial_results = sim.run(progress=True, backend="cuda_streamed")* Compiling simulation... * Running simulation: [####################] 100% (365306/365306 steps) Simulation runtime: 817.21 s GCUPS: 3.578 Launched power: 1 | backend: cuda_streamed
initial_results.plot_field("field", "Hz", frequency=freq0, val="real", cmap="RdBu", show_grid=False)
plt.title("Initial-design field magnitude in the xy core plane")
plt.show()def splitter_metrics(results):
launched = max(float(results.launched_power()), 1e-18)
output_power = np.stack([
np.maximum(np.asarray(results[f"flux_{index}"].flux, dtype=float), 0.0)
for index in range(4)
]) / launched
inner = 0.5 * (output_power[1] + output_power[2])
outer = 0.5 * (output_power[0] + output_power[3])
total = np.sum(output_power, axis=0)
excess_loss_db = -10 * np.log10(np.maximum(total, 1e-12))
imbalance = np.abs(inner - outer)
return output_power, inner, outer, excess_loss_db, imbalance
output_power, inner_power, outer_power, excess_loss_db, imbalance = splitter_metrics(initial_results)
center_index = int(np.argmin(np.abs(wavelengths - lambda0)))
fig, axes = plt.subplots(1, 2, figsize=(11, 4), constrained_layout=True)
for index, power in enumerate(output_power):
axes[0].plot(wavelengths / um, power, label=f"Output {index + 1}")
axes[0].set(xlabel="Wavelength (µm)", ylabel="Normalized output power", title="Transmission spectrum")
axes[0].legend(ncol=2, frameon=False)
axes[1].plot(wavelengths / um, excess_loss_db, color="#d81b60")
axes[1].axvline(lambda0 / um, color="0.35", ls="--", lw=1)
axes[1].set(xlabel="Wavelength (µm)", ylabel="Excess loss (dB)", title="Total excess loss")
for axis in axes:
axis.grid(False)
plt.show()
print(f"1550 nm: inner={inner_power[center_index]:.3f}, outer={outer_power[center_index]:.3f}, excess loss={excess_loss_db[center_index]:.3f} dB")1550 nm: inner=0.212, outer=0.243, excess loss=0.412 dB
outer_mode_data = initial_results.mode("mode_outer")
forward_amps = np.asarray(outer_mode_data.amps.sel(direction="+"))
modal_fraction = np.abs(forward_amps) ** 2 / np.maximum(outer_power[:, None], 1e-12)
fig, ax = plt.subplots(figsize=(6, 3.8))
for mode_index in range(modal_fraction.shape[1]):
ax.plot(wavelengths / um, modal_fraction[:, mode_index], label=f"Mode {mode_index}")
ax.set(xlabel="Wavelength (µm)", ylabel="Outer-output modal power fraction", title="Mode composition")
ax.legend(frameon=False)
ax.grid(False)
plt.show()initial_results.plot_field("field", "H", frequency=freq0, val="abs^2", cmap="magma")
plt.show()