Source code for fdtdx.objects.boundaries.bloch
import functools
from typing import cast
import jax
import jax.numpy as jnp
from typing_extensions import override
from fdtdx.colors import XKCD_WARM_PURPLE, Color
from fdtdx.core.jax.pytrees import autoinit, frozen_field
from fdtdx.objects.boundaries.boundary import BaseBoundary
[docs]
@autoinit
class BlochBoundary(BaseBoundary):
"""Implements Bloch periodic boundary conditions.
Generalizes periodic boundary conditions with a phase shift:
F(x + L) = F(x) * exp(i * k_bloch * L)
When the Bloch vector is zero, this is equivalent to a standard periodic
boundary. Complex-valued field arrays are only required when the Bloch
vector has non-zero components.
"""
#: Bloch wave vector components (k_x, k_y, k_z) in units of rad/m.
bloch_vector: tuple[float, float, float] = frozen_field(default=(0.0, 0.0, 0.0))
#: RGB color tuple for visualization. Defaults to warm purple.
color: Color | None = frozen_field(default=XKCD_WARM_PURPLE)
@property
def needs_complex_fields(self) -> bool:
"""Whether this boundary requires complex-valued fields.
Only True when the Bloch vector component along this boundary's axis
is non-zero.
"""
return self.bloch_vector[self.axis] != 0.0
@property
@override
def descriptive_name(self) -> str:
"""Gets a human-readable name describing this Bloch boundary's location."""
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 uses_wrap_padding(self) -> bool:
"""Bloch boundaries use wrap padding (with Bloch phase correction applied separately)."""
return True
@property
@override
def thickness(self) -> int:
"""Gets the thickness of the Bloch boundary layer in grid points (always 1)."""
return 1
[docs]
@override
def apply_pad_correction(
self, padded_fields: jax.Array, volume_shape: tuple[int, int, int], resolution: float
) -> jax.Array:
"""Apply Bloch phase shift to ghost cells of padded fields.
For the '-' direction boundary: left ghost cell (index 0 on padded axis)
is multiplied by conj(phase).
For the '+' direction boundary: right ghost cell (index -1 on padded axis)
is multiplied by phase.
Args:
padded_fields: Padded field array of shape (3, Nx+2, Ny+2, Nz+2)
volume_shape: Full simulation volume shape (Nx, Ny, Nz)
resolution: Grid resolution in meters
Returns:
Padded fields with Bloch phase corrections applied
"""
if not self.needs_complex_fields:
return padded_fields
phase = self.get_bloch_phase(volume_shape, resolution)
# padded axis index is self.axis + 1 (field arrays have leading component dim)
ax = self.axis + 1
if self.direction == "-":
# Left ghost wraps from the right end: multiply by conj(phase)
idx = cast(list[slice | int], [slice(None)] * padded_fields.ndim)
idx[ax] = 0
idx_tuple = tuple(idx)
padded_fields = padded_fields.at[idx_tuple].set(padded_fields[idx_tuple] * jnp.conj(phase))
else:
# Right ghost wraps from the left end: multiply by phase
idx = cast(list[slice | int], [slice(None)] * padded_fields.ndim)
idx[ax] = -1
idx_tuple = tuple(idx)
padded_fields = padded_fields.at[idx_tuple].set(padded_fields[idx_tuple] * phase)
return padded_fields
[docs]
@override
def apply_field_reset(self, fields: dict[str, jax.Array]) -> dict[str, jax.Array]:
"""Copy field values from this boundary face to maintain periodicity."""
result = {}
for name, field in fields.items():
field_values = field[..., *self.boundary_slice]
result[name] = field.at[..., *self.grid_slice].set(field_values)
return result
@functools.cached_property
def boundary_slice(self) -> tuple[slice, ...]:
"""Get the slice for the current boundary."""
boundary_slice = list(self.grid_slice)
if self.direction == "+":
boundary_slice[self.axis] = slice(
self._grid_slice_tuple[self.axis][0], self._grid_slice_tuple[self.axis][0] + 1
)
else:
boundary_slice[self.axis] = slice(
self._grid_slice_tuple[self.axis][1] - 1, self._grid_slice_tuple[self.axis][1]
)
return tuple(boundary_slice)
@functools.cached_property
def opposite_slice(self) -> tuple[slice, ...]:
"""Get the slice for the opposite boundary."""
opposite_slice = list(self.grid_slice)
if self.direction == "+":
opposite_slice[self.axis] = slice(
self._grid_slice_tuple[self.axis][1] - 1, self._grid_slice_tuple[self.axis][1]
)
else:
opposite_slice[self.axis] = slice(
self._grid_slice_tuple[self.axis][0], self._grid_slice_tuple[self.axis][0] + 1
)
return tuple(opposite_slice)
[docs]
def get_bloch_phase(self, volume_shape: tuple[int, int, int], resolution: float) -> jax.Array:
"""Compute the complex phase factor exp(i * k_bloch * L) for this axis.
The right ghost cell (wrapping from the left side) is multiplied by this phase.
The left ghost cell (wrapping from the right side) is multiplied by the conjugate.
Args:
volume_shape: Full simulation volume shape (Nx, Ny, Nz)
resolution: Grid resolution in meters
Returns:
Complex scalar exp(i * k_axis * L) where L = volume_shape[axis] * resolution
"""
k = self.bloch_vector[self.axis]
grid = getattr(self._config, "resolved_grid", None)
if grid is not None:
edges = grid.edges(self.axis)
L = edges[volume_shape[self.axis]] - edges[0]
else:
L = volume_shape[self.axis] * resolution
return jnp.exp(1j * k * L)