Source code for fdtdx.objects.static_material.static

from abc import ABC, abstractmethod

import jax
import jax.numpy as jnp
import numpy as np

from fdtdx.colors import XKCD_LIGHT_GREY, Color
from fdtdx.core.jax.pytrees import autoinit, field, frozen_field
from fdtdx.materials import Material
from fdtdx.objects.object import OrderableObject


def _normal_from_fill_fraction(obj: OrderableObject, fill_fraction: jax.Array) -> jax.Array:
    """Return a metric-aware unit normal from a static object's fill fraction."""

    frac = np.asarray(fill_fraction, dtype=float)
    grid_shape = obj.grid_shape
    real_shape = obj.real_shape
    pitch = [(real_shape[i] / grid_shape[i]) if grid_shape[i] > 0 else 1.0 for i in range(3)]
    grad = np.zeros((3, *frac.shape), dtype=float)
    for ax in range(3):
        if frac.shape[ax] > 1 and pitch[ax] > 0:
            grad[ax] = np.gradient(frac, pitch[ax], axis=ax)
    grad = -grad
    norm = np.sqrt(np.sum(grad**2, axis=0))
    safe = norm > 1e-12
    normal = np.zeros_like(grad)
    for ax in range(3):
        normal[ax] = np.where(safe, grad[ax] / np.where(safe, norm, 1.0), 0.0)
    return jnp.asarray(normal)


