Polarization splitter-rotator: TE/TM routing#

This notebook reconstructs the full 44 µm asymmetric taper and directional-coupler section from the official Tidy3D PSR example. Separate TE₀ and TM₀ launches are run through the same geometry; actual field phasors and desired/leakage port spectra are plotted for both inputs.

Imports and parameters#

import os
os.environ.setdefault("XLA_PYTHON_CLIENT_PREALLOCATE", "false")
from time import perf_counter
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import fdtdx
from IPython import get_ipython

from benchmarks.cases.device_polarization_splitter_rotator.run import (
    DL_UM, DOMAIN_UM, PML_LAYERS, RUN_TIME, UM, WAVELENGTH0_UM,
    WAVELENGTHS_UM, _scene,
)
from benchmarks.device_geometry import polarization_splitter_rotator_vertices_um

get_ipython().run_line_magic("matplotlib", "inline")
pd.Series({
    "domain_um": DOMAIN_UM,
    "wavelengths_um": WAVELENGTHS_UM,
    "center_wavelength_um": WAVELENGTH0_UM,
    "axis_grid_pitch_um": DL_UM,
    "maximum_time_ps": RUN_TIME * 1e12,
    "PML_cells": PML_LAYERS,
}, name="value").to_frame()
value
domain_um (3.855, 74.86666666666666, 1.4)
wavelengths_um (1.45, 1.525, 1.6)
center_wavelength_um 1.525
axis_grid_pitch_um (0.03, 0.04, 0.03)
maximum_time_ps 2.0
PML_cells 8

1. Exact asymmetric geometry#

wide, narrow = polarization_splitter_rotator_vertices_um()
fig, ax = plt.subplots(figsize=(5.5, 11))
ax.fill(wide[:, 0], wide[:, 1], color="#55c2b5", alpha=0.75, label="wide silicon guide")
ax.fill(narrow[:, 0], narrow[:, 1], color="#8e44ad", alpha=0.75, label="narrow silicon guide")
ax.set(xlabel="x (µm)", ylabel="y (µm)", title="Full taper and coupler geometry", aspect="equal")
ax.legend(); plt.show()
../../_images/38001c0049817c0159ae3b2692a42611d5d5989b9ad3c272f8f01c772bc54ece.png

2. Build TE and TM scenes and inspect voxelization#

scenes = {polarization: _scene(polarization, 1.0, record_field=True) for polarization in ("te", "tm")}
objects_te, arrays_te, config_te, _ = scenes["te"]
print(f"Anisotropic Yee grid: {objects_te.volume.grid_shape}")
print(f"Time-step ceiling per launch: {config_te.time_steps_total:,}")
fig, ax = plt.subplots(figsize=(5.5, 10))
fdtdx.plot_material_from_side(config_te, arrays_te, "z", position=0.0, ax=ax, plot_legend=True)
ax.set_title("Voxelized device plane"); plt.show()
Anisotropic Yee grid: (145, 1888, 63)
Time-step ceiling per launch: 32,318
../../_images/6f45b0ab83c99a0351d485c1e66565d6296480a768024c904e85d03673af583e.png

3. Run the two input polarizations#

results, states = {}, {}
started = perf_counter()
for polarization, (objects, arrays, config, output_ports) in scenes.items():
    results[polarization], states[polarization] = fdtdx.calculate_sparam(
        objects, arrays, config, "input", show_progress=False
    )
print(f"Two local FDTDX runs: {perf_counter() - started:.1f} s")
Two local FDTDX runs: 491.4 s

4. Plot the actual TE and TM field responses#

fig, axes = plt.subplots(1, 2, figsize=(8.5, 10), sharex=True, sharey=True)
for ax, polarization in zip(axes, ("te", "tm"), strict=True):
    phasor = np.asarray(states[polarization]["field_xy"]["phasor"])[0, 0, :3]
    magnitude = np.sqrt(np.sum(np.abs(phasor) ** 2, axis=0)).squeeze()
    magnitude /= magnitude.max() + 1e-30
    image = ax.imshow(magnitude.T, origin="lower", cmap="magma", aspect="auto", extent=(-DOMAIN_UM[0] / 2, DOMAIN_UM[0] / 2, -DOMAIN_UM[1] / 2, DOMAIN_UM[1] / 2), vmin=0, vmax=1)
    ax.set(title=f"{polarization.upper()}₀ input · executed |E|", xlabel="x (µm)")
axes[0].set_ylabel("y (µm)")
fig.colorbar(image, ax=axes, label="normalized |E|", shrink=0.7)
plt.show()
../../_images/afd026165277f72327572e94bd84bc0dde9e4b6d5d475a9b7d5ea0bc98aba5d5.png

5. Desired conversion and leakage spectra#

port_names = {
    "te": ("te_wide", "te_narrow"),
    "tm": ("tm_narrow_te", "tm_wide_tm"),
}
powers = {}
for polarization, names in port_names.items():
    for name in names:
        powers[name] = np.abs(np.asarray(results[polarization][(name, "input")]).squeeze()) ** 2

golden = np.load("benchmarks/goldens/device_polarization_splitter_rotator.npz", allow_pickle=True)
wavelengths = np.asarray(WAVELENGTHS_UM)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4), sharey=True)
for ax, polarization in zip(axes, ("te", "tm"), strict=True):
    for name, color in zip(port_names[polarization], ("#087d83", "#8221a8"), strict=True):
        ax.plot(wavelengths, powers[name], "o-", color=color, label=f"FDTDX · {name}")
        ax.plot(wavelengths, golden[name], "x--", color=color, alpha=0.65, label=f"Tidy3D · {name}")
    ax.set(title=f"{polarization.upper()}₀ launch", xlabel="wavelength (µm)")
    ax.grid(alpha=0.25); ax.legend(fontsize=7)
axes[0].set_ylabel("normalized modal power")
plt.show()
pd.DataFrame({"wavelength_um": wavelengths, **powers})
../../_images/6a9009d345b56a37ea131bfc661b9089bb3b28a04a2e57860187a6fa2cc314f7.png
wavelength_um te_wide te_narrow tm_narrow_te tm_wide_tm
0 1.450 0.996876 0.000260 0.906050 0.009447
1 1.525 0.997406 0.000576 0.924553 0.012085
2 1.600 0.996504 0.001184 0.692314 0.016446

Reproduce#

The anisotropic mesh resolves the 150 nm lateral gap and 220 nm layer while keeping the 75 µm propagation axis affordable.

print("uv run fdtdx-bench run --case device_polarization_splitter_rotator")
uv run fdtdx-bench run --case device_polarization_splitter_rotator