"""Automatic simulation setup: grid, PML, boundaries, and budget checks.
The goal is one function that turns ``(wavelength, domain, physics hints)``
into a trustworthy :class:`~fdtdx.config.SimulationConfig` with the same
"behind the scenes" defaults Tidy3D provides:
* resolution from points-per-wavelength (or an explicit target ``dl``)
* per-axis cell counts adjusted to integers — Tidy3D adjusts ``dl`` per axis
so the domain is covered by an integer number of cells; FDTDX mirrors that
with a :class:`~fdtdx.core.grid.QuasiUniformGrid`
* PML thickness in cells, uniform by default, per-face overridable
* a voxel-budget check with a clear error instead of an OOM crash
The trust argument: if one automatic function reproduces every golden in the
benchmark suite, its defaults are trustworthy. See ``benchmarks/`` and the
parity runners, which use these helpers.
"""
from __future__ import annotations
import math
import warnings
from collections.abc import Sequence
from itertools import pairwise
from typing import TYPE_CHECKING, Literal
import jax.numpy as jnp
import numpy as np
from fdtdx.config import SimulationConfig
from fdtdx.constants import c, eps0
from fdtdx.core.grid import QuasiUniformGrid, RectilinearGrid
if TYPE_CHECKING:
from fdtdx.objects.boundaries.initialization import BoundaryConfig
#: Default voxel budget (matches the benchmark hardware: RTX 3080 12GB).
DEFAULT_MAX_VOXELS = 40_000_000
#: Default points per wavelength for the auto resolution.
DEFAULT_PPW = 20.0
#: Default PML thickness in cells.
DEFAULT_PML_LAYERS = 10
BoundaryType = Literal["pml", "periodic", "pec", "pmc", "bloch"]
def snap_half_integer(scaled: float) -> int:
"""Ceil ``scaled`` — the cell-count rule Tidy3D uses.
Tidy3D covers the domain with ``ceil(size / dl)`` cells and adjusts the
per-axis spacing to ``size / n`` (verified empirically: 0.22um at 50nm ->
5 cells of 44nm, 2.5um at 40nm -> 63 cells of 39.68nm, 1.5um at 40nm ->
38 cells of 39.47nm). Ceil is monotonic, so float representation error
(e.g. ``2.5um / 40nm = 62.50000000000001``) cannot flip the result. The
relative epsilon (1e-5) absorbs the float32 roundoff of a resolved grid's
spacing — the spacing is derived from float32 edge differences, whose
catastrophic cancellation at large edge magnitudes reaches ~1e-6..1e-5
relative error (e.g. a 100-cell 50nm grid reports 4.999993e-08) — while
leaving genuine non-integer ratios (4.4, 62.5, ...) untouched.
Args:
scaled: The ``length / spacing`` ratio.
Returns:
The integer cell count covering the length.
"""
return math.ceil(scaled * (1.0 - 1e-5))
[docs]
def auto_pml_layers(
physical_thickness: float,
spacing: float | tuple[float, float, float],
) -> int:
"""Choose a uniform PML cell count that preserves physical thickness.
Refining a mesh must not silently make its absorber thinner. For an
anisotropic grid, the smallest spacing controls the uniform layer count,
so every face is at least ``physical_thickness`` thick (up to the same
floating-point guard used by :func:`snap_half_integer`).
"""
if physical_thickness <= 0:
raise ValueError(
f"PML physical thickness must be positive, got {physical_thickness}."
)
spacings = spacing if isinstance(spacing, tuple) else (spacing,)
if any(value <= 0 for value in spacings):
raise ValueError(f"Grid spacings must be positive, got {spacings}.")
return max(1, snap_half_integer(physical_thickness / min(spacings)))
[docs]
def auto_grid(
wavelength: float,
domain_size: tuple[float, float, float],
*,
ppw: float = DEFAULT_PPW,
dl: float | None = None,
max_voxels: int = DEFAULT_MAX_VOXELS,
max_refractive_index: float = 1.0,
min_feature_size: float | None = None,
min_cells_per_feature: float = 3.0,
budget_behavior: Literal["raise", "coarsen"] = "raise",
) -> QuasiUniformGrid:
"""Build a grid for ``domain_size`` at ``wavelength / ppw`` resolution.
Each axis gets an integer cell count (``ceil(length / dl)`` with a small
roundoff guard) and the per-axis spacing is adjusted to
``length / n`` — the same adjustment Tidy3D's uniform grid performs, so a
domain of e.g. 0.22um at 40nm becomes 6 cells of 0.0367um instead of a
fractional 5.5 cells.
Args:
wavelength: Free-space wavelength in metres. Used only when ``dl`` is
not given.
domain_size: Physical ``(Lx, Ly, Lz)`` extent in metres.
ppw: Points per wavelength *inside the highest-index material* when
``max_refractive_index`` is supplied. Defaults to 20.
dl: Explicit target cell width in metres. Overrides ``ppw``.
max_voxels: Voxel budget. Raises when exceeded (instead of an OOM
crash later) unless ``budget_behavior="coarsen"``.
max_refractive_index: Largest refractive index relevant to the scene.
The wavelength-derived target spacing is
``wavelength / (ppw * max_refractive_index)``. Defaults to 1 so
existing free-space behavior is preserved.
min_feature_size: Optional smallest geometrical feature that must be
represented. At least ``min_cells_per_feature`` cells are targeted.
min_cells_per_feature: Target cells across ``min_feature_size``.
Defaults to 3.
budget_behavior: ``"raise"`` preserves the requested accuracy and
fails before allocation. ``"coarsen"`` chooses the finest global
spacing that fits ``max_voxels`` and emits a warning. This explicit
warning is important because global coarsening may under-resolve a
requested material wavelength or feature.
Returns:
A :class:`~fdtdx.core.grid.QuasiUniformGrid` with per-axis spacings
adjusted so every axis has an integer cell count.
Raises:
ValueError: If the voxel count exceeds ``max_voxels``.
"""
if max_refractive_index < 1.0:
raise ValueError(f"max_refractive_index must be >= 1, got {max_refractive_index}.")
if ppw <= 0:
raise ValueError(f"Points per wavelength must be positive, got {ppw}.")
if min_cells_per_feature <= 0:
raise ValueError(f"min_cells_per_feature must be positive, got {min_cells_per_feature}.")
if min_feature_size is not None and min_feature_size <= 0:
raise ValueError(f"min_feature_size must be positive, got {min_feature_size}.")
if budget_behavior not in {"raise", "coarsen"}:
raise ValueError(f"budget_behavior must be 'raise' or 'coarsen', got {budget_behavior!r}.")
if dl is None:
material_dl = wavelength / (ppw * max_refractive_index)
feature_dl = math.inf if min_feature_size is None else min_feature_size / min_cells_per_feature
dl = min(material_dl, feature_dl)
if dl <= 0:
raise ValueError(f"Cell width must be positive, got {dl}.")
counts = [snap_half_integer(length / dl) for length in domain_size]
counts = [max(n, 1) for n in counts]
n_voxels = math.prod(counts)
if n_voxels > max_voxels:
if budget_behavior == "raise":
raise ValueError(
f"Auto grid for domain {domain_size} at dl={dl:.3g} needs {n_voxels:,} voxels "
f"(shape {tuple(counts)}), exceeding the budget of {max_voxels:,}. "
"Decrease ppw (coarser), reduce the domain, use symmetry/local refinement, "
"or raise max_voxels."
)
requested_dl = dl
# Solve the continuous cubic estimate first, then move upward until
# the integer ceil rule also fits the exact budget.
dl = max(dl, (math.prod(domain_size) / max_voxels) ** (1.0 / 3.0))
counts = [max(snap_half_integer(length / dl), 1) for length in domain_size]
while math.prod(counts) > max_voxels:
dl *= 1.001
counts = [max(snap_half_integer(length / dl), 1) for length in domain_size]
warnings.warn(
f"Auto grid coarsened from requested dl={requested_dl:.3g} m to about {dl:.3g} m "
f"to fit {math.prod(counts):,}/{max_voxels:,} voxels. Check wavelength and feature convergence.",
UserWarning,
stacklevel=2,
)
spacings = tuple(length / n for length, n in zip(domain_size, counts, strict=True))
return QuasiUniformGrid(dx=spacings[0], dy=spacings[1], dz=spacings[2])
[docs]
def auto_interface_aligned_grid(
domain_size: tuple[float, float, float],
target_spacing: float | tuple[float, float, float],
*,
interfaces: tuple[Sequence[float], Sequence[float], Sequence[float]] = ((), (), ()),
center: tuple[float, float, float] = (0.0, 0.0, 0.0),
max_voxels: int = DEFAULT_MAX_VOXELS,
) -> RectilinearGrid:
"""Build a rectilinear mesh whose cells end exactly at material interfaces.
Each interval between the domain boundary and supplied interface planes is
divided into the fewest equal cells whose width does not exceed
``target_spacing``. This removes mesh-origin changes in thin films while
keeping the mesh quasi-uniform and the cell count predictable. Interface
coordinates are absolute physical coordinates in the same frame as
``center``.
This policy is particularly useful for layered photonics: pass the slab,
etch, and substrate planes on their normal axis and let subpixel smoothing
handle curved or oblique in-plane boundaries.
"""
if isinstance(target_spacing, tuple):
spacings = target_spacing
else:
spacings = (target_spacing, target_spacing, target_spacing)
if any(length <= 0 for length in domain_size):
raise ValueError(f"Domain sizes must be positive, got {domain_size}.")
if any(spacing <= 0 for spacing in spacings):
raise ValueError(f"Target spacings must be positive, got {spacings}.")
edge_arrays: list[np.ndarray] = []
for axis in range(3):
lower = center[axis] - 0.5 * domain_size[axis]
upper = center[axis] + 0.5 * domain_size[axis]
tolerance = 1e-10 * domain_size[axis]
interior = sorted(float(value) for value in interfaces[axis])
for value in interior:
if not lower + tolerance < value < upper - tolerance:
raise ValueError(
f"Interface {value:.6g} on axis {axis} must lie strictly inside "
f"the domain [{lower:.6g}, {upper:.6g}]."
)
anchors = [lower]
for value in interior:
if value - anchors[-1] > tolerance:
anchors.append(value)
anchors.append(upper)
pieces: list[np.ndarray] = []
for segment_index, (start, stop) in enumerate(pairwise(anchors)):
count = max(1, snap_half_integer((stop - start) / spacings[axis]))
segment = np.linspace(start, stop, count + 1, dtype=np.float64)
if segment_index:
segment = segment[1:]
pieces.append(segment)
edge_arrays.append(np.concatenate(pieces))
shape = tuple(len(edges) - 1 for edges in edge_arrays)
voxels = math.prod(shape)
if voxels > max_voxels:
raise ValueError(
f"Interface-aligned grid needs {voxels:,} voxels (shape {shape}), "
f"exceeding the budget of {max_voxels:,}. Increase target_spacing, "
"reduce the domain, or raise max_voxels."
)
return RectilinearGrid.custom(
x_edges=jnp.asarray(edge_arrays[0]),
y_edges=jnp.asarray(edge_arrays[1]),
z_edges=jnp.asarray(edge_arrays[2]),
)
[docs]
def auto_boundary_config(
pml_layers: int = DEFAULT_PML_LAYERS,
boundary_types: dict[str, BoundaryType] | None = None,
structures: Sequence[object] | None = None,
domain_size: tuple[float, float, float] | None = None,
wavelength: float | None = None,
stabilize_evanescent: bool = False,
pml_alpha_fraction: float = 0.20,
) -> BoundaryConfig:
"""Uniform PML boundary config with per-face overrides and inference.
Defaults to PML on every face — the Tidy3D default. Pass
``boundary_types`` to override individual faces, e.g.
``{"min_x": "periodic", "max_x": "periodic"}``.
When ``structures`` and ``domain_size`` are given, any axis whose extent is
completely filled by a structure is inferred as **PEC** on both faces —
the convention the Tidy3D goldens use (z-extent = slab thickness with the
slab filling it). Explicit ``boundary_types`` always win over inference.
Args:
pml_layers: PML thickness in cells on every PML face.
boundary_types: Per-face boundary type overrides keyed by
``"min_x"/"max_x"/"min_y"/"max_y"/"min_z"/"max_z"``.
structures: Scene objects (e.g. waveguides, slabs) used to infer PEC
faces. Any object whose ``partial_real_shape`` fills an axis of
``domain_size`` marks that axis PEC.
domain_size: Physical ``(Lx, Ly, Lz)`` extent in metres. Required when
``structures`` is given.
wavelength: Reference free-space wavelength. Required when
``stabilize_evanescent`` is enabled.
stabilize_evanescent: Use a complex-frequency-shifted PML suitable for
resonators, waveguides, and photonic-crystal terminations with
strong evanescent or grazing fields. Material crossing a PML must
also be continued with :func:`fdtdx.extend_material_to_pml` after
geometry parameters are applied.
pml_alpha_fraction: CFS strength as a fraction of
``2 pi f epsilon_0`` at the PML interface. The 0.20 default was
selected by a late-time stability sweep of an Ez photonic-crystal
surface cavity; smaller values left a growing PML mode.
Returns:
A :class:`~fdtdx.objects.boundaries.initialization.BoundaryConfig`.
"""
# Imported lazily: the boundaries package imports from the fdtdx root, so a
# module-level import here would create a circular import.
from fdtdx.objects.boundaries.initialization import BoundaryConfig
inferred: dict[str, BoundaryType] = {}
if structures is not None:
if domain_size is None:
raise ValueError("domain_size is required when structures is given.")
# The SimulationVolume is the domain itself — it always fills every
# axis and must not trigger PEC inference.
from fdtdx.objects.static_material.static import SimulationVolume
for obj in structures:
if isinstance(obj, SimulationVolume):
continue
shape = getattr(obj, "partial_real_shape", None)
if shape is None:
continue
for axis, length in enumerate(domain_size):
obj_len = shape[axis]
if obj_len is not None and abs(obj_len - length) <= 1e-9 * max(1.0, length):
face_min, face_max = ("min_x", "max_x", "min_y", "max_y", "min_z", "max_z")[2 * axis : 2 * axis + 2]
inferred.setdefault(face_min, "pec")
inferred.setdefault(face_max, "pec")
merged = dict(inferred)
if boundary_types:
merged.update(boundary_types)
if pml_alpha_fraction <= 0:
raise ValueError("pml_alpha_fraction must be positive")
if stabilize_evanescent and (wavelength is None or wavelength <= 0):
raise ValueError("a positive wavelength is required to stabilize evanescent PML fields")
alpha_start = None
alpha_end = None
if stabilize_evanescent:
assert wavelength is not None
alpha_start = pml_alpha_fraction * 2.0 * np.pi * c / wavelength * eps0
alpha_end = 0.0
return BoundaryConfig.from_uniform_bound(
thickness=pml_layers,
boundary_type="pml",
override_types=merged,
alpha_start=alpha_start,
alpha_end=alpha_end,
)
[docs]
def auto_config(
wavelength: float,
domain_size: tuple[float, float, float],
time: float,
*,
ppw: float = DEFAULT_PPW,
dl: float | None = None,
max_voxels: int = DEFAULT_MAX_VOXELS,
max_refractive_index: float = 1.0,
min_feature_size: float | None = None,
min_cells_per_feature: float = 3.0,
budget_behavior: Literal["raise", "coarsen"] = "raise",
dtype: jnp.dtype = jnp.float32,
courant_factor: float = 0.99,
) -> SimulationConfig:
"""One-call simulation config: auto grid + defaults.
Args:
wavelength: Free-space wavelength in metres (resolution basis).
domain_size: Physical ``(Lx, Ly, Lz)`` extent in metres.
time: Total simulation time in seconds.
ppw: Points per wavelength. Defaults to 20.
dl: Explicit target cell width in metres. Overrides ``ppw``.
max_voxels: Voxel budget (see :func:`auto_grid`).
max_refractive_index: Highest scene refractive index used to resolve
wavelength in material.
min_feature_size: Optional smallest geometry feature to resolve.
min_cells_per_feature: Target cells across the smallest feature.
budget_behavior: Whether an over-budget request raises or explicitly
coarsens with a warning.
dtype: Field dtype. Defaults to float32.
courant_factor: Courant safety factor. Defaults to 0.99.
Returns:
A :class:`~fdtdx.config.SimulationConfig` ready for
:func:`fdtdx.place_objects`. Pair with :func:`auto_boundary_config`
for the boundary objects.
"""
grid = auto_grid(
wavelength=wavelength,
domain_size=domain_size,
ppw=ppw,
dl=dl,
max_voxels=max_voxels,
max_refractive_index=max_refractive_index,
min_feature_size=min_feature_size,
min_cells_per_feature=min_cells_per_feature,
budget_behavior=budget_behavior,
)
return SimulationConfig(
grid=grid,
time=time,
dtype=dtype,
courant_factor=courant_factor,
)