Source code for fdtdx.objects.sources.mode

from pathlib import Path
from typing import Literal, Self

import jax
import jax.numpy as jnp
import numpy as np
from matplotlib import pyplot as plt

from fdtdx import constants
from fdtdx.core.axis import get_transverse_axes
from fdtdx.core.grid import calculate_time_offset_yee
from fdtdx.core.jax.pytrees import autoinit, frozen_field, private_field
from fdtdx.core.linalg import get_wave_vector_raw
from fdtdx.core.physics.metrics import compute_energy
from fdtdx.core.physics.modes import compute_mode, compute_mode_symmetry_reduced
from fdtdx.dispersion import effective_complex_inv_permittivity, effective_inv_permittivity
from fdtdx.objects.sources.source import Source
from fdtdx.objects.sources.tfsf import TFSFPlaneSource, _build_dispersive_H_filter


[docs] @autoinit class ModePlaneSource(TFSFPlaneSource): #: index of the mode mode_index: int = frozen_field(default=0) #: a literal value 'te', 'tm' to filter filter_pol: Literal["te", "tm"] | None = frozen_field(default=None) #: Symmetry-plane condition at the min edge of each transverse axis (the two #: non-propagation physical axes, in increasing-index order): ``0`` = PEC #: mirror (electric wall, the default), ``1`` = PMC mirror (magnetic wall). #: Set this only for a **hand-built** half/quarter domain (your own PEC/PMC boundary at the min #: edge), where it asks the mode solver for its own symmetric solve. It is *not* needed - and is #: ignored - when ``config.symmetry`` performs the reduction: there the mode is solved on the #: mirrored full cross-section and restricted, which reproduces the full-domain mode exactly #: (the mode solver's symmetric solve does not, because FDTDX rasterizes materials per cell #: while the solver samples them on its staggered grid). symmetry: tuple[int, int] = frozen_field(default=(0, 0)) _inv_permittivity: jax.Array = private_field() _inv_permeability: jax.Array | float = private_field() _neff: jax.Array = private_field() # not required for sim, used for inspection def _local_edge_coordinates(self) -> tuple[jax.Array, jax.Array, jax.Array] | None: """Return local physical edge coordinates for this source slice. Non-uniform mode sources need edge coordinates for both Tidy3D mode solving and Yee time offsets. Coordinates are shifted so the source slice lower corner is at zero on each axis. """ grid = self._config.resolved_grid if grid is None: return None local_edges = [] for axis in range(3): lower, upper = self.grid_slice_tuple[axis] edges = grid.edges(axis)[lower : upper + 1] local_edges.append(edges - edges[0]) e0, e1, e2 = local_edges return e0, e1, e2 def _transverse_edge_coordinates(self) -> tuple[jax.Array, jax.Array] | None: """Return local transverse edge coordinates for Tidy3D mode solving.""" local_edges = self._local_edge_coordinates() if local_edges is None: return None axes = get_transverse_axes(self.propagation_axis) return local_edges[axes[0]], local_edges[axes[1]] def _mode_solver_resolution(self) -> float: """Return scalar resolution only for legacy uniform mode-solver setup. ``compute_mode`` ignores this value when explicit transverse coordinates are provided, but the argument remains part of the compatibility API. """ if self._config.has_nonuniform_grid: assert self._config.resolved_grid is not None return self._config.resolved_grid.min_spacing return self._config.uniform_spacing() def _source_center_physical(self) -> jax.Array | None: """Return the physical source center for grid-aware Yee time offsets.""" local_edges = self._local_edge_coordinates() if local_edges is None: return None center = [] for axis, edges in enumerate(local_edges): if axis == self.propagation_axis: center.append(jnp.asarray(0.0, dtype=self._config.dtype)) else: center.append(0.5 * edges[-1]) return jnp.asarray(center, dtype=self._config.dtype) def apply( self: Self, key: jax.Array, inv_permittivities: jax.Array, inv_permeabilities: jax.Array | float, dispersive_c1: jax.Array | None = None, dispersive_c2: jax.Array | None = None, dispersive_c3: jax.Array | None = None, electric_conductivity: jax.Array | None = None, dispersive_c4: jax.Array | None = None, ) -> Self: del key if ( self.azimuth_angle != 0 or self.elevation_angle != 0 or self.max_angle_random_offset != 0 or self.max_vertical_offset != 0 or self.max_horizontal_offset != 0 ): raise NotImplementedError() # inv_permittivities shape: (3, Nx, Ny, Nz) - slice with component dimension inv_permittivity_slice = inv_permittivities[:, *self.grid_slice] if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0: # inv_permeabilities shape: (3, Nx, Ny, Nz) - slice with component dimension inv_permeability_slice = inv_permeabilities[:, *self.grid_slice] else: inv_permeability_slice = inv_permeabilities # Preserve the raw ε∞ slice before the carrier-frequency correction — # the broadband impedance filter needs ε∞ to reconstruct ε(ω). inv_eps_inf_slice = inv_permittivity_slice # Frequency-correct the permittivity seen by the mode solver so that # mode profiles computed inside a dispersive medium reflect the true # epsilon at the carrier frequency, not epsilon_infinity. c1_slice = c2_slice = c3_slice = c4_slice = None if dispersive_c1 is not None and dispersive_c2 is not None and dispersive_c3 is not None: c1_slice = dispersive_c1[:, :, *self.grid_slice] c2_slice = dispersive_c2[:, :, *self.grid_slice] c3_slice = dispersive_c3[:, :, *self.grid_slice] c4_slice = None if dispersive_c4 is None else dispersive_c4[:, :, *self.grid_slice] inv_permittivity_slice = effective_inv_permittivity( inv_eps=inv_permittivity_slice, c1=c1_slice, c2=c2_slice, c3=c3_slice, omega=2.0 * np.pi * self.wave_character.get_frequency(), dt=self._config.time_step_duration, c4=c4_slice, ) self = self.aset("_inv_permittivity", inv_permittivity_slice, create_new_ok=True) self = self.aset("_inv_permeability", inv_permeability_slice, create_new_ok=True) # Permittivity handed to the mode solver: the FULL complex epsilon at the # carrier frequency (eps_inf + chi(omega) + i*sigma/(eps0*omega)), so the # solved mode profile and effective index reflect material loss. This is # kept separate from inv_permittivity_slice above, which stays real for the # impedance/energy normalization — using the imaginary part there would # double-count the absorption already integrated by the FDTD update. sigma_slice = None if electric_conductivity is None else electric_conductivity[:, *self.grid_slice] mode_inv_permittivity = inv_eps_inf_slice if sigma_slice is not None or c1_slice is not None: mode_inv_permittivity = effective_complex_inv_permittivity( inv_eps=inv_eps_inf_slice, omega=2.0 * np.pi * self.wave_character.get_frequency(), dt=self._config.time_step_duration, c1=c1_slice, c2=c2_slice, c3=c3_slice, c4=c4_slice, electric_conductivity=sigma_slice, conductivity_spacing=( None if sigma_slice is None else constants.c * self._config.time_step_duration / self._config.courant_number ), ) # compute mode mirrored_axes = self.symmetry_mirror_axes(exclude_axis=self.propagation_axis) if mirrored_axes: # Symmetry-reduced cross-section: solve on the mirrored full cross-section and restrict, # so the injected mode is exactly the one the unreduced simulation would launch. mode_E, mode_H, eff_index = compute_mode_symmetry_reduced( mirrored_axes=mirrored_axes, walls={axis: self._config.symmetry[axis] for axis in mirrored_axes}, frequency=self.wave_character.get_frequency(), inv_permittivities=mode_inv_permittivity, inv_permeabilities=inv_permeability_slice, resolution=self._mode_solver_resolution(), direction=self.direction, mode_index=self.mode_index, filter_pol=self.filter_pol, dtype=self._config.dtype, transverse_coords=self._transverse_edge_coordinates(), object_name=self.name, fixed_propagation_axis=self.propagation_axis, ) else: mode_E, mode_H, eff_index = compute_mode( frequency=self.wave_character.get_frequency(), inv_permittivities=mode_inv_permittivity, inv_permeabilities=inv_permeability_slice, resolution=self._mode_solver_resolution(), direction=self.direction, mode_index=self.mode_index, filter_pol=self.filter_pol, dtype=self._config.dtype, symmetry=self.symmetry, transverse_coords=self._transverse_edge_coordinates(), fixed_propagation_axis=self.propagation_axis, ) # Keep the complex modal fields when the mode was solved against a lossy # (conductivity) permittivity, so the launched source carries the # eigenmode's transverse phase — TFSFPlaneSource.update_E/update_H inject # the complex profile via a quadrature (cos/sin) decomposition. Lossless # modes are projected to real (bit-identical to before). The dispersive # path also stays real here because its broadband H-filter assumes a real # temporal profile. keep_complex_mode = sigma_slice is not None and c1_slice is None if not keep_complex_mode: mode_E, mode_H = jnp.real(mode_E), jnp.real(mode_H) self = self.aset("_E", mode_E, create_new_ok=True) self = self.aset("_H", mode_H, create_new_ok=True) self = self.aset("_neff", eff_index, create_new_ok=True) center = jnp.asarray( [round(self.grid_shape[self.horizontal_axis]), round(self.grid_shape[self.vertical_axis])], dtype=jnp.int32 ) raw_wave_vector = get_wave_vector_raw( direction=self.direction, propagation_axis=self.propagation_axis, dtype=self._config.dtype, ) time_offset_E, time_offset_H = calculate_time_offset_yee( center=center, wave_vector=raw_wave_vector, inv_permittivities=inv_permittivity_slice, inv_permeabilities=jnp.ones_like(inv_permeability_slice), resolution=self._mode_solver_resolution(), time_step_duration=self._config.time_step_duration, effective_index=jnp.real(eff_index), coordinate_edges=self._local_edge_coordinates(), center_physical=self._source_center_physical(), ) self = self.aset("_time_offset_E", time_offset_E, create_new_ok=True) self = self.aset("_time_offset_H", time_offset_H, create_new_ok=True) # Broadband impedance correction for dispersive media. The mode solver # above used ε(ω_c), so the resulting H profile already carries the # correct scalar impedance at the carrier frequency. For a broadband # pulse the medium's ε(ω) varies across the source spectrum, which # mismatches η away from ω_c and radiates spurious reflections through # the TFSF surface. Precompute a filtered H-side temporal profile # whose spectrum is S(ω)·√(ε(ω)/ε(ω_c)) to bake in the frequency- # dependent correction. # # Note: bulk ε(ω) is averaged uniformly over the source cells; this # does not capture geometric modal dispersion (the fact that a # waveguide mode's effective index also depends on frequency). if c1_slice is not None and c2_slice is not None and c3_slice is not None: filtered = _build_dispersive_H_filter( temporal_profile=self.temporal_profile, wave_character=self.wave_character, dt=self._config.time_step_duration, num_time_steps=self._config.time_steps_total, c1_slice=c1_slice, c2_slice=c2_slice, c3_slice=c3_slice, inv_eps_inf_slice=inv_eps_inf_slice, dtype=self._config.dtype, c4_slice=c4_slice, ) self = self.aset("_temporal_H_filter", filtered, create_new_ok=True) else: # Reused source applied in a non-dispersive context: clear any stale # filter from a previous dispersive apply. self = self.aset("_temporal_H_filter", None, create_new_ok=True) return self def plot(self, save_path: str | Path): if self._H is None or self._E is None: raise Exception("Cannot plot mode without init to grid and apply params first") energy = compute_energy( E=self._E, H=self._H, inv_permittivity=self._inv_permittivity, inv_permeability=self._inv_permeability, ) energy_2d = energy.squeeze().T plt.clf() fig = plt.figure(figsize=(10, 10)) mode_cmap = "inferno" im = plt.imshow( energy_2d, cmap=mode_cmap, origin="lower", aspect="equal", ) plt.colorbar(im) # Ensure the plot takes up the entire figure plt.tight_layout(pad=0) plt.savefig(save_path, bbox_inches="tight", pad_inches=0) plt.close(fig)
@autoinit class ModeProfileCurrentSource(ModePlaneSource): """Soft electric-current sheet shaped as a solved waveguide mode. Unlike :class:`ModePlaneSource`, this source does not inject the magnetic Huygens partner and therefore does not select a propagation direction. It is an impressed electric current with transverse profile ``mode_E`` and radiates to both sides of its plane, the distributed analogue of an electric point dipole. ``direction`` only fixes the otherwise arbitrary phase of the reference-mode solve. A sheet may touch a symmetry plane normal to its mode axis when ``subcell_symmetry_centered`` is true. The kept first-cell deposit is then weighted by one half so its mirror pair preserves the full-domain current moment. """ amplitude: float = frozen_field(default=1.0) subcell_symmetry_centered: bool = frozen_field(default=False) def validate_placement(self, objects) -> list[str]: # Deliberately bypass TFSFPlaneSource's prohibition on a plane normal # to a symmetry wall: this is a soft current, not a Huygens boundary. errors = list(Source.validate_placement(self, objects)) if self.touches_symmetry_plane(self.propagation_axis) and not self.subcell_symmetry_centered: errors.append( f"Mode current '{self.name}' touches the symmetry plane normal to its mode axis; " "set subcell_symmetry_centered=True or move it off the plane." ) return errors @property def _current_centering_scale(self) -> float: if self.subcell_symmetry_centered and self.touches_symmetry_plane(self.propagation_axis): return 0.5 return 1.0 def with_electric_current_profile(self, profile: jax.Array) -> Self: """Return a copy that injects ``profile`` instead of the solved mode. The mode solve performed by :meth:`ModePlaneSource.apply` remains the reference used to establish the source plane, normalization, and detector reciprocity. This method only replaces the impressed electric-current profile used by :meth:`update_E`. If ``profile`` is assembled from differentiable coefficients inside an objective, the checkpointed FDTD reverse pass differentiates through those coefficients as well as through the material arrays. Normalization and physical-support constraints deliberately belong to the caller: different applications use different current norms and launchable subspaces. Keeping this primitive literal prevents the solver from silently changing source amplitude. """ if self._E is None: raise ValueError("the reference mode must be applied before replacing its current profile") values = jnp.asarray(profile) if tuple(values.shape) != tuple(self._E.shape): raise ValueError( f"current profile shape {tuple(values.shape)} does not match " f"the solved mode shape {tuple(self._E.shape)}" ) if jnp.iscomplexobj(values): raise ValueError("ModeProfileCurrentSource currently requires a real electric-current profile") return self.aset("_E", values.astype(jnp.real(self._E).dtype), create_new_ok=True) def update_E( self, E: jax.Array, inv_permittivities: jax.Array, inv_permeabilities: jax.Array | float, time_step: jax.Array, inverse: bool, ) -> jax.Array: del inv_permeabilities amplitude = self.temporal_profile.get_amplitude( time=time_step * self._config.time_step_duration, period=self.wave_character.get_period(), phase_shift=self.wave_character.phase_shift, ) current = jnp.real(self._E) inv_eps = inv_permittivities[:, *self.grid_slice] if inv_eps.shape[0] == 9: inv_eps_tensor = inv_eps.reshape((3, 3, *inv_eps.shape[1:])) response = jnp.einsum("ij...,j...->i...", inv_eps_tensor, current) else: response = inv_eps * current sign = 1.0 if inverse else -1.0 scale = ( sign * self._config.courant_number * self.static_amplitude_factor * self.amplitude * self._current_centering_scale * amplitude ) return E.at[:, *self.grid_slice].add((scale * response).astype(E.dtype)) def update_H( self, H: jax.Array, inv_permittivities: jax.Array, inv_permeabilities: jax.Array | float, time_step: jax.Array, inverse: bool, ) -> jax.Array: del inv_permittivities, inv_permeabilities, time_step, inverse return H