from typing import Literal, Self
import jax
import jax.numpy as jnp
import numpy as np
from fdtdx.core.axis import get_oriented_transverse_axes
from fdtdx.core.jax.pytrees import autoinit, frozen_field, private_field
from fdtdx.core.linalg import rotate_vector
from fdtdx.core.null import Null
from fdtdx.dispersion import effective_inv_permittivity
from fdtdx.objects.sources.source import Source
def _contract_orientation(
inv_material: jax.Array | float,
orientation: jax.Array,
) -> jax.Array:
"""Contract a material tensor with the dipole orientation.
Returns ``inv_oriented[i, x, y, z] = sum_j inv_material_{ij}(x,y,z) * orientation_j``
as a ``(3, Nx, Ny, Nz)`` array. For the isotropic and diagonal
representations this reduces to componentwise multiplication, matching the
pre-existing behavior. For the 9-component flattened tensor it correctly
picks up the off-diagonal coupling that was previously dropped by indexing
a row of the flattened tensor with ``inv_material[axis]``.
"""
orient = orientation.reshape(3, 1, 1, 1)
if not isinstance(inv_material, jax.Array) or inv_material.ndim == 0:
return jnp.asarray(inv_material) * orient
shape0 = inv_material.shape[0]
if shape0 == 9:
tensor = inv_material.reshape(3, 3, *inv_material.shape[1:])
return jnp.einsum("ij...,j->i...", tensor, orientation)
# shape0 in (1, 3): isotropic scalar broadcasts against orientation, diagonal
# multiplies componentwise. Both collapse to this single multiplication.
return inv_material * orient
def _axis_aligned_diagonal_injection(
inv_material: jax.Array | float, azimuth_angle: float, elevation_angle: float
) -> bool:
"""Return whether an axis-aligned dipole can update only one field component."""
if azimuth_angle != 0.0 or elevation_angle != 0.0:
return False
if not isinstance(inv_material, jax.Array) or inv_material.ndim == 0:
return True
return inv_material.shape[0] in (1, 3)
[docs]
@autoinit
class PointDipoleSource(Source):
"""Soft point dipole source (electric or magnetic).
Injects an impressed current at a single Yee cell. The source is "soft":
it adds to the field rather than overwriting, so scattered/reflected fields
pass through without artificial reflections.
The dipole orientation starts along the ``polarization`` axis and is then
rotated by ``azimuth_angle`` and ``elevation_angle`` (both in degrees),
following the same convention as :class:`TFSFPlaneSource`. When both
angles are zero the dipole is axis-aligned, recovering the original
behavior.
For an electric dipole with unit orientation ``p_hat``, the E-field
update at each time step is::
E[i, x, y, z] += -c * (inv_eps @ p_hat)[i] * amplitude * temporal(t)
where the tensor contraction ``(inv_eps @ p_hat)[i] = sum_j inv_eps_{ij} * p_hat_j``
collapses to ``inv_eps[i] * p_hat[i]`` for isotropic and diagonal media but
correctly picks up off-diagonal coupling when the permittivity is a full
3x3 tensor.
For a magnetic dipole, the dual applies during the H update with
inv_permeability replacing inv_permittivity.
The medium permittivity/permeability at the source cell is sampled once
during :meth:`apply` — at the carrier angular frequency when a dispersive
coefficient arrays is provided — so dispersive media are handled correctly
without runtime overhead.
"""
#: Polarization axis (0=x, 1=y, 2=z).
polarization: int = frozen_field()
#: Azimuth angle in degrees (rotation around vertical axis).
azimuth_angle: float = frozen_field(default=0.0)
#: Elevation angle in degrees (rotation around horizontal axis).
elevation_angle: float = frozen_field(default=0.0)
#: Source type: "electric" injects into E update, "magnetic" into H update.
source_type: Literal["electric", "magnetic"] = frozen_field(default="electric")
#: Source amplitude.
amplitude: float = frozen_field(default=1.0)
_inv_eps_local: jax.Array = private_field()
_inv_mu_local: jax.Array | float = private_field()
_inv_eps_oriented: jax.Array = private_field()
_inv_mu_oriented: jax.Array = private_field()
def __post_init__(self):
if self.source_type not in ("electric", "magnetic"):
raise ValueError(f"source_type must be electric or magnetic, got {self.source_type}")
if self.polarization not in (0, 1, 2):
raise ValueError(f"polarization must be 0, 1, or 2, got {self.polarization}")
@property
def _orientation(self) -> jnp.ndarray:
"""Normalized orientation vector as a (3,) JAX array.
Starts as the unit vector along ``polarization`` and is rotated by
``azimuth_angle`` / ``elevation_angle`` using the same rotation
convention as :func:`rotate_vector`.
"""
base = jnp.zeros(3, dtype=self._config.dtype).at[self.polarization].set(1.0)
if self.azimuth_angle == 0.0 and self.elevation_angle == 0.0:
return base
horizontal_axis, vertical_axis = get_oriented_transverse_axes(self.polarization)
axes_tuple = (horizontal_axis, vertical_axis, self.polarization)
return rotate_vector(
base,
azimuth_angle=np.deg2rad(self.azimuth_angle),
elevation_angle=np.deg2rad(self.elevation_angle),
axes_tuple=axes_tuple,
)
[docs]
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, electric_conductivity
# inv_permittivities shape: (num_components, Nx, Ny, Nz)
inv_eps_slice = inv_permittivities[:, *self.grid_slice]
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_eps_slice = effective_inv_permittivity(
inv_eps=inv_eps_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,
)
if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0:
inv_mu_slice: jax.Array | float = inv_permeabilities[:, *self.grid_slice]
else:
inv_mu_slice = inv_permeabilities
inv_eps_oriented = _contract_orientation(inv_eps_slice, self._orientation)
inv_mu_oriented = _contract_orientation(inv_mu_slice, self._orientation)
self = self.aset("_inv_eps_local", inv_eps_slice, create_new_ok=True)
self = self.aset("_inv_mu_local", inv_mu_slice, create_new_ok=True)
self = self.aset("_inv_eps_oriented", inv_eps_oriented, create_new_ok=True)
self = self.aset("_inv_mu_oriented", inv_mu_oriented, create_new_ok=True)
return self
[docs]
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
if self.source_type != "electric":
return E
dt = self._config.time_step_duration
c = self._config.courant_number
amplitude = self.temporal_profile.get_amplitude(
time=time_step * dt,
period=self.wave_character.get_period(),
phase_shift=self.wave_character.phase_shift,
)
sign = -1.0 if not inverse else 1.0
if isinstance(self._inv_eps_oriented, Null):
inv_eps_source = inv_permittivities[:, *self.grid_slice]
inv_eps_oriented = _contract_orientation(inv_eps_source, self._orientation)
else:
inv_eps_source = self._inv_eps_local
inv_eps_oriented = self._inv_eps_oriented
scale = c * self.amplitude * self.static_amplitude_factor * amplitude
if _axis_aligned_diagonal_injection(inv_eps_source, self.azimuth_angle, self.elevation_angle):
injection = scale * inv_eps_oriented[self.polarization]
E = E.at[self.polarization, *self.grid_slice].add(sign * injection.astype(E.dtype))
else:
for axis in range(3):
injection = scale * inv_eps_oriented[axis]
E = E.at[axis, *self.grid_slice].add(sign * injection.astype(E.dtype))
return E
[docs]
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
if self.source_type != "magnetic":
return H
dt = self._config.time_step_duration
c = self._config.courant_number
amplitude = self.temporal_profile.get_amplitude(
time=time_step * dt,
period=self.wave_character.get_period(),
phase_shift=self.wave_character.phase_shift,
)
sign = -1.0 if not inverse else 1.0
if isinstance(self._inv_mu_oriented, Null):
inv_mu_source: jax.Array | float = inv_permeabilities
if isinstance(inv_permeabilities, jax.Array) and inv_permeabilities.ndim > 0:
inv_mu_source = inv_permeabilities[:, *self.grid_slice]
inv_mu_oriented = _contract_orientation(inv_mu_source, self._orientation)
else:
inv_mu_source = self._inv_mu_local
inv_mu_oriented = self._inv_mu_oriented
scale = c * self.amplitude * self.static_amplitude_factor * amplitude
if _axis_aligned_diagonal_injection(inv_mu_source, self.azimuth_angle, self.elevation_angle):
injection = scale * inv_mu_oriented[self.polarization]
H = H.at[self.polarization, *self.grid_slice].add(sign * injection.astype(H.dtype))
else:
for axis in range(3):
injection = scale * inv_mu_oriented[axis]
H = H.at[axis, *self.grid_slice].add(sign * injection.astype(H.dtype))
return H