[docs] @autoinit class UniformMaterialObject(OrderableObject): #: the material object material: Material = field() #: the color object color: Color | None = frozen_field(default=XKCD_LIGHT_GREY) #: Resolve the physical faces of this axis-aligned box with analytic cell #: overlap instead of filling its integer placement slice. This preserves #: requested thicknesses and center offsets below one Yee cell and enables #: the Farjadpour arithmetic/harmonic interface average used by the other #: static geometry primitives. subpixel_smoothing: bool = frozen_field(default=False) #: Store the complete 3x3 Farjadpour tensor. Axis-aligned box faces are #: represented exactly by the cheaper three-component diagonal path, so #: this is normally unnecessary except at deliberately resolved corners. subpixel_full_tensor: bool = frozen_field(default=False) def get_fill_fraction_for_shape(self) -> jax.Array: """Return the exact volume overlap of the requested box and every cell. ``grid_slice`` is only the integer allocation envelope. The actual box remains centered at ``partial_real_position`` (when supplied), with side lengths from ``partial_real_shape``. Axes whose physical length is constraint-derived (``None``) fill their complete placed extent. """ grid = self._config.resolved_grid if grid is None: raise ValueError("UniformMaterialObject must be placed before computing its fill fraction.") axis_fractions: list[np.ndarray] = [] for axis in range(3): lower, upper = self.grid_slice_tuple[axis] edges = np.asarray(grid.edges(axis), dtype=float)[lower : upper + 1] widths = np.diff(edges) requested_length = self.partial_real_shape[axis] if requested_length is None: axis_fractions.append(np.ones(widths.shape, dtype=float)) continue local_edges = edges - edges[0] center = self.local_geometry_center(axis) geometry_lower = center - 0.5 * float(requested_length) geometry_upper = center + 0.5 * float(requested_length) overlap = np.maximum( 0.0, np.minimum(local_edges[1:], geometry_upper) - np.maximum(local_edges[:-1], geometry_lower), ) axis_fractions.append(np.clip(overlap / widths, 0.0, 1.0)) fill = ( axis_fractions[0][:, None, None] * axis_fractions[1][None, :, None] * axis_fractions[2][None, None, :] ) return jnp.asarray(fill) def get_interface_normal_for_shape(self) -> jax.Array: """Return unit interface normals derived from the analytic box fill.""" return _normal_from_fill_fraction(self, self.get_fill_fraction_for_shape())
@autoinit class StaticMultiMaterialObject(OrderableObject, ABC): #: the static material materials: dict[str, Material] = field() #: the color of the material color: Color | None = frozen_field(default=XKCD_LIGHT_GREY) #: Enable sub-pixel (sub-cell) dielectric smoothing for this object. When ``True`` the assembler #: replaces the binary voxel occupancy with an analytic fill-fraction and builds a smoothed, #: anisotropic (full 3x3 tensor) effective permittivity at interface cells following Farjadpour et #: al. (Meep): arithmetic mean of ``eps`` for the field components tangential to the interface and #: harmonic mean of ``eps`` for the component normal to it. This removes the first-order staircasing #: error of the Yee grid at strong dielectric jumps (2nd-order accuracy). Forces the whole #: simulation to allocate an anisotropic permittivity tensor (3-component diagonal by default, or a #: full 9-component tensor when ``subpixel_full_tensor`` is set). Requires the object to provide a #: fractional ``get_fill_fraction_for_shape`` (the default falls back to the binary mask, which #: still yields a valid but only cell-wide normal). See issue #373. subpixel_smoothing: bool = frozen_field(default=False) #: Selects the smoothing tensor representation when ``subpixel_smoothing`` is on. ``False`` (default) #: keeps only the DIAGONAL of the Farjadpour tensor (``eps_ii = eps_bar - (eps_bar - eps_h)*n_i**2``), #: allocating a cheap 3-component array that runs on the elementwise Yee update. This is EXACT for #: axis-aligned interfaces (their normal lies on one axis, so the off-diagonal terms vanish) and is the #: recommended production path for Manhattan geometries. ``True`` allocates the full 9-component tensor #: (keeps the off-diagonal ``-(eps_bar - eps_h)*n_i*n_j`` terms), which is more accurate for tilted #: interfaces (slanted sidewalls, diagonal edges) but ~3x heavier per step and forces the anisotropic #: update kernel. Ignored when ``subpixel_smoothing`` is False. subpixel_full_tensor: bool = frozen_field(default=False) @abstractmethod def get_voxel_mask_for_shape(self) -> jax.Array: """Get a binary mask of the objects shape. Everything voxel not in the mask, will not be updated by this object. For example, can be used to approximate a round shape. The mask is calculated in device voxel size, not in simulation voxels. Returns: jax.Array: Binary mask representing the voxels occupied by the object """ raise NotImplementedError() @abstractmethod def get_material_mapping( self, ) -> jax.Array: """Returns an array, which represents the material index at every voxel. Specifically, it returns the index of the ordered material list. Returns: jax.Array: Index array """ raise NotImplementedError() def get_fill_fraction_for_shape(self) -> jax.Array: """Return the per-cell fill fraction of the object's material, in ``[0, 1]``. This is the sub-pixel generalisation of :meth:`get_voxel_mask_for_shape`: interior cells return ``1.0``, exterior cells ``0.0`` and interface cells the fraction of the cell volume covered by the object. The default implementation falls back to the binary mask cast to float, so a subclass that does not compute a genuine fill fraction still behaves correctly (albeit without the sub-pixel accuracy gain). Subclasses that can rasterise fractionally should override this. Returns: jax.Array: Float array of shape ``self.grid_shape`` with values in ``[0, 1]``. """ return self.get_voxel_mask_for_shape().astype(float) def _axis_fill_fraction_for_shape(self, axis: int) -> np.ndarray: """Return exact per-cell overlap with a finite extrusion interval. The placed grid slice is an integer allocation envelope and may include exterior halo cells for the interface-normal stencil. When the primitive declares a finite metric length on ``axis``, those allocation cells must not become material merely because they lie inside the slice. A constraint-defined extent (``partial_real_shape[axis] is None``) intentionally fills the complete placed interval. """ requested_length = self.partial_real_shape[axis] if requested_length is None: return np.ones((self.grid_shape[axis],), dtype=float) grid = self._config.resolved_grid if grid is None: spacing = self._config.uniform_spacing() edges = np.arange(self.grid_shape[axis] + 1, dtype=float) * spacing else: lower, upper = self.grid_slice_tuple[axis] all_edges = np.asarray(grid.edges(axis), dtype=float) edges = all_edges[lower : upper + 1] - all_edges[lower] widths = np.diff(edges) center = self.local_geometry_center(axis) geometry_lower = center - 0.5 * float(requested_length) geometry_upper = center + 0.5 * float(requested_length) overlap = np.maximum( 0.0, np.minimum(edges[1:], geometry_upper) - np.maximum(edges[:-1], geometry_lower), ) return np.clip(overlap / widths, 0.0, 1.0) def get_interface_normal_for_shape(self) -> jax.Array: """Return a per-cell unit interface normal derived from the fill-fraction gradient. The normal is ``n = -grad(fill) / |grad(fill)|`` (the sign is irrelevant downstream because only the symmetric outer product ``n ⊗ n`` is used). The gradient is taken with the object's physical cell pitch on each axis, so the direction is geometrically correct on anisotropic grids. Cells away from an interface (``|grad(fill)| ~ 0``) get a zero normal, which makes the smoothed tensor collapse back to the isotropic bulk value. Computed in NumPy at initialisation (static geometry, not a traced quantity). Returns: jax.Array: Float array of shape ``(3, *self.grid_shape)`` with the per-cell unit normal. """ return _normal_from_fill_fraction(self, self.get_fill_fraction_for_shape())
[docs] @autoinit class SimulationVolume(UniformMaterialObject): """Background material for the entire simulation volume. Defines the default material properties for the simulation background. Usually represents air/vacuum with εᵣ=1.0 and μᵣ=1.0. """ #: an integer values of the placement order placement_order: int = frozen_field(default=-1000) #: the static material material: Material = field( default=Material( permittivity=(1.0, 1.0, 1.0), permeability=(1.0, 1.0, 1.0), ), )