Source code for fdtdx.objects.boundaries.perfectly_matched_layer

import jax
import jax.numpy as jnp
from typing_extensions import override

from fdtdx import Color
from fdtdx.colors import XKCD_DARK_GREY
from fdtdx.constants import c, eps0, eta0
from fdtdx.core.jax.pytrees import autoinit, frozen_field
from fdtdx.objects.boundaries.boundary import BaseBoundary


[docs] @autoinit class PerfectlyMatchedLayer(BaseBoundary): """Implements a Convolutional Perfectly Matched Layer (CPML) boundary condition. The CPML absorbs outgoing electromagnetic waves with minimal reflection by using a complex coordinate stretching approach. This implementation supports arbitrary axis orientation and both positive/negative directions. """ #: Initial loss parameter for complex frequency shifting. Defaults to 0.01 * 2 * jnp.pi * c / wavelength * eps0 if not provided. alpha_start: float | None = frozen_field(default=None) #: Final loss parameter for complex frequency shifting. Defaults to 0.0 if not provided. alpha_end: float | None = frozen_field(default=None) #: Polynomial order for alpha grading. Defaults to 1.0 if not provided. alpha_order: float | None = frozen_field(default=None) #: Initial kappa stretching coefficient. Defaults to 0.0 if not provided. kappa_start: float | None = frozen_field(default=None) #: Final kappa stretching coefficient. Defaults to 0.0 if not provided. kappa_end: float | None = frozen_field(default=None) #: Polynomial order for kappa grading. Defaults to 1.0 if not provided. kappa_order: float | None = frozen_field(default=None) #: Initial sigma value. Defaults to 0.0 if not provided. sigma_start: float | None = frozen_field(default=None) #: Final sigma value. Defaults to 1.0 if not provided. sigma_end: float | None = frozen_field(default=None) #: Polynomial order for sigma grading. Defaults to 3.0 if not provided. sigma_order: float | None = frozen_field(default=None) #: RGB color tuple for visualization. defaults to dark grey. color: Color | None = frozen_field(default=XKCD_DARK_GREY) #: CPML 'a' coefficient array for Electric field updates. pml_a_E: jax.Array | None = frozen_field(default=None) #: CPML 'b' coefficient array for Electric field updates. pml_b_E: jax.Array | None = frozen_field(default=None) #: Inverse of the kappa stretching parameter array for the Electric field. inv_kappa_E: jax.Array | None = frozen_field(default=None) #: CPML 'a' coefficient array for Magnetic field updates. pml_a_H: jax.Array | None = frozen_field(default=None) #: CPML 'b' coefficient array for Magnetic field updates. pml_b_H: jax.Array | None = frozen_field(default=None) #: Inverse of the kappa stretching parameter array for the Magnetic field. inv_kappa_H: jax.Array | None = frozen_field(default=None) def __post_init__(self): """Sets default PML parameters if not provided.""" # Set default values if None is provided # Simple defaults that don't depend on grid properties if self.alpha_start is None: object.__setattr__(self, "alpha_start", 0.01 * 2 * jnp.pi * c / 1.55e-6 * eps0) if self.alpha_end is None: object.__setattr__(self, "alpha_end", 0.0) if self.alpha_order is None: object.__setattr__(self, "alpha_order", 1.0) if self.kappa_start is None: object.__setattr__(self, "kappa_start", 1.0) if self.kappa_end is None: object.__setattr__(self, "kappa_end", 1.0) if self.kappa_order is None: object.__setattr__(self, "kappa_order", 3.0) if self.sigma_start is None: object.__setattr__(self, "sigma_start", 0.0) if self.sigma_order is None: object.__setattr__(self, "sigma_order", 3.0)
[docs] def place_on_grid(self, grid_slice_tuple, config, key): """Place the PML on the grid and calculate any remaining defaults. This is called after initialization, so grid_shape and config are available. """ # First call the parent implementation to set grid_slice_tuple and config self = super().place_on_grid(grid_slice_tuple, config, key) # Now calculate sigma_end if it wasn't provided by the user if self.sigma_end is None: assert self.sigma_order is not None, "sigma_order should be set by __post_init__" pml_thickness = self._physical_thickness() sigma_end_calculated = -(self.sigma_order + 1) * jnp.log(1e-6) / (2 * (eta0 / 1.0) * pml_thickness) self = self.aset("sigma_end", sigma_end_calculated.astype(float)) dtype = config.dtype dt = config.time_step_duration assert self.sigma_start is not None and self.sigma_end is not None and self.sigma_order is not None assert self.kappa_start is not None and self.kappa_end is not None and self.kappa_order is not None assert self.alpha_start is not None and self.alpha_end is not None and self.alpha_order is not None sigma_E, sigma_H = self._compute_pml_profile(self.sigma_start, self.sigma_end, self.sigma_order, dtype) kappa_E, kappa_H = self._compute_pml_profile(self.kappa_start, self.kappa_end, self.kappa_order, dtype) alpha_E, alpha_H = self._compute_pml_profile(self.alpha_start, self.alpha_end, self.alpha_order, dtype) b_E = jnp.expm1(-dt / eps0 * (sigma_E / kappa_E + alpha_E)) + 1 a_E = jnp.nan_to_num((b_E - 1.0) * sigma_E / (sigma_E + alpha_E * kappa_E) / kappa_E, nan=0.0) b_H = jnp.expm1(-dt / eps0 * (sigma_H / kappa_H + alpha_H)) + 1 a_H = jnp.nan_to_num((b_H - 1.0) * sigma_H / (sigma_H + alpha_H * kappa_H) / kappa_H, nan=0.0) self = self.aset("pml_a_E", a_E) self = self.aset("pml_b_E", b_E) self = self.aset("inv_kappa_E", 1.0 / kappa_E) self = self.aset("pml_a_H", a_H) self = self.aset("pml_b_H", b_H) self = self.aset("inv_kappa_H", 1.0 / kappa_H) return self
[docs] def step_cpml( self, d_field_1: jax.Array, d_field_2: jax.Array, psi_1: jax.Array, psi_2: jax.Array, is_curl_E: bool, simulate_boundaries: bool, ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]: """Performs localized CPML correction for the two derivatives along this boundary's axis. Uses Auxiliary Differential Equations (ADEs) to update the psi arrays and applies the complex coordinate stretching corrections to the spatial derivatives. Args: d_field_1: The first spatial derivative array needing PML correction. d_field_2: The second spatial derivative array needing PML correction. psi_1: The accumulator array (psi) corresponding to the first derivative. psi_2: The accumulator array (psi) corresponding to the second derivative. is_curl_E: Flag determining whether to use H-field coefficients (True, when computing curl(E) to update H) or E-field coefficients (False, when computing curl(H) to update E). simulate_boundaries: Flag to toggle whether the boundary memory variables (psi) should actually be updated in this step. Returns: tuple[jax.Array, jax.Array, jax.Array, jax.Array]: A tuple containing: - corr_1: The PML-corrected first spatial derivative. - corr_2: The PML-corrected second spatial derivative. - psi_1_new: The updated accumulator array for the first derivative. - psi_2_new: The updated accumulator array for the second derivative. """ assert self.pml_a_E is not None and self.pml_b_E is not None and self.inv_kappa_E is not None assert self.pml_a_H is not None and self.pml_b_H is not None and self.inv_kappa_H is not None if is_curl_E: a, b, inv_kappa = self.pml_a_H, self.pml_b_H, self.inv_kappa_H else: a, b, inv_kappa = self.pml_a_E, self.pml_b_E, self.inv_kappa_E if simulate_boundaries: psi_1_new = b * psi_1 + a * d_field_1 psi_2_new = b * psi_2 + a * d_field_2 else: psi_1_new, psi_2_new = psi_1, psi_2 if self.kappa_start == 1.0 and self.kappa_end == 1.0: corr_1, corr_2 = psi_1_new, psi_2_new else: corr_1 = (inv_kappa - 1.0) * d_field_1 + psi_1_new corr_2 = (inv_kappa - 1.0) * d_field_2 + psi_2_new return corr_1, corr_2, psi_1_new, psi_2_new
def _physical_thickness(self) -> float: """Return PML thickness in metres. Uniform-grid simulations keep the historical ``cell_count * spacing`` behavior. Non-uniform grids derive thickness from physical grid edges so the same PML cell count can represent stretched physical layers. """ grid = self._config.resolved_grid if grid is not None: return grid.axis_extent(self.axis, self.grid_slice_tuple[self.axis]) return self.thickness * self._config.uniform_spacing() @property @override def descriptive_name(self) -> str: """Gets a human-readable name describing this PML boundary's location. Returns: str: Description like "min_x" or "max_z" indicating position """ axis_str = "x" if self.axis == 0 else "y" if self.axis == 1 else "z" direction_str = "min" if self.direction == "-" else "max" return f"{direction_str}_{axis_str}" @property @override def thickness(self) -> int: """Gets the thickness of the PML layer in grid points. Returns: int: Number of grid points in the PML along its axis """ return self.grid_shape[self.axis]
[docs] @override def apply_field_reset(self, fields: dict[str, jax.Array]) -> dict[str, jax.Array]: """Zero all field components within the PML region.""" return {name: field.at[:, *self.grid_slice].set(0) for name, field in fields.items()}
def _compute_pml_profile( self, value_start: float, value_end: float, order: float, dtype, ) -> tuple[jax.Array, jax.Array]: """Computes a graded PML profile using polynomial scaling. Args: value_start: Value at the interface (inner boundary) value_end: Value at the outer boundary order: Polynomial order for grading dtype: Data type for the array Returns: Broadcast-shaped E/H profile arrays with grading along only the PML axis. """ L = self.thickness # Total thickness of PML # Create distance array along the PML axis # d varies from 0 (at interface) to L (at outer edge) if self._config.has_nonuniform_grid: dE, dH, norm = self._compute_nonuniform_pml_depths(dtype) elif self.direction == "-": # For min boundary, distance increases as we go towards lower indices dE = jnp.arange(L - 1, -1, -1, dtype=dtype) dH = jnp.append(jnp.arange(L - 1.5, -0.5, -1, dtype=dtype), 0) norm = L else: # For max boundary, distance increases as we go towards higher indices dE = jnp.insert(jnp.arange(0.5, L - 0.5, 1, dtype=dtype), 0, 0) dH = jnp.arange(0, L, 1, dtype=dtype) norm = L # Compute polynomial grading: value_start + (value_end - value_start) * (d/L)^order profileE_1d = value_start + (value_end - value_start) * jnp.power(dE / norm, order) profileH_1d = value_start + (value_end - value_start) * jnp.power(dH / norm, order) # Create shape matching PML region with grading only along self.axis shape = [1, 1, 1] shape[self.axis] = L profileE_reshaped = profileE_1d.reshape(shape) profileH_reshaped = profileH_1d.reshape(shape) return profileE_reshaped, profileH_reshaped def _compute_nonuniform_pml_depths(self, dtype) -> tuple[jax.Array, jax.Array, float]: """Return E/H physical depths into a non-uniform PML. Depth is measured from the interior PML interface toward the outer boundary. The E profile uses cell-edge depth so the interface cell has zero depth, matching the existing uniform-grid endpoint convention. The H profile uses cell-center depth except at the interface cell, where it is pinned to zero for continuity with the historical CPML staggering. """ grid = self._config.resolved_grid assert grid is not None lower, upper = self.grid_slice_tuple[self.axis] edges = grid.edges(self.axis)[lower : upper + 1].astype(dtype) norm = float(edges[-1] - edges[0]) centers = 0.5 * (edges[:-1] + edges[1:]) zero = jnp.asarray(0.0, dtype=dtype) if self.direction == "-": interface = edges[-1] dE = interface - edges[1:] dH = jnp.concatenate([interface - centers[1:], zero.reshape(1)]) else: interface = edges[0] dE = jnp.concatenate([zero.reshape(1), centers[:-1] - interface]) dH = edges[:-1] - interface return dE, dH, norm