from collections import namedtuple
from collections.abc import Sequence
from types import SimpleNamespace
from typing import Literal
import jax
import jax.numpy as jnp
import numpy as np
import tidy3d
from jax.typing import ArrayLike
from loguru import logger
from tidy3d.components.mode.solver import compute_modes as _compute_modes
from fdtdx.core.axis import get_transverse_axes
from fdtdx.core.jax.utils import is_jax_tracer
from fdtdx.core.misc import expand_to_3x3
from fdtdx.core.physics.metrics import normalize_by_poynting_flux
from fdtdx.core.physics.symmetry import (
mirror_edge_coordinates,
mirror_material_cross_section,
project_onto_parity,
restrict_to_kept_half,
)
ModeTupleType = namedtuple("ModeTupleType", ["neff", "Ex", "Ey", "Ez", "Hx", "Hy", "Hz"])
"""A named tuple containing the mode fields and effective index.
Attributes:
neff: Complex effective refractive index of the mode
Ex: x-component of the electric field
Ey: y-component of the electric field
Ez: z-component of the electric field
Hx: x-component of the magnetic field
Hy: y-component of the magnetic field
Hz: z-component of the magnetic field
"""
def compute_mode_polarization_fraction(
mode: ModeTupleType,
tangential_axes: tuple[int, int],
pol: Literal["te", "tm"],
) -> float:
"""Mode polarization fraction.
Args:
mode (ModeTupleType): a ModeTupleType instance
tangential_axes (tuple[int, int]): indices of transverse E-field component axes.
pol (Literal["te", "tm"]): "te" or "tm" determines which axis is 'E1'
Returns:
float: Polarization fraction between 0 and 1.
"""
E_fields = [mode.Ex, mode.Ey, mode.Ez]
E1 = E_fields[tangential_axes[0]]
E2 = E_fields[tangential_axes[1]]
if pol == "te":
numerator = np.sum(np.abs(E1) ** 2)
elif pol == "tm":
numerator = np.sum(np.abs(E2) ** 2)
else:
raise ValueError(f"pol must be 'te' or 'tm', but got {pol}")
denominator = np.sum(np.abs(E1) ** 2 + np.abs(E2) ** 2) + 1e-18
return numerator / denominator
def sort_modes(
modes: list[ModeTupleType],
filter_pol: Literal["te", "tm"] | None,
tangential_axes: tuple[int, int],
) -> list[ModeTupleType]:
"""
Sort modes by polarization.
Args:
modes (list[ModeTupleType]): list of modes.
filter_pol (Literal["te", "tm"] | None): If not none, sort by polarization specificaton.
tangential_axes (tuple[int, int]): indices of transverse E-field component axes.
Returns:
list[ModeTupleType]: sorted list of modes.
"""
if filter_pol is None:
return sorted(modes, key=lambda m: float(np.real(m.neff)), reverse=True)
def is_matching(mode):
frac = compute_mode_polarization_fraction(mode, tangential_axes, filter_pol)
return frac >= 0.5
matching = [m for m in modes if is_matching(m)]
non_matching = [m for m in modes if not is_matching(m)]
matching_sorted = sorted(matching, key=lambda m: float(np.real(m.neff)), reverse=True)
non_matching_sorted = sorted(non_matching, key=lambda m: float(np.real(m.neff)), reverse=True)
return matching_sorted + non_matching_sorted
[docs]
def compute_mode(
frequency: float,
inv_permittivities: jax.Array, # shape (nx, ny, nz)
inv_permeabilities: jax.Array | float,
resolution: float | None = None,
direction: Literal["+", "-"] = "+",
mode_index: int = 0,
filter_pol: Literal["te", "tm"] | None = None,
dtype: jnp.dtype = jnp.float32,
bend_radius: float | None = None,
bend_axis: int | None = None,
symmetry: tuple[int, int] = (0, 0),
transverse_coords: Sequence[jax.Array] | None = None,
fixed_propagation_axis: int | None = None,
) -> tuple[
jax.Array, # E
jax.Array, # H
jax.Array, # complex propagation constant
]:
"""Compute optical modes of a waveguide cross-section.
This function uses the Tidy3D mode solver to compute the optical modes of a given waveguide cross-section defined
by its permittivity distribution.
By default modes are sorted by their effective index. The mode_index argument indexes this sorted list of modes and
returns the desired mode. With filter_pol, it is also possible to only index a specific polarization.
Args:
frequency (float): Operating frequency in Hz
inv_permittivities (jax.Array): 3D array of inverse relative permittivity values
inv_permeabilities (jax.Array | float): 3D array of inverse relative permittivity values or single float for
uniform permeability distribution.
resolution (float | None): Uniform-grid spacing in metres. Required when ``transverse_coords`` is not
provided (uniform-grid path). Ignored when ``transverse_coords`` is given. Defaults to None.
direction (Literal["+", "-"]): Propagation direction, either "+" or "-".
mode_index (int, optional): Index of the mode to compute. Defaults to 0.
filter_pol (Literal["te", "tm"] | None, optional). If not None, modes are filtered by polarization.
dtype (jnp.dtype, optional): Float dtype of the simulation. Controls whether mode fields are returned
as complex64 (float32) or complex128 (float64). Defaults to jnp.float32.
bend_radius (float | None, optional): Bend radius of the waveguide in meters. Must be set together with
bend_axis. When set, the mode solver uses a conformal transformation to account for the bend. Defaults to
None (straight waveguide).
bend_axis (int | None, optional): Physical axis index (0/1/2) pointing from the waveguide toward the center
of curvature. Must differ from the propagation axis. Required when bend_radius is set. Defaults to None.
symmetry (tuple[int, int], optional): Symmetry-plane condition at the *min* edge of each transverse axis,
in the order of the two non-propagation physical axes (increasing index). ``0`` imposes a PEC mirror
(electric wall — the tidy3d default), ``1`` imposes a PMC mirror (magnetic wall). Use this when the
waveguide sits on a symmetry plane of a reduced (half/quarter) domain so the mode solver reproduces the
same boundary the FDTD uses there. For a +x-propagating TE mode on a y/z quarter domain with PEC at y=0
and PMC at the z Si-mid plane, pass ``(0, 1)``. Defaults to ``(0, 0)`` (PEC on both, i.e. no symmetry).
transverse_coords: Optional pair of physical edge-coordinate arrays, in metres, for the two axes transverse
to propagation. Each array must have one more entry than the corresponding transverse cell count.
When provided, the Tidy3D mode solver receives the non-uniform rectilinear grid directly.
JAX arrays are accepted; the numpy conversion happens inside the tidy3d callback so the function
remains compatible with ``jax.jit``.
fixed_propagation_axis: Optional physical axis normal to the mode plane.
This disambiguates an extruded 2-D plane whose normal axis and
invariant transverse axis are both one cell wide.
Returns:
Tuple[jax.Array, jax.Array, jax.Array]:
Tuple of E, H field and the effective index as complex-valued jax arrays.
"""
# Input validation
valid_permittivity = inv_permittivities.ndim == 4 and inv_permittivities.shape[0] in [1, 3, 9]
if fixed_propagation_axis is None:
valid_permittivity = valid_permittivity and sum(dim == 1 for dim in inv_permittivities.shape[1:]) == 1
else:
valid_permittivity = (
valid_permittivity
and fixed_propagation_axis in (0, 1, 2)
and inv_permittivities.shape[fixed_propagation_axis + 1] == 1
)
if not valid_permittivity:
raise ValueError(f"Invalid shape of inv_permittivities: {inv_permittivities.shape}")
if (
isinstance(inv_permeabilities, jax.Array)
and inv_permeabilities.ndim > 0
and (
not (inv_permeabilities.ndim == 4 and inv_permeabilities.shape[0] in [1, 3, 9])
or (
fixed_propagation_axis is None
and sum(dim == 1 for dim in inv_permeabilities.shape[1:]) != 1
)
or (
fixed_propagation_axis is not None
and inv_permeabilities.shape[fixed_propagation_axis + 1] != 1
)
)
):
raise ValueError(f"Invalid shape of inv_permeabilities: {inv_permeabilities.shape}")
if (bend_radius is None) != (bend_axis is None):
raise ValueError("bend_radius and bend_axis must both be set or both be None")
np_complex_dtype = np.complex128 if dtype == jnp.float64 else np.complex64
def mode_helper(permittivity, permeability, c0_um, c1_um):
coords = [np.asarray(c0_um), np.asarray(c1_um)]
# A genuinely extruded 2-D FDTD domain has one cell on one transverse
# axis. The eigensolver needs a non-degenerate 2-D cross-section, so
# solve the same uniform extrusion on four virtual cells spanning the
# identical physical extent, then average it back to one cell. Four is
# also the historical minimum used by FDTDX's compact 2-D examples,
# making source and overlap profiles invariant under this optimization.
singleton_axes = [axis for axis, size in enumerate(permittivity.shape[1:]) if size == 1]
virtual_extrusion_axis = singleton_axes[0] if singleton_axes else None
if virtual_extrusion_axis is not None:
repeat_axis = virtual_extrusion_axis + 1
permittivity = np.repeat(permittivity, 4, axis=repeat_axis)
if isinstance(permeability, np.ndarray) and permeability.ndim > 0:
permeability = np.repeat(permeability, 4, axis=repeat_axis)
coords[virtual_extrusion_axis] = np.linspace(
coords[virtual_extrusion_axis][0],
coords[virtual_extrusion_axis][-1],
5,
)
# Implicitly detect 2D mode if any transverse dimension is exactly 2
mode_2d = virtual_extrusion_axis is None and 2 in permittivity.shape[1:]
if mode_2d:
collapsed_axis = permittivity.shape[1:].index(2)
sl = (slice(None), slice(None), [0]) if collapsed_axis == 1 else (slice(None), [0], slice(None))
assert np.allclose(permittivity, permittivity[sl]), "Permittivity is not uniform across the collapsed axis!"
assert len(coords[collapsed_axis]) == 3, (
f"Assumption: Permittivity {permittivity.shape[1:]=}+1 matches ({coords[0].shape=}, {coords[1].shape})"
)
permittivity = permittivity[sl]
if isinstance(permeability, np.ndarray) and permeability.ndim > 0:
permeability = permeability[sl]
# Adjust coordinates for the collapsed dimension
coords[collapsed_axis] = coords[collapsed_axis][:2]
if bend_radius is not None:
assert bend_axis is not None
transverse_axes = get_transverse_axes(propagation_axis)
tidy3d_bend_axis = transverse_axes.index(bend_axis)
bend_radius_um = bend_radius / 1e-6
plane_center = (float(0.5 * (coords[0][0] + coords[0][-1])), float(0.5 * (coords[1][0] + coords[1][-1])))
else:
tidy3d_bend_axis = None
bend_radius_um = None
plane_center = None
modes = tidy3d_mode_computation_wrapper(
frequency=frequency,
permittivity_cross_section=permittivity,
permeability_cross_section=permeability,
coords=coords,
direction=direction,
num_modes=2 * (mode_index + 1) + 10,
bend_radius=bend_radius_um,
bend_axis=tidy3d_bend_axis,
plane_center=plane_center,
symmetry=symmetry,
)
# sort modes by polarization
# tidy3d assumes propagation in the z-direction. The tangential axes are therefore x and y.
modes = sort_modes(modes, filter_pol, (0, 1))
mode = modes[mode_index]
if propagation_axis == 0:
mode_E, mode_H = (
np.stack([mode.Ez, mode.Ex, mode.Ey], axis=0).astype(np_complex_dtype),
np.stack([mode.Hz, mode.Hx, mode.Hy], axis=0).astype(np_complex_dtype),
)
elif propagation_axis == 1:
mode_E, mode_H = (
np.stack([mode.Ex, mode.Ez, mode.Ey], axis=0).astype(np_complex_dtype),
-np.stack([mode.Hx, mode.Hz, mode.Hy], axis=0).astype(np_complex_dtype),
)
elif propagation_axis == 2:
mode_E, mode_H = (
np.stack([mode.Ex, mode.Ey, mode.Ez], axis=0).astype(np_complex_dtype),
np.stack([mode.Hx, mode.Hy, mode.Hz], axis=0).astype(np_complex_dtype),
)
else:
raise ValueError(f"Invalid propagation axis: {propagation_axis}")
if virtual_extrusion_axis is not None:
collapse_axis = virtual_extrusion_axis + 1
mode_E = np.mean(mode_E, axis=collapse_axis, keepdims=True)
mode_H = np.mean(mode_H, axis=collapse_axis, keepdims=True)
if mode_2d:
# Re-expand the collapsed dimension
mode_E = np.expand_dims(mode_E, axis=collapsed_axis + 1)
mode_E = np.repeat(mode_E, 2, axis=collapsed_axis + 1)
mode_H = np.expand_dims(mode_H, axis=collapsed_axis + 1)
mode_H = np.repeat(mode_H, 2, axis=collapsed_axis + 1)
neff = np.asarray(mode.neff).astype(np_complex_dtype)
return mode_E, mode_H, neff
# compute input to tidy3d Mode solver
if inv_permittivities.shape[0] == 9:
eps = expand_to_3x3(inv_permittivities)
# Invert the 3x3 matrix
perm = (2, 3, 4, 0, 1) # (3, 3, nx, ny, nz) -> (nx, ny, nz, 3, 3)
inv_perm = (3, 4, 0, 1, 2) # (nx, ny, nz, 3, 3) -> (3, 3, nx, ny, nz)
permittivities = (
jnp.linalg.inv(eps.transpose(perm)).transpose(inv_perm).reshape(9, *inv_permittivities.shape[1:])
)
else:
permittivities = 1 / inv_permittivities
propagation_axis = (
permittivities.shape[1:].index(1)
if fixed_propagation_axis is None
else fixed_propagation_axis
)
other_axes = [axis + 1 for axis in range(3) if axis != propagation_axis]
if transverse_coords is None:
if resolution is None:
raise ValueError("resolution is required when transverse_coords is not provided")
# Uniform grid: build concrete coordinate arrays in µm and pass as callback args.
c0_um = jnp.asarray(np.arange(permittivities.shape[other_axes[0]] + 1) * resolution / 1e-6)
c1_um = jnp.asarray(np.arange(permittivities.shape[other_axes[1]] + 1) * resolution / 1e-6)
normalization_area_weights = None
else:
if len(transverse_coords) != 2:
raise ValueError(
f"transverse_coords must contain exactly two coordinate arrays, got {len(transverse_coords)}"
)
# Shape validation uses .shape which is always concrete, even for JAX tracers.
expected_lengths = [permittivities.shape[dim] + 1 for dim in other_axes]
for axis_idx, (coord, expected_length) in enumerate(zip(transverse_coords, expected_lengths, strict=True)):
if coord.ndim != 1 or coord.shape[0] != expected_length:
raise ValueError(
f"transverse_coords[{axis_idx}] must be 1D with length {expected_length}, got {coord.shape}"
)
# Convert to µm for tidy3d; keep as JAX arrays so jax.jit can trace through.
c0_um = jnp.asarray(transverse_coords[0]) / 1e-6
c1_um = jnp.asarray(transverse_coords[1]) / 1e-6
# area_2d in m²: use jnp.diff so this works with traced JAX arrays.
area_2d = (
jnp.diff(jnp.asarray(transverse_coords[0]))[:, None] * jnp.diff(jnp.asarray(transverse_coords[1]))[None, :]
).astype(dtype)
weight_shape = [1, 1, 1]
weight_shape[other_axes[0] - 1] = area_2d.shape[0]
weight_shape[other_axes[1] - 1] = area_2d.shape[1]
normalization_area_weights = area_2d.reshape(weight_shape)
permittivity_squeezed = jnp.take(
permittivities,
indices=0,
axis=propagation_axis + 1,
)
# Rotate permittivity components to match tidy3d coordinate system
# tidy3d assumes propagation along z, so we need to map physical axes to tidy3d axes:
# - tidy3d x → first transverse axis
# - tidy3d y → second transverse axis
# - tidy3d z → propagation axis
if propagation_axis == 0:
# propagation along x: tidy3d (x,y,z) → physical (y,z,x)
perm_idx = [1, 2, 0]
perm_idx_full_anisotropy = [4, 5, 3, 7, 8, 6, 1, 2, 0]
elif propagation_axis == 1:
# propagation along y: tidy3d (x,y,z) → physical (x,z,y)
perm_idx = [0, 2, 1]
perm_idx_full_anisotropy = [0, 2, 1, 6, 8, 7, 3, 5, 4]
else: # propagation_axis == 2
# propagation along z: tidy3d (x,y,z) → physical (x,y,z)
perm_idx = [0, 1, 2]
perm_idx_full_anisotropy = [0, 1, 2, 3, 4, 5, 6, 7, 8]
# Only apply rotation if anisotropic (3 components)
if permittivity_squeezed.shape[0] == 3:
permittivity_squeezed = permittivity_squeezed[jnp.array(perm_idx), :, :]
if permittivity_squeezed.shape[0] == 9:
permittivity_squeezed = permittivity_squeezed[jnp.array(perm_idx_full_anisotropy), :, :]
jnp_complex_dtype = jnp.complex128 if dtype == jnp.float64 else jnp.complex64
result_shape_dtype = (
jnp.zeros((3, *permittivity_squeezed.shape[1:]), dtype=jnp_complex_dtype),
jnp.zeros((3, *permittivity_squeezed.shape[1:]), dtype=jnp_complex_dtype),
jnp.zeros(shape=(), dtype=jnp_complex_dtype),
)
if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0 and inv_permeabilities.shape[0] == 9:
mu = expand_to_3x3(inv_permeabilities)
# Invert the 3x3 matrix
perm = (2, 3, 4, 0, 1) # (3, 3, nx, ny, nz) -> (nx, ny, nz, 3, 3)
inv_perm = (3, 4, 0, 1, 2) # (nx, ny, nz, 3, 3) -> (3, 3, nx, ny, nz)
permeabilities = (
jnp.linalg.inv(mu.transpose(perm)).transpose(inv_perm).reshape(9, *inv_permeabilities.shape[1:])
)
else:
permeabilities = 1 / inv_permeabilities
if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0:
permeability_squeezed = jnp.take(
permeabilities,
indices=0,
axis=propagation_axis + 1,
)
# Apply same rotation to permeability if anisotropic
if permeability_squeezed.shape[0] == 3:
permeability_squeezed = permeability_squeezed[jnp.array(perm_idx), :, :]
if permeability_squeezed.shape[0] == 9:
permeability_squeezed = permeability_squeezed[jnp.array(perm_idx_full_anisotropy), :, :]
else: # float
permeability_squeezed = permeabilities
# pure callback to tidy3d is necessary to work in jitted environment.
# c0_um and c1_um are passed as explicit args so JAX materialises them to
# concrete numpy arrays before calling mode_helper, allowing np.asarray()
# inside the callback without raising TracerArrayConversionError.
mode_E_raw, mode_H_raw, eff_idx = jax.pure_callback(
mode_helper,
result_shape_dtype,
jax.lax.stop_gradient(permittivity_squeezed),
jax.lax.stop_gradient(permeability_squeezed),
jax.lax.stop_gradient(c0_um),
jax.lax.stop_gradient(c1_um),
)
mode_E = jnp.expand_dims(mode_E_raw, axis=propagation_axis + 1)
mode_H = jnp.expand_dims(mode_H_raw, axis=propagation_axis + 1)
# Tidy3D uses different scaling internally, so convert back
mode_H = mode_H * tidy3d.constants.ETA_0
mode_E_norm, mode_H_norm = normalize_by_poynting_flux(
mode_E,
mode_H,
axis=propagation_axis,
area_weights=normalization_area_weights,
)
return mode_E_norm, mode_H_norm, eff_idx
def _check_parity_residual(residual: jax.Array, walls: dict[int, int], object_name: str) -> None:
"""Diagnose a parity projection that removed too much of the mode.
Skipped when ``residual`` is a JAX tracer. A mode source or mode-overlap detector that overlaps a
:class:`~fdtdx.Device` solves its mode inside :func:`fdtdx.apply_params`, which callers routinely
trace (``jax.jit`` around an optimization step), and there the residual has no value yet:
concretizing it would raise, and comparing it would fire on a tracer. The check cannot be hoisted
out of the trace either — the residual depends on the device permittivity being traced over. It is
setup guidance, not something the solve depends on, so an eager ``apply_params`` surfaces it; and
``place_objects`` already applies (hence checks) every mode object that does not overlap a device,
which is the overwhelming majority.
Args:
residual (jax.Array): Fraction of the mode the projection removed.
walls (dict[int, int]): Mirror axis to wall type, for the message.
object_name (str): Name used in diagnostics.
Raises:
ValueError: If the projection removed (almost) the whole mode, i.e. the configured wall types
are incompatible with the selected mode.
"""
if is_jax_tracer(residual):
return
value = float(residual)
wall_description = ", ".join(f"{'xyz'[axis]}={'PMC' if wall == 1 else 'PEC'}" for axis, wall in walls.items())
if value > 0.9:
raise ValueError(
f"The mode selected for '{object_name}' has (almost) none of the symmetry imposed by the "
f"walls on {wall_description}: projecting it onto the admissible parity removes "
f"{value:.1%} of the mode. The wall types do not match this mode - flip the sign of "
f"config.symmetry on those axes, pick a different mode_index/filter_pol, or run without "
f"config.symmetry."
)
if value > 0.3:
logger.warning(
f"The mode of '{object_name}' is only approximately symmetric about the walls on "
f"{wall_description}: the parity projection removed {value:.1%} of it. Check that the "
f"structure is mirror-symmetric there and that the wall types match the mode. A coarsely "
f"resolved cross-section alone can account for a residual of a few tens of percent - the "
f"mode solver samples materials on its staggered grid, so its discrete mode is only "
f"mirror-symmetric to first order in the cell size."
)
def compute_mode_symmetry_reduced(
*,
mirrored_axes: tuple[int, ...],
walls: dict[int, int],
frequency: float,
inv_permittivities: jax.Array,
inv_permeabilities: jax.Array | float,
resolution: float | None = None,
direction: Literal["+", "-"] = "+",
mode_index: int = 0,
filter_pol: Literal["te", "tm"] | None = None,
dtype: jnp.dtype = jnp.float32,
bend_radius: float | None = None,
bend_axis: int | None = None,
transverse_coords: Sequence[jax.Array] | None = None,
object_name: str = "mode object",
fixed_propagation_axis: int | None = None,
) -> tuple[jax.Array, jax.Array, jax.Array]:
"""Solve a mode on a symmetry-reduced cross-section by way of the full cross-section.
A reduced simulation replaces the discarded half by the mirror image of the kept half, so the
mode it supports is the full-domain mode restricted to the kept half. Rather than asking the
mode solver for a *symmetric* solve on the reduced cross-section, this mirrors the reduced
material arrays back to the full cross-section, solves there, and restricts the result. The
detour matters: the mode solver interprets the permittivity arrays on its own staggered Yee
grid, while FDTDX rasterizes materials per cell and hands the same array to every component, so
a solve on the reduced cross-section is inconsistent with the full one at first order in the
cell size (an ``neff`` error of several percent for a high-index waveguide). Going through the
full cross-section reproduces exactly the mode the unreduced simulation would inject.
The solved mode is then projected onto the parity subspace the walls admit (see
:func:`~fdtdx.core.physics.symmetry.project_onto_parity`), which both removes the small
non-symmetric residue of the discrete mode and detects a wall type that does not match the mode
at all. Finally the fields are renormalized to unit Poynting flux through the *reduced* plane,
keeping the convention that a mode source launches unit power through the plane it occupies.
Both steps are limited by the same discretization the detour above avoids for ``neff``: because
the solver samples materials on its staggered grid, the discrete mode of a mirror-symmetric
cross-section is itself only symmetric to first order in the cell size, so its flux does not
split exactly evenly between the two halves. Measured for a 400x200 nm Si waveguide at 1.55 um:
0.446 / 0.554 along the solver's first transverse axis at 25 nm (0.473 / 0.527 at 12.5 nm), and
0.499 / 0.501 along its second. Renormalizing over the reduced plane therefore leaves the
returned profile up to ~6% (25 nm) resp. ~3% (12.5 nm) above ``sqrt(2**k)`` times the restriction
of the full-domain mode on such an axis, and the parity projection removes a few tenths of a
percent of its norm. Both vanish with refinement; the flux convention is exact by construction at
every resolution.
Args:
mirrored_axes (tuple[int, ...]): Physical axes clipped by a symmetry plane.
walls (dict[int, int]): Mirror axis to wall type (``-1`` PEC, ``+1`` PMC).
frequency (float): Operating frequency in Hz.
inv_permittivities (jax.Array): Reduced inverse permittivity on the mode plane.
inv_permeabilities (jax.Array | float): Reduced inverse permeability on the mode plane.
resolution (float | None): Uniform grid spacing, required without ``transverse_coords``.
direction (Literal["+", "-"]): Propagation direction.
mode_index (int): Index into the sorted mode list.
filter_pol (Literal["te", "tm"] | None): Optional polarization filter.
dtype (jnp.dtype): Float dtype of the simulation.
bend_radius (float | None): Waveguide bend radius, with ``bend_axis``.
bend_axis (int | None): Physical axis pointing toward the center of curvature.
transverse_coords (Sequence[jax.Array] | None): Reduced transverse edge coordinates, or
None on a uniform grid.
object_name (str): Name used in diagnostics.
fixed_propagation_axis (int | None): Explicit mode-plane normal for an
extruded domain with two singleton dimensions.
Returns:
tuple[jax.Array, jax.Array, jax.Array]: ``(E, H, effective_index)`` on the reduced
cross-section.
Raises:
ValueError: If a waveguide bend shares an axis with a symmetry plane, or if the parity
projection removes almost the entire mode, which means the configured wall types are
incompatible with the selected mode. The latter is only detectable where the residual is
concrete, i.e. not inside ``jax.jit`` (see :func:`_check_parity_residual`).
"""
if bend_radius is not None and bend_axis is not None and bend_axis in mirrored_axes:
raise ValueError(
f"'{object_name}' bends about the {'xyz'[bend_axis]}-axis and config.symmetry mirrors "
f"that same axis. The bend is modelled by a conformal transformation that scales the "
f"refractive index linearly across bend_axis, so the transformed cross-section is not "
f"mirror-symmetric about a plane normal to it: the mode has no definite parity there and "
f"the reduced simulation, which replaces the discarded half by the mirror of the kept "
f"half, cannot represent it. Drop config.symmetry on the {'xyz'[bend_axis]}-axis, or put "
f"the symmetry plane normal to the other transverse axis - a bend leaves that one "
f"mirror-symmetric."
)
propagation_axis = (
next(a for a in range(3) if inv_permittivities.shape[1:][a] == 1)
if fixed_propagation_axis is None
else fixed_propagation_axis
)
full_inv_permittivities = mirror_material_cross_section(inv_permittivities, mirrored_axes)
if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0:
full_inv_permeabilities: jax.Array | float = mirror_material_cross_section(inv_permeabilities, mirrored_axes)
else:
full_inv_permeabilities = inv_permeabilities
full_transverse_coords = transverse_coords
if transverse_coords is not None:
transverse_axes = get_transverse_axes(propagation_axis)
full_transverse_coords = [
mirror_edge_coordinates(coords) if axis in mirrored_axes else coords
for axis, coords in zip(transverse_axes, transverse_coords, strict=True)
]
mode_E, mode_H, eff_index = compute_mode(
frequency=frequency,
inv_permittivities=full_inv_permittivities,
inv_permeabilities=full_inv_permeabilities,
resolution=resolution,
direction=direction,
mode_index=mode_index,
filter_pol=filter_pol,
dtype=dtype,
bend_radius=bend_radius,
bend_axis=bend_axis,
symmetry=(0, 0),
transverse_coords=full_transverse_coords,
fixed_propagation_axis=propagation_axis,
)
mode_E, residual_E = project_onto_parity(mode_E, "E", walls)
mode_H, residual_H = project_onto_parity(mode_H, "H", walls)
_check_parity_residual(jnp.maximum(residual_E, residual_H), walls, object_name)
mode_E = restrict_to_kept_half(mode_E, mirrored_axes)
mode_H = restrict_to_kept_half(mode_H, mirrored_axes)
area_weights = None
if transverse_coords is not None:
widths = [jnp.diff(jnp.asarray(coords)) for coords in transverse_coords]
area_2d = widths[0][:, None] * widths[1][None, :]
weight_shape = [1, 1, 1]
for local_axis, axis in enumerate(get_transverse_axes(propagation_axis)):
weight_shape[axis] = area_2d.shape[local_axis]
area_weights = area_2d.reshape(weight_shape).astype(mode_E.real.dtype)
mode_E, mode_H = normalize_by_poynting_flux(
mode_E,
mode_H,
axis=propagation_axis,
area_weights=area_weights,
)
return mode_E, mode_H, eff_index
def _is_reciprocal_tensor(components: Sequence[ArrayLike], tol: float = 1e-6) -> bool:
"""Check whether a material tensor given as 9 row-major components is symmetric (reciprocal)."""
return bool(
np.max(np.abs(np.asarray(components[1]) - np.asarray(components[3]))) <= tol
and np.max(np.abs(np.asarray(components[2]) - np.asarray(components[6]))) <= tol
and np.max(np.abs(np.asarray(components[5]) - np.asarray(components[7]))) <= tol
)
def _canonicalize_mode_phase(fields: tuple[np.ndarray, ...], num_modes: int) -> tuple[np.ndarray, ...]:
"""Choose a deterministic global phase for each mode.
Eigenvectors have arbitrary global phase, and Tidy3D 2.9 can add a global
``±i`` to backward modes while normalizing their negative self-flux. Use
the largest transverse electric-field sample (``Ex`` or ``Ey`` in
Tidy3D's propagation-along-z frame) as a real, non-negative phase anchor.
This removes only global phase; intrinsic spatial phase in tensorial or
lossy modes is preserved.
"""
def canonicalize_one(mode_fields: tuple[np.ndarray, ...]) -> tuple[np.ndarray, ...]:
transverse_e = np.concatenate([mode_fields[0].ravel(), mode_fields[1].ravel()])
reference = transverse_e[np.argmax(np.abs(transverse_e))]
if abs(reference) == 0:
return mode_fields
phase = np.conj(reference) / abs(reference)
return tuple(field * phase for field in mode_fields)
arrays = tuple(np.asarray(field) for field in fields)
if num_modes == 1:
return canonicalize_one(arrays)
canonical_modes = [canonicalize_one(tuple(field[..., index] for field in arrays)) for index in range(num_modes)]
return tuple(np.stack([mode[field_index] for mode in canonical_modes], axis=-1) for field_index in range(6))
def tidy3d_mode_computation_wrapper(
frequency: float,
permittivity_cross_section: ArrayLike,
coords: list[np.ndarray],
direction: Literal["+", "-"],
permeability_cross_section: ArrayLike | float | None = None,
target_neff: float | None = None,
angle_theta: float = 0.0,
angle_phi: float = 0.0,
num_modes: int = 10,
precision: Literal["single", "double"] = "double",
bend_radius: float | None = None,
bend_axis: int | None = None,
plane_center: tuple[float, float] | None = None,
symmetry: tuple[int, int] = (0, 0),
) -> list[ModeTupleType]:
"""Compute optical modes of a waveguide cross-section.
This function uses the Tidy3D mode solver to compute the optical modes of a given
waveguide cross-section defined by its permittivity distribution.
Args:
frequency (float): Operating frequency in Hz
permittivity_cross_section (jax.Array): 2D array of relative permittivity values
coords (List[np.ndarray]): List of coordinate arrays [x, y] defining the grid
direction (Literal["+", "-"], optional): Propagation direction, either "+" or "-"
permeability_cross_section (jax.Array | float | None, optional): 2D array of relative permeability values.
Defauts to None.
target_neff (float | None, optional): Target effective index to search around. Defaults to None.
angle_theta (float, optional): Polar angle in radians. Defaults to 0.0.
angle_phi (float, optional): Azimuthal angle in radians. Defaults to 0.0.
num_modes (int, optional): Number of modes to compute. Defaults to 10.
precision (Literal["single", "double"], optional): Numerical precision. Defaults to "double".
bend_radius (float | None, optional): Bend radius in microns (tidy3d units). Defaults to None.
bend_axis (int | None, optional): Axis index (0 or 1) of the center of curvature in tidy3d's transverse
coordinate frame. Defaults to None.
plane_center (tuple[float, float] | None, optional): Center of the mode plane in the same units as coords.
Required by tidy3d when bend_radius is set. Defaults to None.
symmetry (tuple[int, int], optional): Per-transverse-axis symmetry condition at the min edge, forwarded to
the tidy3d mode solver. ``1`` imposes a PMC (magnetic) wall there; ``0`` (default) leaves the solver's
PEC (electric) wall. Order matches ``coords``. Defaults to ``(0, 0)``.
Notes:
tidy3d assumes propagation in z-direction. The output fields should be handled accordingly.
Returns:
List[ModeTupleType]: List of computed modes sorted by decreasing real part of
effective index. Each mode contains the field components and effective index.
"""
# see https://docs.flexcompute.com/projects/tidy3d/en/latest/_autosummary/tidy3d.ModeSpec.html#tidy3d.ModeSpec
mode_spec = SimpleNamespace(
# Note that the filter_pol argument is not used here since it does not work from tidy3d
num_modes=num_modes,
target_neff=target_neff,
num_pml=(0, 0),
angle_theta=angle_theta,
angle_phi=angle_phi,
bend_radius=bend_radius,
bend_axis=bend_axis,
precision=precision,
track_freq="central",
group_index_step=False,
)
permittivity_cross_section = jnp.asarray(permittivity_cross_section)
permittivity_cross_section = expand_to_3x3(permittivity_cross_section)
permittivity_cross_section = permittivity_cross_section.reshape(9, *permittivity_cross_section.shape[2:])
eps_cross = [
permittivity_cross_section[0],
permittivity_cross_section[1],
permittivity_cross_section[2],
permittivity_cross_section[3],
permittivity_cross_section[4],
permittivity_cross_section[5],
permittivity_cross_section[6],
permittivity_cross_section[7],
permittivity_cross_section[8],
]
mu_cross = None
if permeability_cross_section is not None:
permeability_cross_section = jnp.asarray(permeability_cross_section)
permeability_cross_section = expand_to_3x3(permeability_cross_section)
permeability_cross_section = permeability_cross_section.reshape(9, *permeability_cross_section.shape[2:])
mu_cross = [
permeability_cross_section[0],
permeability_cross_section[1],
permeability_cross_section[2],
permeability_cross_section[3],
permeability_cross_section[4],
permeability_cross_section[5],
permeability_cross_section[6],
permeability_cross_section[7],
permeability_cross_section[8],
]
if direction == "-" and (
angle_theta != 0.0
or angle_phi != 0.0
or not _is_reciprocal_tensor(eps_cross)
or (mu_cross is not None and not _is_reciprocal_tensor(mu_cross))
):
raise NotImplementedError(
"Backward ('-') modes are derived from the forward solve via the reciprocity transformation, "
"which requires symmetric material tensors and normal incidence."
)
# Solve the requested direction. A backward mode is not generally a simple
# component-sign transform of the forward mode when the material tensor
# couples longitudinal and transverse fields. Canonicalizing below removes
# Tidy3D 2.9's arbitrary backward global phase without changing the field.
EH, neffs, _ = _compute_modes(
eps_cross=eps_cross,
coords=coords,
freq=frequency,
precision=precision,
mode_spec=mode_spec,
direction=direction,
mu_cross=mu_cross,
plane_center=plane_center,
symmetry=symmetry,
)
((Ex, Ey, Ez), (Hx, Hy, Hz)) = EH.squeeze()
Ex, Ey, Ez, Hx, Hy, Hz = _canonicalize_mode_phase((Ex, Ey, Ez, Hx, Hy, Hz), num_modes)
if num_modes == 1:
modes = [
ModeTupleType(
Ex=Ex,
Ey=Ey,
Ez=Ez,
Hx=Hx,
Hy=Hy,
Hz=Hz,
neff=float(neffs.real) + 1j * float(neffs.imag),
)
for _ in range(num_modes)
]
else:
modes = [
ModeTupleType(
Ex=Ex[..., i],
Ey=Ey[..., i],
Ez=Ez[..., i],
Hx=Hx[..., i],
Hy=Hy[..., i],
Hz=Hz[..., i],
neff=neffs[i],
)
for i in range(num_modes)
]
return modes