Source code for fdtdx.inverse_design

"""Reusable topology-optimization transforms and reverse-tape planning.

The functions in this module are deliberately independent of a particular
photonic device.  They provide the common mechanics needed by density-based
inverse design: minimum-feature filtering, projection continuation,
erosion/dilation penalties, deterministic hard binarization, and an automatic
choice of reversible-recorder storage that respects the accelerator budget.
"""

from __future__ import annotations

import math
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from functools import reduce
from operator import mul
from typing import Any, Literal, Protocol

import jax
import jax.numpy as jnp
import numpy as np
import optax
from jax.scipy.signal import convolve2d

from fdtdx.constants import c
from fdtdx.interfaces.modules import DtypeConversion
from fdtdx.interfaces.recorder import Recorder
from fdtdx.interfaces.time_filter import LinearReconstructEveryK
from fdtdx.typing import BackendOption


@dataclass(frozen=True)
class RecorderMemoryPlan:
    """Auditable storage decision for a reversible FDTD boundary tape."""

    raw_bytes: int
    estimated_bytes: int
    budget_bytes: int
    device_limit_bytes: int
    storage_dtype: str
    temporal_stride: int
    latent_steps: int

    @property
    def compression_ratio(self) -> float:
        return self.raw_bytes / max(self.estimated_bytes, 1)


[docs] @dataclass(frozen=True) class TopologyOptimizerConfig: """Device-independent bounded Adam continuation policy.""" iterations: int continuation_steps: int | None = None learning_rate: float = 0.1 final_learning_rate_fraction: float = 0.1 beta_start: float = 1.0 beta_end: float = 50.0 lower_bound: float = 0.0 upper_bound: float = 1.0 maximize: bool = True early_stop_patience: int | None = None early_stop_min_delta: float = 1e-4 algorithm: Literal["adam", "sgd", "lbfgs"] = "adam" momentum: float = 0.9 lbfgs_memory_size: int = 10
@dataclass(frozen=True) class TopologyStepRecord: """Small host-side diagnostic record for one objective evaluation.""" step: int objective: float beta: float gradient_norm: float | None accepted: bool
[docs] @dataclass(frozen=True) class TopologyOptimizationResult: """Parameters and diagnostics returned by :func:`optimize_topology`.""" final_parameters: np.ndarray best_parameters: np.ndarray best_objective: float best_step: int records: tuple[TopologyStepRecord, ...]
@dataclass(frozen=True) class SafeguardedLBFGSDirection: """A limited-memory ascent proposal with auditable curvature health. ``relative_predicted_ascent`` compares the selected direction with a diagonally scaled steepest-ascent direction after both are normalized to the same peak coordinate motion. This is the normalization used by the bounded trust steps in the inverse-design campaigns, so the comparison is invariant to arbitrary L-BFGS scaling. """ direction: np.ndarray method: str retained_step_history: np.ndarray retained_gradient_difference_history: np.ndarray pruned_pairs: int predicted_ascent: float reference_ascent: float relative_predicted_ascent: float def safeguarded_lbfgs_ascent_direction( gradient: np.ndarray, step_history: np.ndarray, gradient_difference_history: np.ndarray, *, diagonal_scale: np.ndarray | None = None, trainable_mask: np.ndarray | None = None, parameters: np.ndarray | None = None, lower_bounds: np.ndarray | None = None, upper_bounds: np.ndarray | None = None, minimum_relative_ascent: float = 0.1, minimum_secant_cosine: float | None = None, ) -> SafeguardedLBFGSDirection: """Return a curvature-accelerated direction only while it is healthy. The routine is deliberately objective- and architecture-independent. It filters numerically unreliable secant pairs, tests the full L-BFGS model, and then removes the oldest pairs until the peak-normalized predicted ascent is a useful fraction of current-gradient ascent. If no suffix is healthy, it returns the current diagonally scaled gradient. All work is host-side linear algebra; no objective or gradient evaluation is added. ``gradient_difference_history`` follows the maximization convention ``g_previous - g_current``, so the usual minimization L-BFGS recursion is applied to ``-gradient``. Box bounds are optional. When supplied, an outward direction on an active bound is removed before health is scored. """ gradient64 = np.asarray(gradient, dtype=np.float64) if gradient64.ndim != 1: raise ValueError("gradient must be one-dimensional") steps = np.asarray(step_history, dtype=np.float64) changes = np.asarray(gradient_difference_history, dtype=np.float64) if steps.ndim != 2 or changes.ndim != 2 or steps.shape != changes.shape: raise ValueError("L-BFGS histories must be equally shaped matrices") if steps.shape[1] != gradient64.size: raise ValueError("L-BFGS history width must match the gradient") if not 0.0 < minimum_relative_ascent <= 1.0: raise ValueError("minimum_relative_ascent must lie in (0, 1]") if diagonal_scale is None: scale = np.ones_like(gradient64) else: scale = np.asarray(diagonal_scale, dtype=np.float64) if scale.shape != gradient64.shape: raise ValueError("diagonal_scale must match the gradient") if not np.all(np.isfinite(scale)) or np.any(scale <= 0.0): raise ValueError("diagonal_scale must be finite and positive") if trainable_mask is None: mask = np.ones_like(gradient64) else: mask = np.asarray(trainable_mask, dtype=np.float64) if mask.shape != gradient64.shape: raise ValueError("trainable_mask must match the gradient") bounded = parameters is not None or lower_bounds is not None or upper_bounds is not None if bounded and (parameters is None or lower_bounds is None or upper_bounds is None): raise ValueError("parameters and both bounds must be supplied together") if bounded: current = np.asarray(parameters, dtype=np.float64) lower = np.asarray(lower_bounds, dtype=np.float64) upper = np.asarray(upper_bounds, dtype=np.float64) if current.shape != gradient64.shape or lower.shape != gradient64.shape or upper.shape != gradient64.shape: raise ValueError("parameters and bounds must match the gradient") secant_floor = ( float(np.sqrt(np.finfo(np.float32).eps)) if minimum_secant_cosine is None else float(minimum_secant_cosine) ) if not 0.0 <= secant_floor < 1.0: raise ValueError("minimum_secant_cosine must lie in [0, 1)") tiny = np.finfo(np.float64).tiny def project(direction: np.ndarray) -> np.ndarray: projected = np.asarray(direction, dtype=np.float64) * mask if bounded: projected = projected.copy() projected[(current <= lower) & (projected < 0.0)] = 0.0 projected[(current >= upper) & (projected > 0.0)] = 0.0 return projected def normalized_ascent(direction: np.ndarray) -> tuple[np.ndarray, float]: projected = project(direction) peak = float(np.max(np.abs(projected), initial=0.0)) if not np.isfinite(peak) or peak <= tiny: return projected, 0.0 normalized = projected / peak return projected, float(np.vdot(gradient64, normalized).real) fallback, reference_ascent = normalized_ascent(gradient64 * scale) empty = np.empty((0, gradient64.size), dtype=np.float64) if not np.isfinite(reference_ascent) or reference_ascent <= tiny: return SafeguardedLBFGSDirection( direction=fallback, method="projected_scaled_gradient_stationary", retained_step_history=empty, retained_gradient_difference_history=empty.copy(), pruned_pairs=int(steps.shape[0]), predicted_ascent=float(reference_ascent), reference_ascent=float(reference_ascent), relative_predicted_ascent=1.0, ) def valid_pairs(start: int) -> tuple[np.ndarray, np.ndarray]: kept_steps: list[np.ndarray] = [] kept_changes: list[np.ndarray] = [] for step, change in zip(steps[start:], changes[start:], strict=True): curvature = float(np.dot(step, change)) norm_product = float(np.linalg.norm(step) * np.linalg.norm(change)) if ( np.all(np.isfinite(step)) and np.all(np.isfinite(change)) and curvature > secant_floor * max(norm_product, tiny) ): kept_steps.append(step) kept_changes.append(change) if not kept_steps: return empty, empty.copy() return np.stack(kept_steps), np.stack(kept_changes) def two_loop(pair_steps: np.ndarray, pair_changes: np.ndarray) -> np.ndarray: value = -gradient64.copy() alphas: list[float] = [] inverse_curvatures: list[float] = [] for step, change in zip( reversed(pair_steps), reversed(pair_changes), strict=True ): inverse_curvature = 1.0 / float(np.dot(step, change)) inverse_curvatures.append(inverse_curvature) alpha = inverse_curvature * float(np.dot(step, value)) alphas.append(alpha) value -= alpha * change last_step = pair_steps[-1] last_change = pair_changes[-1] value *= float( np.dot(last_step, last_change) / np.dot(last_change, last_change) ) for step, change, inverse_curvature, alpha in zip( pair_steps, pair_changes, reversed(inverse_curvatures), reversed(alphas), strict=True, ): value += step * ( alpha - inverse_curvature * float(np.dot(change, value)) ) return -value pair_count = int(steps.shape[0]) for history_start in range(pair_count): retained_steps, retained_changes = valid_pairs(history_start) if retained_steps.shape[0] == 0: continue candidate, candidate_ascent = normalized_ascent( two_loop(retained_steps, retained_changes) * scale ) relative_ascent = candidate_ascent / reference_ascent if ( np.all(np.isfinite(candidate)) and np.isfinite(relative_ascent) and relative_ascent >= minimum_relative_ascent ): return SafeguardedLBFGSDirection( direction=candidate, method="safeguarded_lbfgs", retained_step_history=retained_steps, retained_gradient_difference_history=retained_changes, pruned_pairs=pair_count - int(retained_steps.shape[0]), predicted_ascent=float(candidate_ascent), reference_ascent=float(reference_ascent), relative_predicted_ascent=float(relative_ascent), ) return SafeguardedLBFGSDirection( direction=fallback, method="scaled_steepest_ascent_curvature_fallback", retained_step_history=empty, retained_gradient_difference_history=empty.copy(), pruned_pairs=pair_count, predicted_ascent=float(reference_ascent), reference_ascent=float(reference_ascent), relative_predicted_ascent=1.0, ) @dataclass(frozen=True) class LowRadiationSubspaceConfig: """Configuration for an optional low-radiation direction projector. The rows supplied to :func:`update_low_radiation_subspace` span the range of a local radiation curvature factor such as ``J.H @ J``. Projecting a design direction out of that range leaves the locally dark/null-space component. In practice the rows can be direct radiation gradients and gradient secants, since a secant is a matrix-free approximation to one radiation-Hessian action. ``enabled=False`` is deliberately the default so existing optimizers are bit-for-bit unaffected until the mechanism is explicitly selected. """ enabled: bool = False maximum_rank: int = 8 relative_singular_value_cutoff: float = 1.0e-5 projection_strength: float = 1.0 def __post_init__(self) -> None: if not isinstance(self.enabled, bool): raise TypeError("enabled must be boolean") if ( not isinstance(self.maximum_rank, int) or isinstance(self.maximum_rank, bool) or self.maximum_rank < 1 ): raise ValueError("maximum_rank must be a positive integer") if ( not np.isfinite(self.relative_singular_value_cutoff) or not 0.0 <= self.relative_singular_value_cutoff < 1.0 ): raise ValueError( "relative_singular_value_cutoff must lie in [0, 1)" ) if ( not np.isfinite(self.projection_strength) or not 0.0 <= self.projection_strength <= 1.0 ): raise ValueError("projection_strength must lie in [0, 1]") @dataclass(frozen=True) class LowRadiationProjection: """Projected proposal and diagnostics for one optimizer step.""" direction: np.ndarray basis: np.ndarray singular_values: np.ndarray rank: int removed_norm_fraction: float sensitive_norm_before: float sensitive_norm_after: float @dataclass(frozen=True) class LinearizedConstraintProjection: """A proposal projected into one linearized feasible half-space.""" direction: np.ndarray active: bool unconstrained_derivative: float constrained_derivative: float required_derivative: float correction_multiplier: float correction_norm_fraction: float def _normalized_finite_rows( rows: np.ndarray | jax.Array | None, *, width: int, ) -> np.ndarray: """Return finite, unit-norm rows without changing their span.""" if rows is None: return np.empty((0, width), dtype=np.float64) values = np.asarray(rows, dtype=np.float64) if values.size == 0: return np.empty((0, width), dtype=np.float64) if values.ndim == 1: values = values[None, :] values = values.reshape((values.shape[0], -1)) if values.shape[1] != width: raise ValueError( f"radiation sensitivity width {values.shape[1]} does not match " f"the design width {width}" ) norms = np.linalg.norm(values, axis=1) threshold = np.finfo(np.float64).eps * math.sqrt(max(width, 1)) keep = np.all(np.isfinite(values), axis=1) & np.isfinite(norms) & (norms > threshold) if not np.any(keep): return np.empty((0, width), dtype=np.float64) return values[keep] / norms[keep, None] def update_low_radiation_subspace( basis: np.ndarray | jax.Array | None, radiation_gradient: np.ndarray | jax.Array, *, previous_radiation_gradient: np.ndarray | jax.Array | None = None, accepted_step: np.ndarray | jax.Array | None = None, config: LowRadiationSubspaceConfig | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Update a compact radiation-sensitive range from gradients and secants. Near a dark design, inverse Q is locally quadratic and ``grad(loss_new) - grad(loss_old) ~= H_rad @ accepted_step``. Accumulating these matrix-free Hessian actions learns ``range(H_rad)`` online; its orthogonal complement is the low-radiation subspace. The current loss gradient is included because it lies in the same range for the local quadratic model and protects Q before the first accepted secant exists. Returns an orthonormal row basis and the retained singular values. No history is changed when the feature is disabled. """ if config is None: config = LowRadiationSubspaceConfig() gradient = np.asarray(radiation_gradient, dtype=np.float64) design_shape = gradient.shape width = gradient.size existing = _normalized_finite_rows(basis, width=width) if not config.enabled: return existing.reshape((existing.shape[0], *design_shape)), np.empty( (0,), dtype=np.float64 ) # ``radiation_gradient`` is one design-space vector even when the design # itself is an image or volume. Flatten it explicitly so a 2-D design is # not misread as a stack of short basis rows. candidates = [ existing, _normalized_finite_rows(gradient.reshape(-1), width=width), ] if (previous_radiation_gradient is None) != (accepted_step is None): raise ValueError( "previous_radiation_gradient and accepted_step must be supplied together" ) if previous_radiation_gradient is not None and accepted_step is not None: previous = np.asarray(previous_radiation_gradient, dtype=np.float64) step = np.asarray(accepted_step, dtype=np.float64) if previous.shape != design_shape or step.shape != design_shape: raise ValueError("radiation-gradient secant arrays must match the current gradient") step_norm = float(np.linalg.norm(step)) if np.isfinite(step_norm) and step_norm > np.finfo(np.float64).eps: secant_action = (gradient - previous) / step_norm candidates.append( _normalized_finite_rows(secant_action.reshape(-1), width=width) ) matrix = np.concatenate(candidates, axis=0) if matrix.shape[0] == 0: return np.empty((0, *design_shape), dtype=np.float64), np.empty( (0,), dtype=np.float64 ) _, singular_values, right = np.linalg.svd(matrix, full_matrices=False) cutoff = config.relative_singular_value_cutoff * singular_values[0] rank = min( config.maximum_rank, int(np.count_nonzero(singular_values > cutoff)), ) retained = right[:rank] return retained.reshape((rank, *design_shape)), singular_values[:rank] def project_low_radiation_direction( direction: np.ndarray | jax.Array, radiation_basis: np.ndarray | jax.Array | None, *, config: LowRadiationSubspaceConfig | None = None, ) -> LowRadiationProjection: """Remove the learned radiation-sensitive component of a proposal. ``radiation_basis`` need not already be orthogonal. It is compressed with an SVD on every call because the intended rank is small (usually 4--12), while the number of design variables may be very large. """ if config is None: config = LowRadiationSubspaceConfig() values = np.asarray(direction, dtype=np.float64) if values.size == 0 or not np.all(np.isfinite(values)): raise ValueError("direction must be a non-empty finite array") flat = values.reshape(-1) rows = _normalized_finite_rows(radiation_basis, width=flat.size) if not config.enabled or rows.shape[0] == 0 or config.projection_strength == 0.0: return LowRadiationProjection( direction=np.asarray(values), basis=rows.reshape((rows.shape[0], *values.shape)), singular_values=np.empty((0,), dtype=np.float64), rank=0, removed_norm_fraction=0.0, sensitive_norm_before=0.0, sensitive_norm_after=0.0, ) _, singular_values, right = np.linalg.svd(rows, full_matrices=False) cutoff = config.relative_singular_value_cutoff * singular_values[0] rank = min( config.maximum_rank, int(np.count_nonzero(singular_values > cutoff)), ) orthonormal = right[:rank] coefficients = orthonormal @ flat sensitive = orthonormal.T @ coefficients projected = flat - config.projection_strength * sensitive projected_coefficients = orthonormal @ projected direction_norm = float(np.linalg.norm(flat)) return LowRadiationProjection( direction=projected.reshape(values.shape), basis=orthonormal.reshape((rank, *values.shape)), singular_values=singular_values[:rank], rank=rank, removed_norm_fraction=float( config.projection_strength * np.linalg.norm(sensitive) / max(direction_norm, np.finfo(np.float64).tiny) ), sensitive_norm_before=float(np.linalg.norm(coefficients)), sensitive_norm_after=float(np.linalg.norm(projected_coefficients)), ) def project_linearized_constraint_direction( direction: np.ndarray | jax.Array, constraint_gradient: np.ndarray | jax.Array, *, metric_constraint_direction: np.ndarray | jax.Array | None = None, minimum_directional_derivative: float = 0.0, minimum_cosine: float = 0.0, ) -> LinearizedConstraintProjection: """Project a proposal into ``grad(constraint) @ direction >= required``. The constraint is assumed to be feasible when it increases. With the defaults this is the closest constant-or-improving direction. A positive ``minimum_cosine`` adds a small first-order feasibility margin so that the linear term can dominate adverse second-order curvature after a finite step. ``metric_constraint_direction`` may be ``H @ grad(constraint)`` for a positive-definite inverse-Hessian or other optimizer metric. Omitting it gives the ordinary Euclidean projection. """ proposal = np.asarray(direction, dtype=np.float64) gradient = np.asarray(constraint_gradient, dtype=np.float64) if proposal.size == 0 or proposal.shape != gradient.shape: raise ValueError("direction and constraint_gradient must have one non-empty shape") if not np.all(np.isfinite(proposal)) or not np.all(np.isfinite(gradient)): raise ValueError("direction and constraint_gradient must be finite") if not np.isfinite(minimum_directional_derivative): raise ValueError("minimum_directional_derivative must be finite") if not np.isfinite(minimum_cosine) or not 0.0 <= minimum_cosine < 1.0: raise ValueError("minimum_cosine must lie in [0, 1)") proposal_flat = proposal.reshape(-1) gradient_flat = gradient.reshape(-1) proposal_norm = float(np.linalg.norm(proposal_flat)) gradient_norm = float(np.linalg.norm(gradient_flat)) threshold = np.finfo(np.float64).eps * math.sqrt(max(proposal_flat.size, 1)) if proposal_norm <= threshold: raise ValueError("direction must have nonzero norm") if gradient_norm <= threshold: raise ValueError("constraint_gradient must have nonzero norm") if metric_constraint_direction is None: metric_flat = gradient_flat else: metric = np.asarray(metric_constraint_direction, dtype=np.float64) if metric.shape != proposal.shape or not np.all(np.isfinite(metric)): raise ValueError("metric_constraint_direction must be finite and shape-matched") metric_flat = metric.reshape(-1) denominator = float(np.vdot(gradient_flat, metric_flat)) if not np.isfinite(denominator) or denominator <= threshold: raise ValueError( "metric_constraint_direction must have positive constraint curvature" ) unconstrained = float(np.vdot(gradient_flat, proposal_flat)) required = max( float(minimum_directional_derivative), float(minimum_cosine * gradient_norm * proposal_norm), ) if unconstrained >= required: return LinearizedConstraintProjection( direction=np.asarray(proposal), active=False, unconstrained_derivative=unconstrained, constrained_derivative=unconstrained, required_derivative=required, correction_multiplier=0.0, correction_norm_fraction=0.0, ) multiplier = (required - unconstrained) / denominator correction = multiplier * metric_flat constrained = proposal_flat + correction return LinearizedConstraintProjection( direction=constrained.reshape(proposal.shape), active=True, unconstrained_derivative=unconstrained, constrained_derivative=float(np.vdot(gradient_flat, constrained)), required_derivative=required, correction_multiplier=float(multiplier), correction_norm_fraction=float( np.linalg.norm(correction) / max(proposal_norm, np.finfo(np.float64).tiny) ), ) DesignActivation = Literal["identity", "sigmoid", "tanh"] class DesignBasis2D(Protocol): """Interface implemented by differentiable two-dimensional design bases. Basis objects are static Python configuration captured by a JAX objective. The optimized value is always the array returned by :attr:`parameter_shape`. ``decode`` maps those parameters to a material density on ``shape`` while ``encode`` provides a host-side initializer from an existing density map. """ shape: tuple[int, int] @property def parameter_shape(self) -> tuple[int, ...]: ... @property def parameter_bounds(self) -> tuple[float, float]: ... def latent(self, parameters: jax.Array) -> jax.Array: ... def decode(self, parameters: jax.Array) -> jax.Array: ... def encode(self, density: jax.Array | np.ndarray) -> jax.Array: ... class DesignBasis1D(Protocol): """Interface for differentiable one-dimensional fabrication bases.""" size: int @property def parameter_shape(self) -> tuple[int, ...]: ... @property def parameter_bounds(self) -> tuple[float, float]: ... def latent(self, parameters: jax.Array) -> jax.Array: ... def decode(self, parameters: jax.Array) -> jax.Array: ... def encode(self, density: jax.Array | np.ndarray) -> jax.Array: ... def _validate_design_shape(shape: tuple[int, int]) -> tuple[int, int]: if len(shape) != 2 or any(not isinstance(value, int) or isinstance(value, bool) or value < 2 for value in shape): raise ValueError("shape must contain two integer dimensions of at least two cells") return tuple(shape) def _validate_design_spacing(spacing: tuple[float, float]) -> tuple[float, float]: if len(spacing) != 2 or any(not np.isfinite(value) or value <= 0 for value in spacing): raise ValueError("spacing must contain two positive finite values") return tuple(float(value) for value in spacing) def _validate_design_size(size: int) -> int: if not isinstance(size, int) or isinstance(size, bool) or size < 2: raise ValueError("size must be an integer of at least two cells") return size def _activate_design_latent(values: jax.Array, activation: DesignActivation) -> jax.Array: if activation == "identity": return values if activation == "sigmoid": return jax.nn.sigmoid(values) if activation == "tanh": return 0.5 + 0.5 * jnp.tanh(values) raise ValueError("activation must be 'identity', 'sigmoid', or 'tanh'") def _inverse_design_activation( density: jax.Array | np.ndarray, activation: DesignActivation, *, epsilon: float = 1e-4, ) -> np.ndarray: values = np.asarray(density, dtype=np.float64) if values.ndim != 2: raise ValueError("density must be a two-dimensional array") if not 0 < epsilon < 0.5: raise ValueError("epsilon must lie strictly between zero and one half") if not np.all(np.isfinite(values)): raise ValueError("density must contain only finite values") if activation == "identity": return values clipped = np.clip(values, epsilon, 1.0 - epsilon) if activation == "sigmoid": return np.log(clipped) - np.log1p(-clipped) if activation == "tanh": return np.arctanh(2.0 * clipped - 1.0) raise ValueError("activation must be 'identity', 'sigmoid', or 'tanh'") def _inverse_design_activation_1d( density: jax.Array | np.ndarray, activation: DesignActivation, *, epsilon: float = 1e-4, ) -> np.ndarray: values = np.asarray(density, dtype=np.float64) if values.ndim != 1: raise ValueError("density must be a one-dimensional array") if not 0 < epsilon < 0.5: raise ValueError("epsilon must lie strictly between zero and one half") if not np.all(np.isfinite(values)): raise ValueError("density must contain only finite values") if activation == "identity": return values clipped = np.clip(values, epsilon, 1.0 - epsilon) if activation == "sigmoid": return np.log(clipped) - np.log1p(-clipped) if activation == "tanh": return np.arctanh(2.0 * clipped - 1.0) raise ValueError("activation must be 'identity', 'sigmoid', or 'tanh'") def _period_is_selected( wave_number: float, *, minimum_period: float | None, maximum_period: float | None, ) -> bool: if wave_number == 0: return True period = 2.0 * math.pi / wave_number if minimum_period is not None and period < minimum_period: return False return maximum_period is None or period <= maximum_period def _validate_period_band(minimum_period: float | None, maximum_period: float | None) -> None: if minimum_period is not None and (not np.isfinite(minimum_period) or minimum_period <= 0): raise ValueError("minimum_period must be positive and finite") if maximum_period is not None and (not np.isfinite(maximum_period) or maximum_period <= 0): raise ValueError("maximum_period must be positive and finite") if minimum_period is not None and maximum_period is not None and minimum_period > maximum_period: raise ValueError("minimum_period must not exceed maximum_period") @dataclass(frozen=True) class PixelBasis2D: """One independent design parameter per in-plane material pixel.""" shape: tuple[int, int] activation: DesignActivation = "identity" def __post_init__(self) -> None: _validate_design_shape(self.shape) _activate_design_latent(jnp.asarray(0.0), self.activation) @property def parameter_shape(self) -> tuple[int, int]: return self.shape @property def parameter_bounds(self) -> tuple[float, float]: return (0.0, 1.0) if self.activation == "identity" else (-math.inf, math.inf) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.shape: raise ValueError(f"pixel parameters must have shape {self.shape}, got {parameters.shape}") return parameters def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation(density, self.activation) if latent.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {latent.shape}") return jnp.asarray(latent, dtype=jnp.float32) @dataclass(frozen=True) class BoundaryTopology: """Component-and-hole signature used to guard boundary evolution.""" material_components: int enclosed_voids: int BoundaryEdge = Literal["min_x", "max_x", "min_y", "max_y"] _ALL_BOUNDARY_EDGES: tuple[BoundaryEdge, ...] = ( "min_x", "max_x", "min_y", "max_y", ) def _validate_exterior_edges( exterior_edges: tuple[BoundaryEdge, ...], ) -> tuple[BoundaryEdge, ...]: edges = tuple(exterior_edges) if len(set(edges)) != len(edges) or any(edge not in _ALL_BOUNDARY_EDGES for edge in edges): raise ValueError( "exterior_edges must contain unique values from " "('min_x', 'max_x', 'min_y', 'max_y')" ) return edges
[docs] def boundary_topology_signature( density: jax.Array | np.ndarray, *, threshold: float = 0.5, diagonal_connectivity: bool = False, exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES, ) -> BoundaryTopology: """Return the material-component and enclosed-void counts of a 2-D mask. Air connected to one of ``exterior_edges`` is exterior and is therefore not counted as a hole. Omit symmetry planes from that tuple: a half-hole touching a mirror plane then remains an enclosed hole after unfolding. This host-side diagnostic is intended for accepted candidate checks, not for differentiation inside a JIT-compiled objective. """ from scipy import ndimage values = np.asarray(density) if values.ndim != 2 or min(values.shape) < 2: raise ValueError("density must be a two-dimensional array at least two cells wide") if not np.all(np.isfinite(values)): raise ValueError("density must contain only finite values") if not 0.0 <= threshold <= 1.0: raise ValueError("threshold must lie in [0, 1]") exterior_edges = _validate_exterior_edges(exterior_edges) connectivity = 2 if diagonal_connectivity else 1 structure = ndimage.generate_binary_structure(2, connectivity) material = values >= threshold _, material_components = ndimage.label(material, structure=structure) void_labels, void_components = ndimage.label(~material, structure=structure) edge_values = { "min_x": void_labels[0, :], "max_x": void_labels[-1, :], "min_y": void_labels[:, 0], "max_y": void_labels[:, -1], } exterior_labels: set[int] = set() for edge in exterior_edges: exterior_labels.update(np.unique(edge_values[edge])) exterior_labels.discard(0) enclosed_voids = void_components - len(exterior_labels) return BoundaryTopology(int(material_components), int(enclosed_voids))
def _topology_region_labels( density: jax.Array | np.ndarray, *, threshold: float, diagonal_connectivity: bool, exterior_edges: tuple[BoundaryEdge, ...], ) -> tuple[np.ndarray, np.ndarray]: """Label material components and non-exterior voids for lineage checks.""" from scipy import ndimage values = np.asarray(density) connectivity = 2 if diagonal_connectivity else 1 structure = ndimage.generate_binary_structure(2, connectivity) material_labels, _ = ndimage.label(values >= threshold, structure=structure) void_labels, _ = ndimage.label(values < threshold, structure=structure) edge_values = { "min_x": void_labels[0, :], "max_x": void_labels[-1, :], "min_y": void_labels[:, 0], "max_y": void_labels[:, -1], } exterior_labels: set[int] = set() for edge in exterior_edges: exterior_labels.update(np.unique(edge_values[edge])) exterior_labels.discard(0) enclosed_labels = np.zeros_like(void_labels) enclosed_index = 0 for label in range(1, int(void_labels.max()) + 1): if label not in exterior_labels: enclosed_index += 1 enclosed_labels[void_labels == label] = enclosed_index return material_labels, enclosed_labels def _labels_have_one_to_one_lineage( reference_labels: np.ndarray, candidate_labels: np.ndarray, ) -> bool: """Return whether every labeled region overlaps exactly one counterpart.""" for source, destination in ( (reference_labels, candidate_labels), (candidate_labels, reference_labels), ): for label in range(1, int(source.max()) + 1): overlaps = np.unique(destination[source == label]) overlaps = overlaps[overlaps > 0] if overlaps.size != 1: return False return True def signed_distance_level_set( density: jax.Array | np.ndarray, *, spacing: tuple[float, float] = (1.0, 1.0), threshold: float = 0.5, ) -> np.ndarray: """Fit a positive-in-material signed-distance field to a density image. The returned level set lives on cell *vertices* and therefore has shape ``(density.shape[0] + 1, density.shape[1] + 1)``. Thresholding a grey topology result before this conversion selects the zero-contour once; subsequent optimization moves that contour instead of changing material values throughout the bulk. """ from scipy import ndimage values = np.asarray(density, dtype=np.float64) if values.ndim != 2 or min(values.shape) < 2: raise ValueError("density must be a two-dimensional array at least two cells wide") _validate_design_spacing(spacing) if not np.all(np.isfinite(values)): raise ValueError("density must contain only finite values") if not 0.0 <= threshold <= 1.0: raise ValueError("threshold must lie in [0, 1]") # Average the four adjacent cell values onto every vertex. Edge padding # extends the design normally rather than introducing a fictitious air rim. padded = np.pad(values, ((1, 1), (1, 1)), mode="edge") vertex_density = 0.25 * ( padded[:-1, :-1] + padded[1:, :-1] + padded[:-1, 1:] + padded[1:, 1:] ) material = vertex_density >= threshold if np.all(material) or not np.any(material): raise ValueError("density must contain at least one material interface") inside_distance = ndimage.distance_transform_edt(material, sampling=spacing) outside_distance = ndimage.distance_transform_edt(~material, sampling=spacing) level_set = np.where(material, inside_distance, -outside_distance) return np.asarray(level_set, dtype=np.float32) def fit_level_set_to_density( density: jax.Array | np.ndarray, *, spacing: tuple[float, float] = (1.0, 1.0), threshold: float = 0.5, supersample: int = 4, interface_width: float | None = None, fit_band_width: float | None = None, max_iterations: int = 200, exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES, ) -> np.ndarray: """Fit a sign-preserving vertex level set to a two-phase density field. A thresholded signed-distance initializer establishes the topology. The magnitudes of vertices near that interface are then fitted to the supplied subpixel density while every vertex sign remains fixed. This preserves the selected topology but places the zero contour much more accurately than a direct cell-to-vertex threshold, which is important for thin photonic features during a density-to-boundary handoff. """ from scipy.optimize import minimize values = np.asarray(density, dtype=np.float32) spacing = _validate_design_spacing(spacing) if values.ndim != 2 or min(values.shape) < 2 or not np.all(np.isfinite(values)): raise ValueError("density must be a finite two-dimensional array") if not isinstance(max_iterations, int) or max_iterations < 1: raise ValueError("max_iterations must be a positive integer") if not isinstance(supersample, int) or supersample < 1: raise ValueError("supersample must be a positive integer") grid_scale = max(spacing) width = min(spacing) / supersample if interface_width is None else interface_width if width <= 0.0: raise ValueError("interface_width must be positive") band = 2.5 * grid_scale if fit_band_width is None else fit_band_width if band <= 0.0: raise ValueError("fit_band_width must be positive") edges = _validate_exterior_edges(exterior_edges) initial = signed_distance_level_set(values, spacing=spacing, threshold=threshold) active = np.abs(initial) <= band active_indices = np.flatnonzero(active) if active_indices.size == 0: return initial base_flat = jnp.asarray(initial).reshape(-1) indices = jnp.asarray(active_indices, dtype=jnp.int32) target = jnp.asarray(values) vertex_shape = initial.shape def loss(active_values: jax.Array) -> jax.Array: level_set = base_flat.at[indices].set(active_values).reshape(vertex_shape) candidate = level_set_fill_fraction( level_set, supersample=supersample, interface_width=width, ) return jnp.mean(jnp.square(candidate - target)) value_and_gradient = jax.jit(jax.value_and_grad(loss)) def objective(active_values: np.ndarray) -> tuple[float, np.ndarray]: value, gradient = value_and_gradient(jnp.asarray(active_values, dtype=jnp.float32)) return float(value), np.asarray(gradient, dtype=np.float64) initial_active = initial.reshape(-1)[active_indices].astype(np.float64) epsilon = max(1.0e-6 * grid_scale, np.finfo(np.float32).eps) magnitude_limit = max(4.0 * grid_scale, band) bounds = [ (epsilon, magnitude_limit) if value >= 0.0 else (-magnitude_limit, -epsilon) for value in initial_active ] result = minimize( objective, initial_active, method="L-BFGS-B", jac=True, bounds=bounds, options={ "maxiter": max_iterations, "ftol": 1.0e-14, "gtol": 1.0e-8, "maxls": 20, }, ) if not np.all(np.isfinite(result.x)): raise RuntimeError("level-set density fit produced nonfinite vertices") fitted = initial.copy().reshape(-1) fitted[active_indices] = result.x fitted = fitted.reshape(initial.shape).astype(np.float32) fitted_density = np.asarray( level_set_fill_fraction( jnp.asarray(fitted), supersample=supersample, interface_width=width, ) ) initial_topology = boundary_topology_signature( values, threshold=threshold, exterior_edges=edges, ) fitted_topology = boundary_topology_signature( fitted_density, threshold=threshold, exterior_edges=edges, ) if fitted_topology != initial_topology: raise RuntimeError("sign-preserving level-set fit changed material topology") return fitted def level_set_fill_fraction( level_set: jax.Array, *, supersample: int = 4, interface_width: float, ) -> jax.Array: """Return a differentiable subpixel fill fraction for a binary boundary. Bilinear interpolation locates the zero contour at a regular set of quadrature points inside each Yee cell. A compact, continuously differentiable Heaviside maps those samples to material coverage. It is exactly zero or one outside ``interface_width`` and therefore introduces no optimizable grey bulk material: fractional values occur only in cells intersected by the geometric interface. Crucially, the same smooth coverage is used by both the forward Maxwell solve and reverse-mode differentiation. This avoids the inconsistent straight-through construction in which an infinitesimal boundary motion had a nonzero derivative but left the permittivity seen by FDTD unchanged. """ level_set = jnp.asarray(level_set) if level_set.ndim != 2 or min(level_set.shape) < 3: raise ValueError("level_set must be a two-dimensional vertex grid at least three points wide") if not isinstance(supersample, int) or isinstance(supersample, bool) or supersample < 1: raise ValueError("supersample must be a positive integer") if interface_width <= 0: raise ValueError("interface_width must be positive") coordinate = (jnp.arange(supersample, dtype=level_set.dtype) + 0.5) / supersample u = coordinate[None, None, :, None] v = coordinate[None, None, None, :] phi00 = level_set[:-1, :-1, None, None] phi10 = level_set[1:, :-1, None, None] phi01 = level_set[:-1, 1:, None, None] phi11 = level_set[1:, 1:, None, None] subcell_level_set = ( (1.0 - u) * (1.0 - v) * phi00 + u * (1.0 - v) * phi10 + (1.0 - u) * v * phi01 + u * v * phi11 ) # C1 compact smoothstep approximation to H(phi). The compact support is # preferable to a sigmoid here: uniform cells remain exactly binary while # interface cells change continuously as the physical contour moves. coordinate = jnp.clip( 0.5 + 0.5 * subcell_level_set / interface_width, 0.0, 1.0, ) material = coordinate * coordinate * (3.0 - 2.0 * coordinate) return jnp.mean(material, axis=(2, 3))
[docs] @dataclass(frozen=True) class LevelSetBoundary2D: """Binary, topology-guarded boundary-normal design parameterization. ``parameters`` are signed physical displacements on the vertex grid. Positive displacement grows material along its outward normal and negative displacement retreats it. A cosine narrow-band window makes the Maxwell derivative exactly zero in uniform bulk regions, so the gradient is a normal boundary sensitivity rather than a pixel-density sensitivity. The basis cannot nucleate a remote component because displacement vanishes outside ``narrow_band_width``. Boundary collision can still merge or erase existing components, so :meth:`preserves_topology` and :meth:`rebase` provide an explicit host-side acceptance guard. """ reference_level_set: np.ndarray spacing: tuple[float, float] = (1.0, 1.0) narrow_band_width: float = 4.0 maximum_displacement: float = 2.0 interface_width: float = 0.25 supersample: int = 4 threshold: float = 0.5 diagonal_connectivity: bool = False exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES def __post_init__(self) -> None: level_set = np.asarray(self.reference_level_set, dtype=np.float32) if level_set.ndim != 2 or min(level_set.shape) < 3: raise ValueError("reference_level_set must be a two-dimensional vertex grid") if not np.all(np.isfinite(level_set)): raise ValueError("reference_level_set must contain only finite values") if not np.any(level_set < 0.0) or not np.any(level_set >= 0.0): raise ValueError("reference_level_set must contain a zero interface") _validate_design_spacing(self.spacing) if self.narrow_band_width <= 0 or self.maximum_displacement <= 0 or self.interface_width <= 0: raise ValueError("boundary widths and maximum displacement must be positive") if self.maximum_displacement > self.narrow_band_width: raise ValueError("maximum_displacement must not exceed narrow_band_width") if not isinstance(self.supersample, int) or isinstance(self.supersample, bool) or self.supersample < 1: raise ValueError("supersample must be a positive integer") if not 0.0 <= self.threshold <= 1.0: raise ValueError("threshold must lie in [0, 1]") object.__setattr__(self, "exterior_edges", _validate_exterior_edges(self.exterior_edges)) frozen_level_set = level_set.copy() frozen_level_set.setflags(write=False) object.__setattr__(self, "reference_level_set", frozen_level_set)
[docs] @classmethod def from_density( cls, density: jax.Array | np.ndarray, *, spacing: tuple[float, float] = (1.0, 1.0), threshold: float = 0.5, narrow_band_width: float | None = None, maximum_displacement: float | None = None, interface_width: float | None = None, supersample: int = 4, diagonal_connectivity: bool = False, exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES, ) -> LevelSetBoundary2D: """Threshold a grey result once and fit its boundary level set.""" spacing = _validate_design_spacing(spacing) grid_scale = max(spacing) narrow_band = 4.0 * grid_scale if narrow_band_width is None else narrow_band_width maximum = 0.5 * narrow_band if maximum_displacement is None else maximum_displacement width = min(spacing) / supersample if interface_width is None else interface_width return cls( reference_level_set=signed_distance_level_set( density, spacing=spacing, threshold=threshold, ), spacing=spacing, narrow_band_width=narrow_band, maximum_displacement=maximum, interface_width=width, supersample=supersample, threshold=threshold, diagonal_connectivity=diagonal_connectivity, exterior_edges=exterior_edges, )
@property def shape(self) -> tuple[int, int]: return ( self.reference_level_set.shape[0] - 1, self.reference_level_set.shape[1] - 1, ) @property def parameter_shape(self) -> tuple[int, int]: return self.reference_level_set.shape @property def parameter_bounds(self) -> tuple[float, float]: return (-self.maximum_displacement, self.maximum_displacement)
[docs] def initial_parameters(self, *, dtype: jnp.dtype = jnp.float32) -> jax.Array: """Return the zero-displacement start for this reference boundary.""" return jnp.zeros(self.parameter_shape, dtype=dtype)
[docs] def boundary_window(self, *, dtype: jnp.dtype = jnp.float32) -> jax.Array: """Cosine window equal to one on the interface and zero in the bulk.""" distance = jnp.abs(jnp.asarray(self.reference_level_set, dtype=dtype)) phase = jnp.clip(distance / self.narrow_band_width, 0.0, 1.0) return jnp.where( distance < self.narrow_band_width, 0.5 * (1.0 + jnp.cos(jnp.pi * phase)), 0.0, )
def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError( f"boundary parameters must have shape {self.parameter_shape}, got {parameters.shape}" ) displacement = jnp.clip( parameters, -self.maximum_displacement, self.maximum_displacement, ) reference = jnp.asarray(self.reference_level_set, dtype=parameters.dtype) return reference + self.boundary_window(dtype=parameters.dtype) * displacement def decode(self, parameters: jax.Array) -> jax.Array: return level_set_fill_fraction( self.latent(parameters), supersample=self.supersample, interface_width=self.interface_width, ) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: """Fit a same-topology binary density as a boundary displacement.""" values = np.asarray(density) if values.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {values.shape}") if not self.preserves_topology(values): raise ValueError("density topology differs from the reference boundary") candidate = signed_distance_level_set( values, spacing=self.spacing, threshold=self.threshold, ) window = np.asarray(self.boundary_window(), dtype=np.float64) displacement = np.zeros_like(candidate, dtype=np.float64) active = window > 0.25 displacement[active] = ( candidate[active] - self.reference_level_set[active] ) / window[active] return jnp.asarray( np.clip( displacement, -self.maximum_displacement, self.maximum_displacement, ), dtype=jnp.float32, )
[docs] def interface_normal(self, parameters: jax.Array) -> jax.Array: """Return the outward material normal on the vertex grid.""" level_set = self.latent(parameters) grad_x, grad_y = jnp.gradient(level_set, self.spacing[0], self.spacing[1]) magnitude = jnp.sqrt(grad_x**2 + grad_y**2 + 1e-24) # The level set is positive in material, hence its gradient points in; # the material-outward normal has the opposite sign. return -jnp.stack((grad_x / magnitude, grad_y / magnitude), axis=0)
@property def topology(self) -> BoundaryTopology: reference_density = np.asarray(self.decode(self.initial_parameters())) return boundary_topology_signature( reference_density, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) def preserves_topology(self, density: jax.Array | np.ndarray) -> bool: candidate = np.asarray(density) if candidate.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {candidate.shape}") if boundary_topology_signature( candidate, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) != self.topology: return False reference = np.asarray(self.decode(self.initial_parameters())) reference_material, reference_voids = _topology_region_labels( reference, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) candidate_material, candidate_voids = _topology_region_labels( candidate, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) return _labels_have_one_to_one_lineage( reference_material, candidate_material, ) and _labels_have_one_to_one_lineage(reference_voids, candidate_voids)
[docs] def rebase( self, parameters: jax.Array | np.ndarray, *, preserve_topology: bool = True, ) -> tuple[LevelSetBoundary2D, jax.Array]: """Accept a moved interface and return a fresh signed-distance stage.""" density = np.asarray(self.decode(jnp.asarray(parameters))) if preserve_topology and not self.preserves_topology(density): raise ValueError("candidate boundary changes the reference topology") rebased = type(self).from_density( density, spacing=self.spacing, threshold=self.threshold, narrow_band_width=self.narrow_band_width, maximum_displacement=self.maximum_displacement, interface_width=self.interface_width, supersample=self.supersample, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) if preserve_topology and rebased.topology != self.topology: raise ValueError("signed-distance rebasing changed the reference topology") return rebased, rebased.initial_parameters()
def _cubic_reflecting_interpolation_matrix( output_size: int, control_size: int, *, dtype: jnp.dtype, ) -> jax.Array: """Return a uniform cubic B-spline matrix with reflected end knots.""" if output_size < 2 or control_size < 4: raise ValueError("cubic interpolation requires two outputs and four controls") coordinate = jnp.linspace(0.0, control_size - 1, output_size, dtype=dtype) interval = jnp.floor(coordinate).astype(jnp.int32) fraction = coordinate - interval.astype(dtype) fraction2 = fraction * fraction fraction3 = fraction2 * fraction weights = ( (1.0 - fraction) ** 3 / 6.0, (3.0 * fraction3 - 6.0 * fraction2 + 4.0) / 6.0, (-3.0 * fraction3 + 3.0 * fraction2 + 3.0 * fraction + 1.0) / 6.0, fraction3 / 6.0, ) period = 2 * (control_size - 1) rows = jnp.arange(output_size, dtype=jnp.int32) matrix = jnp.zeros((output_size, control_size), dtype=dtype) for weight, offset in zip(weights, (-1, 0, 1, 2), strict=True): wrapped = jnp.mod(interval + offset, period) indices = jnp.where(wrapped <= control_size - 1, wrapped, period - wrapped) matrix = matrix.at[rows, indices].add(weight) return matrix def _cubic_reflecting_sampling_matrix( coordinates: jax.Array, control_size: int, *, dtype: jnp.dtype, ) -> jax.Array: """Evaluate a reflecting uniform cubic B-spline at arbitrary coordinates.""" if control_size < 4: raise ValueError("cubic interpolation requires at least four controls") coordinate = jnp.asarray(coordinates, dtype=dtype) if coordinate.ndim != 1: raise ValueError("coordinates must be one-dimensional") interval = jnp.floor(coordinate).astype(jnp.int32) fraction = coordinate - interval.astype(dtype) fraction2 = fraction * fraction fraction3 = fraction2 * fraction weights = ( (1.0 - fraction) ** 3 / 6.0, (3.0 * fraction3 - 6.0 * fraction2 + 4.0) / 6.0, (-3.0 * fraction3 + 3.0 * fraction2 + 3.0 * fraction + 1.0) / 6.0, fraction3 / 6.0, ) period = 2 * (control_size - 1) rows = jnp.arange(coordinate.size, dtype=jnp.int32) matrix = jnp.zeros((coordinate.size, control_size), dtype=dtype) for weight, offset in zip(weights, (-1, 0, 1, 2), strict=True): wrapped = jnp.mod(interval + offset, period) indices = jnp.where(wrapped <= control_size - 1, wrapped, period - wrapped) matrix = matrix.at[rows, indices].add(weight) return matrix @dataclass(frozen=True) class SplinePhaseField2D: """Topology-free smooth material interface with cut-cell FDTD fractions. The optimized values are an *absolute signed level set* on a coarse cubic B-spline control grid, rather than independent material densities. The spline is sampled directly at subcell quadrature points and a compact C1 Heaviside converts its sign to material coverage. Thus the underlying interface is continuous and smooth, uniform cells are exactly zero or one, and only cells cut by the interface retain fractional fill in the sharp limit. Unlike :class:`SplineLevelSetBoundary2D`, this discovery representation has no immutable reference contour or narrow-band window. Any control may cross zero, so components and holes may appear or disappear while topology is still being discovered. A campaign should slowly reduce ``interface_width``, then replay into a topology-guarded boundary chart once its topology has stabilized. """ shape: tuple[int, int] control_shape: tuple[int, int] spacing: tuple[float, float] = (1.0, 1.0) interface_width: float = 1.0 supersample: int = 4 def __post_init__(self) -> None: _validate_design_shape(self.shape) _validate_design_spacing(self.spacing) if len(self.control_shape) != 2 or min(self.control_shape) < 4: raise ValueError("control_shape must contain two dimensions of at least four") if any( control > cells + 1 for control, cells in zip(self.control_shape, self.shape, strict=True) ): raise ValueError("control_shape cannot exceed the material vertex grid") if not np.isfinite(self.interface_width) or self.interface_width <= 0.0: raise ValueError("interface_width must be positive and finite") if not isinstance(self.supersample, int) or isinstance(self.supersample, bool): raise TypeError("supersample must be a positive integer") if self.supersample < 1: raise ValueError("supersample must be a positive integer") @property def parameter_shape(self) -> tuple[int, int]: return self.control_shape @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) def _sampling_matrices(self, dtype: jnp.dtype) -> tuple[jax.Array, jax.Array]: matrices = [] for cells, controls in zip(self.shape, self.control_shape, strict=True): physical = (jnp.arange(cells * self.supersample, dtype=dtype) + 0.5) / ( cells * self.supersample ) coordinates = physical * (controls - 1) matrices.append( _cubic_reflecting_sampling_matrix(coordinates, controls, dtype=dtype) ) return matrices[0], matrices[1] def latent(self, parameters: jax.Array) -> jax.Array: """Return the continuous phase field sampled on material vertices.""" parameters = jnp.asarray(parameters) if tuple(parameters.shape) != self.control_shape: raise ValueError( f"phase-field parameters must have shape {self.control_shape}, got {parameters.shape}" ) x_matrix = _cubic_reflecting_interpolation_matrix( self.shape[0] + 1, self.control_shape[0], dtype=parameters.dtype, ) y_matrix = _cubic_reflecting_interpolation_matrix( self.shape[1] + 1, self.control_shape[1], dtype=parameters.dtype, ) return x_matrix @ parameters @ y_matrix.T def decode(self, parameters: jax.Array) -> jax.Array: """Integrate the smooth material indicator inside every FDTD cell.""" parameters = jnp.asarray(parameters) if tuple(parameters.shape) != self.control_shape: raise ValueError( f"phase-field parameters must have shape {self.control_shape}, got {parameters.shape}" ) x_matrix, y_matrix = self._sampling_matrices(parameters.dtype) sampled = x_matrix @ parameters @ y_matrix.T coordinate = jnp.clip( 0.5 + 0.5 * sampled / self.interface_width, 0.0, 1.0, ) material = coordinate * coordinate * (3.0 - 2.0 * coordinate) reshaped = material.reshape( self.shape[0], self.supersample, self.shape[1], self.supersample, ) return jnp.mean(reshaped, axis=(1, 3)) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: """Least-squares fit a signed-distance initializer to spline controls.""" values = np.asarray(density, dtype=np.float32) if tuple(values.shape) != self.shape: raise ValueError( f"density must have shape {self.shape}, got {values.shape}" ) level_set = signed_distance_level_set(values, spacing=self.spacing) x_matrix = np.asarray( _cubic_reflecting_interpolation_matrix( self.shape[0] + 1, self.control_shape[0], dtype=jnp.float32, ) ) y_matrix = np.asarray( _cubic_reflecting_interpolation_matrix( self.shape[1] + 1, self.control_shape[1], dtype=jnp.float32, ) ) # Solve X C Y.T ~= phi without forming the Kronecker product. left = np.linalg.lstsq(x_matrix, level_set, rcond=1.0e-8)[0] controls = np.linalg.lstsq(y_matrix, left.T, rcond=1.0e-8)[0].T if not np.all(np.isfinite(controls)): raise RuntimeError("spline phase-field fit produced nonfinite controls") return jnp.asarray(controls, dtype=jnp.float32) def fit_to_density( self, density: jax.Array | np.ndarray, *, maximum_control_magnitude: float | None = None, max_iterations: int = 500, ) -> jax.Array: """Fit the decoded cut-cell density without constraining topology. :meth:`encode` is a fast signed-distance least-squares initializer. Coarse spline interpolation can move its zero contour, however, so a direct fit of the *decoded* cell fractions is preferable at a density-to-boundary handoff. This host-side fit performs no Maxwell solves and imposes no sign or component-lineage constraint: small islands may disappear, holes may close, and nearby components may fuse whenever that is the closest representable smooth contour. ``maximum_control_magnitude`` is an optional physical bound on the absolute phase field. Clipping far-away saturated bulk values does not move the zero contour, and gives an optimizer a finite normalized coordinate chart after the handoff. """ from scipy.optimize import minimize values = np.asarray(density, dtype=np.float32) if tuple(values.shape) != self.shape: raise ValueError(f"density must have shape {self.shape}, got {values.shape}") if not np.all(np.isfinite(values)): raise ValueError("density must contain only finite values") if not isinstance(max_iterations, int) or max_iterations < 1: raise ValueError("max_iterations must be a positive integer") if maximum_control_magnitude is not None and ( not np.isfinite(maximum_control_magnitude) or maximum_control_magnitude <= 0.0 ): raise ValueError("maximum_control_magnitude must be positive and finite") initial = np.asarray(self.encode(values), dtype=np.float32) if maximum_control_magnitude is not None: initial = np.clip( initial, -maximum_control_magnitude, maximum_control_magnitude, ) target = jnp.asarray(values) def loss(flat_controls: jax.Array) -> jax.Array: controls = flat_controls.reshape(self.control_shape) return jnp.mean(jnp.square(self.decode(controls) - target)) value_and_gradient = jax.jit(jax.value_and_grad(loss)) def objective(flat_controls: np.ndarray) -> tuple[float, np.ndarray]: value, gradient = value_and_gradient( jnp.asarray(flat_controls, dtype=jnp.float32) ) return float(value), np.asarray(gradient, dtype=np.float64) bounds = None if maximum_control_magnitude is not None: bounds = [ (-maximum_control_magnitude, maximum_control_magnitude) ] * initial.size result = minimize( objective, initial.reshape(-1).astype(np.float64), method="L-BFGS-B", jac=True, bounds=bounds, options={ "maxiter": max_iterations, "ftol": 1.0e-14, "gtol": 1.0e-8, "maxls": 30, }, ) initial_value = objective(initial.reshape(-1))[0] fitted = result.x if float(result.fun) <= initial_value else initial.reshape(-1) controls = fitted.reshape(self.control_shape) if not np.all(np.isfinite(controls)): raise RuntimeError( "spline phase-field density fit produced nonfinite controls" ) return jnp.asarray(controls, dtype=jnp.float32) @dataclass(frozen=True) class SplineLevelSetBoundary2D: """Topology-guarded boundary motion on a coarse cubic-spline grid. The immutable signed-distance field supplies the literal reference interface. Parameters are physical normal displacements sampled on a smaller tensor-product cubic B-spline grid. Their interpolated field is multiplied by the same narrow-band window as :class:`LevelSetBoundary2D`, so the Maxwell derivative remains a boundary sensitivity while neighboring boundary segments move coherently. """ reference_level_set: np.ndarray control_shape: tuple[int, int] spacing: tuple[float, float] = (1.0, 1.0) narrow_band_width: float = 4.0 maximum_displacement: float = 2.0 interface_width: float = 0.25 supersample: int = 4 threshold: float = 0.5 diagonal_connectivity: bool = False exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES def __post_init__(self) -> None: level_set = np.asarray(self.reference_level_set, dtype=np.float32) if level_set.ndim != 2 or min(level_set.shape) < 3: raise ValueError("reference_level_set must be a two-dimensional vertex grid") if len(self.control_shape) != 2 or min(self.control_shape) < 4: raise ValueError("control_shape must contain two dimensions of at least four") if any( control > size for control, size in zip(self.control_shape, level_set.shape, strict=True) ): raise ValueError("control_shape cannot exceed the reference vertex grid") validated = LevelSetBoundary2D( reference_level_set=level_set, spacing=self.spacing, narrow_band_width=self.narrow_band_width, maximum_displacement=self.maximum_displacement, interface_width=self.interface_width, supersample=self.supersample, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) frozen = np.asarray(validated.reference_level_set).copy() frozen.setflags(write=False) object.__setattr__(self, "reference_level_set", frozen) object.__setattr__(self, "exterior_edges", validated.exterior_edges) @classmethod def from_density( cls, density: jax.Array | np.ndarray, *, control_shape: tuple[int, int], spacing: tuple[float, float] = (1.0, 1.0), threshold: float = 0.5, narrow_band_width: float | None = None, maximum_displacement: float | None = None, interface_width: float | None = None, supersample: int = 4, diagonal_connectivity: bool = False, exterior_edges: tuple[BoundaryEdge, ...] = _ALL_BOUNDARY_EDGES, fit_reference: bool = True, fit_max_iterations: int = 200, ) -> SplineLevelSetBoundary2D: spacing = _validate_design_spacing(spacing) grid_scale = max(spacing) width = ( min(spacing) / supersample if interface_width is None else interface_width ) if fit_reference: reference_level_set = fit_level_set_to_density( density, spacing=spacing, threshold=threshold, supersample=supersample, interface_width=width, max_iterations=fit_max_iterations, exterior_edges=exterior_edges, ) else: reference_level_set = signed_distance_level_set( density, spacing=spacing, threshold=threshold, ) return cls( reference_level_set=reference_level_set, control_shape=control_shape, spacing=spacing, narrow_band_width=( 4.0 * grid_scale if narrow_band_width is None else narrow_band_width ), maximum_displacement=( 2.0 * grid_scale if maximum_displacement is None else maximum_displacement ), interface_width=width, supersample=supersample, threshold=threshold, diagonal_connectivity=diagonal_connectivity, exterior_edges=exterior_edges, ) @property def shape(self) -> tuple[int, int]: return ( self.reference_level_set.shape[0] - 1, self.reference_level_set.shape[1] - 1, ) @property def parameter_shape(self) -> tuple[int, int]: return self.control_shape @property def parameter_bounds(self) -> tuple[float, float]: return (-self.maximum_displacement, self.maximum_displacement) def initial_parameters(self, *, dtype: jnp.dtype = jnp.float32) -> jax.Array: return jnp.zeros(self.parameter_shape, dtype=dtype) def _pixel_basis(self) -> LevelSetBoundary2D: return LevelSetBoundary2D( reference_level_set=self.reference_level_set, spacing=self.spacing, narrow_band_width=self.narrow_band_width, maximum_displacement=self.maximum_displacement, interface_width=self.interface_width, supersample=self.supersample, threshold=self.threshold, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) def _matrices(self, dtype: jnp.dtype) -> tuple[jax.Array, jax.Array]: return ( _cubic_reflecting_interpolation_matrix( self.reference_level_set.shape[0], self.control_shape[0], dtype=dtype, ), _cubic_reflecting_interpolation_matrix( self.reference_level_set.shape[1], self.control_shape[1], dtype=dtype, ), ) def boundary_window(self, *, dtype: jnp.dtype = jnp.float32) -> jax.Array: """Return controls whose cubic support intersects the narrow band.""" x_matrix, y_matrix = self._matrices(dtype) vertex_window = self._pixel_basis().boundary_window(dtype=dtype) influence = x_matrix.T @ (vertex_window > 0.0).astype(dtype) @ y_matrix return (influence > 0.0).astype(dtype) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError( f"spline parameters must have shape {self.parameter_shape}, got {parameters.shape}" ) controls = jnp.clip(parameters, *self.parameter_bounds) x_matrix, y_matrix = self._matrices(parameters.dtype) displacement = x_matrix @ controls @ y_matrix.T pixel = self._pixel_basis() return jnp.asarray(self.reference_level_set, dtype=parameters.dtype) + ( pixel.boundary_window(dtype=parameters.dtype) * displacement ) def decode(self, parameters: jax.Array) -> jax.Array: return level_set_fill_fraction( self.latent(parameters), supersample=self.supersample, interface_width=self.interface_width, ) @property def topology(self) -> BoundaryTopology: return self._pixel_basis().topology def preserves_topology(self, density: jax.Array | np.ndarray) -> bool: return self._pixel_basis().preserves_topology(density) def interface_normal(self, parameters: jax.Array) -> jax.Array: level_set = self.latent(parameters) grad_x, grad_y = jnp.gradient(level_set, self.spacing[0], self.spacing[1]) magnitude = jnp.sqrt(grad_x**2 + grad_y**2 + 1e-24) return -jnp.stack((grad_x / magnitude, grad_y / magnitude), axis=0) def rebase( self, parameters: jax.Array | np.ndarray, *, preserve_topology: bool = True, ) -> tuple[SplineLevelSetBoundary2D, jax.Array]: density = np.asarray(self.decode(jnp.asarray(parameters))) if preserve_topology and not self.preserves_topology(density): raise ValueError("candidate spline boundary changes the reference topology") rebased = type(self).from_density( density, control_shape=self.control_shape, spacing=self.spacing, threshold=self.threshold, narrow_band_width=self.narrow_band_width, maximum_displacement=self.maximum_displacement, interface_width=self.interface_width, supersample=self.supersample, diagonal_connectivity=self.diagonal_connectivity, exterior_edges=self.exterior_edges, ) if preserve_topology and rebased.topology != self.topology: raise ValueError("spline boundary rebasing changed the reference topology") return rebased, rebased.initial_parameters()
[docs] def topology_safe_boundary_step( basis: LevelSetBoundary2D | SplineLevelSetBoundary2D, parameters: jax.Array | np.ndarray, normal_velocity: jax.Array | np.ndarray, *, step_size: float, backtracking_scales: tuple[float, ...] = (1.0, 0.5, 0.25, 0.125, 0.0625), ) -> tuple[jax.Array, float]: """Take the largest proposed normal step that preserves interface topology. ``normal_velocity`` is normally the gradient of a scalar Maxwell objective with respect to the basis parameters. The returned scale is zero when all candidates would merge, split, create, or erase a boundary component. An objective-aware optimizer should additionally accept only improving steps; this helper supplies the independent geometric guard for its line search. This is deliberately a host-side operation because connected-component topology is discrete and should never be hidden inside a surrogate JAX gradient. """ if not np.isfinite(step_size) or step_size <= 0.0: raise ValueError("step_size must be positive and finite") if not backtracking_scales or any( not np.isfinite(scale) or not 0.0 < scale <= 1.0 for scale in backtracking_scales ): raise ValueError("backtracking_scales must contain values in (0, 1]") current = jnp.asarray(parameters) velocity = jnp.asarray(normal_velocity, dtype=current.dtype) if current.shape != basis.parameter_shape or velocity.shape != basis.parameter_shape: raise ValueError(f"parameters and velocity must have shape {basis.parameter_shape}") if not bool(jnp.all(jnp.isfinite(current))) or not bool(jnp.all(jnp.isfinite(velocity))): raise ValueError("parameters and velocity must contain only finite values") active_velocity = velocity * basis.boundary_window(dtype=current.dtype) peak = float(jnp.max(jnp.abs(active_velocity))) if peak == 0.0: return current, 0.0 unit_velocity = active_velocity / peak lower, upper = basis.parameter_bounds for scale in backtracking_scales: candidate = jnp.clip(current + scale * step_size * unit_velocity, lower, upper) if basis.preserves_topology(basis.decode(candidate)): return candidate, float(scale) return current, 0.0
@dataclass(frozen=True) class CosineBasis1D: """Orthonormal DCT-II basis for a finite radial or longitudinal line. ``mode_count`` retains the lowest modes, including the constant bias. Unlike a periodic Fourier series, the DCT does not identify the two ends of the line. A complete ``mode_count == size`` basis is an invertible reparameterization of the sampled line; a truncated basis supplies a smooth, globally correlated topology prior. """ size: int mode_count: int spacing: float = 1.0 minimum_period: float | None = None maximum_period: float | None = None activation: DesignActivation = "sigmoid" def __post_init__(self) -> None: size = _validate_design_size(self.size) if not isinstance(self.mode_count, int) or isinstance(self.mode_count, bool) or self.mode_count < 1: raise ValueError("mode_count must be a positive integer") if self.mode_count > size: raise ValueError("cosine mode_count cannot exceed the decoded line size") if not np.isfinite(self.spacing) or self.spacing <= 0: raise ValueError("spacing must be positive and finite") _validate_period_band(self.minimum_period, self.maximum_period) _activate_design_latent(jnp.asarray(0.0), self.activation) if not self.mode_indices: raise ValueError("the requested cosine period band contains no modes") @property def mode_indices(self) -> tuple[int, ...]: modes = [] for mode in range(self.mode_count): wave_number = math.pi * mode / (self.size * self.spacing) if _period_is_selected( wave_number, minimum_period=self.minimum_period, maximum_period=self.maximum_period, ): modes.append(mode) return tuple(modes) @property def parameter_shape(self) -> tuple[int]: return (len(self.mode_indices),) @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) def _basis(self, dtype: jnp.dtype = jnp.float32) -> jax.Array: modes = jnp.asarray(self.mode_indices, dtype=dtype) coordinate = jnp.arange(self.size, dtype=dtype) + 0.5 normalization = jnp.where( modes == 0, jnp.sqrt(1.0 / self.size), jnp.sqrt(2.0 / self.size), ) return ( jnp.cos(jnp.pi * coordinate[:, None] * modes[None, :] / self.size) * normalization[None, :] ) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError( f"cosine parameters must have shape {self.parameter_shape}, got {parameters.shape}" ) return self._basis(parameters.dtype) @ parameters def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation_1d(density, self.activation) if latent.shape != (self.size,): raise ValueError( f"density must have shape {(self.size,)}, got {latent.shape}" ) basis = np.asarray(self._basis(jnp.float32), dtype=np.float64) coefficients = np.einsum("xk,x->k", basis, latent) return jnp.asarray(coefficients, dtype=jnp.float32) @dataclass(frozen=True) class CosineBasis2D: """Separable DCT-II material basis, ideal for mirror-reduced domains. ``mode_shape=(mx, my)`` retains the lowest ``mx * my`` cosine modes before optional physical-period filtering. Cosines have zero normal derivative at each design-box boundary, making this basis particularly natural on an x/y mirror-reduced quadrant. The zero mode is always retained as a bias. """ shape: tuple[int, int] mode_shape: tuple[int, int] spacing: tuple[float, float] = (1.0, 1.0) minimum_period: float | None = None maximum_period: float | None = None activation: DesignActivation = "sigmoid" def __post_init__(self) -> None: shape = _validate_design_shape(self.shape) _validate_design_spacing(self.spacing) _validate_period_band(self.minimum_period, self.maximum_period) if len(self.mode_shape) != 2 or any( not isinstance(value, int) or isinstance(value, bool) or value < 1 for value in self.mode_shape ): raise ValueError("mode_shape must contain two positive integers") if any(count > size for count, size in zip(self.mode_shape, shape, strict=True)): raise ValueError("cosine mode_shape cannot exceed the decoded grid shape") _activate_design_latent(jnp.asarray(0.0), self.activation) if len(self.mode_indices) == 0: raise ValueError("the requested cosine period band contains no modes") @property def mode_indices(self) -> tuple[tuple[int, int], ...]: nx, ny = self.shape dx, dy = self.spacing modes = [] for mx in range(self.mode_shape[0]): for my in range(self.mode_shape[1]): wave_number = math.hypot(math.pi * mx / (nx * dx), math.pi * my / (ny * dy)) if _period_is_selected( wave_number, minimum_period=self.minimum_period, maximum_period=self.maximum_period, ): modes.append((mx, my)) return tuple(modes) @property def parameter_shape(self) -> tuple[int]: return (len(self.mode_indices),) @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) def _basis(self, dtype: jnp.dtype = jnp.float32) -> jax.Array: nx, ny = self.shape modes = jnp.asarray(self.mode_indices, dtype=dtype) x = jnp.arange(nx, dtype=dtype) + 0.5 y = jnp.arange(ny, dtype=dtype) + 0.5 mx = modes[:, 0] my = modes[:, 1] norm_x = jnp.where(mx == 0, jnp.sqrt(1.0 / nx), jnp.sqrt(2.0 / nx)) norm_y = jnp.where(my == 0, jnp.sqrt(1.0 / ny), jnp.sqrt(2.0 / ny)) along_x = jnp.cos(jnp.pi * x[:, None] * mx[None, :] / nx) * norm_x[None, :] along_y = jnp.cos(jnp.pi * y[:, None] * my[None, :] / ny) * norm_y[None, :] return along_x[:, None, :] * along_y[None, :, :] def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError(f"cosine parameters must have shape {self.parameter_shape}, got {parameters.shape}") return jnp.einsum("xyk,k->xy", self._basis(parameters.dtype), parameters) def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation(density, self.activation) if latent.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {latent.shape}") basis = np.asarray(self._basis(jnp.float32), dtype=np.float64) coefficients = np.einsum("xyk,xy->k", basis, latent) return jnp.asarray(coefficients, dtype=jnp.float32) @dataclass(frozen=True) class FourierBasis2D: """General real plane-wave basis with independently learned phase. Parameters are ordered as one bias followed by cosine amplitudes and sine amplitudes for every unique reciprocal-grid vector. Unlike :class:`CosineBasis2D`, this basis does not impose mirror parity. """ shape: tuple[int, int] max_mode_indices: tuple[int, int] spacing: tuple[float, float] = (1.0, 1.0) minimum_period: float | None = None maximum_period: float | None = None activation: DesignActivation = "sigmoid" def __post_init__(self) -> None: shape = _validate_design_shape(self.shape) _validate_design_spacing(self.spacing) _validate_period_band(self.minimum_period, self.maximum_period) if len(self.max_mode_indices) != 2 or any( not isinstance(value, int) or isinstance(value, bool) or value < 0 for value in self.max_mode_indices ): raise ValueError("max_mode_indices must contain two non-negative integers") if any(limit > size // 2 for limit, size in zip(self.max_mode_indices, shape, strict=True)): raise ValueError("Fourier mode indices cannot exceed the grid Nyquist indices") _activate_design_latent(jnp.asarray(0.0), self.activation) @property def mode_indices(self) -> tuple[tuple[int, int], ...]: nx, ny = self.shape dx, dy = self.spacing modes = [] for mx in range(-self.max_mode_indices[0], self.max_mode_indices[0] + 1): for my in range(-self.max_mode_indices[1], self.max_mode_indices[1] + 1): # Keep exactly one vector from each conjugate pair. if not (mx > 0 or (mx == 0 and my > 0)): continue wave_number = math.hypot(2.0 * math.pi * mx / (nx * dx), 2.0 * math.pi * my / (ny * dy)) if _period_is_selected( wave_number, minimum_period=self.minimum_period, maximum_period=self.maximum_period, ): modes.append((mx, my)) return tuple(modes) @property def parameter_shape(self) -> tuple[int]: return (1 + 2 * len(self.mode_indices),) @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) def _basis(self, dtype: jnp.dtype = jnp.float32) -> jax.Array: nx, ny = self.shape if not self.mode_indices: return jnp.ones((*self.shape, 1), dtype=dtype) modes = jnp.asarray(self.mode_indices, dtype=dtype) x = (jnp.arange(nx, dtype=dtype) + 0.5) / nx y = (jnp.arange(ny, dtype=dtype) + 0.5) / ny phase = 2.0 * jnp.pi * (x[:, None, None] * modes[None, None, :, 0] + y[None, :, None] * modes[None, None, :, 1]) return jnp.concatenate( ( jnp.ones((*self.shape, 1), dtype=dtype), jnp.cos(phase), jnp.sin(phase), ), axis=2, ) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError(f"Fourier parameters must have shape {self.parameter_shape}, got {parameters.shape}") return jnp.einsum("xyk,k->xy", self._basis(parameters.dtype), parameters) def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation(density, self.activation) if latent.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {latent.shape}") matrix = np.asarray(self._basis(jnp.float32), dtype=np.float64).reshape((-1, self.parameter_shape[0])) coefficients, *_ = np.linalg.lstsq(matrix, latent.reshape(-1), rcond=None) return jnp.asarray(coefficients, dtype=jnp.float32) @dataclass(frozen=True) class FFTGridBasis2D: r"""Dense real Fourier grid decoded with an :math:`O(N\log N)` FFT. A real material field has one independent real degree of freedom per grid cell, even though its Fourier spectrum is complex. This class packs each self-conjugate reciprocal point as one real value and every other Hermitian pair as one real and one imaginary value. With no period band, ``parameter_shape == (prod(shape),)`` exactly: there are no redundant or aliased coefficients. Use this basis when thousands of Fourier modes are desired. The explicit :class:`FourierBasis2D` remains preferable for a small hand-selected bank, because it exposes each cosine/sine wave directly. """ shape: tuple[int, int] spacing: tuple[float, float] = (1.0, 1.0) minimum_period: float | None = None maximum_period: float | None = None activation: DesignActivation = "sigmoid" def __post_init__(self) -> None: _validate_design_shape(self.shape) _validate_design_spacing(self.spacing) _validate_period_band(self.minimum_period, self.maximum_period) _activate_design_latent(jnp.asarray(0.0), self.activation) if self.parameter_shape[0] == 0: raise ValueError("the requested FFT period band contains no modes") @staticmethod def _signed_index(index: int, size: int) -> int: return index if index <= size // 2 else index - size def _period_selected(self, index: tuple[int, int]) -> bool: nx, ny = self.shape dx, dy = self.spacing mx = self._signed_index(index[0], nx) my = self._signed_index(index[1], ny) wave_number = math.hypot(2.0 * math.pi * mx / (nx * dx), 2.0 * math.pi * my / (ny * dy)) return _period_is_selected( wave_number, minimum_period=self.minimum_period, maximum_period=self.maximum_period, ) @property def self_conjugate_indices(self) -> tuple[tuple[int, int], ...]: nx, ny = self.shape result = [] for ix in range(nx): for iy in range(ny): partner = ((-ix) % nx, (-iy) % ny) if partner == (ix, iy) and self._period_selected((ix, iy)): result.append((ix, iy)) return tuple(result) @property def paired_indices(self) -> tuple[tuple[int, int], ...]: nx, ny = self.shape result = [] for ix in range(nx): for iy in range(ny): partner = ((-ix) % nx, (-iy) % ny) flat = ix * ny + iy partner_flat = partner[0] * ny + partner[1] if flat < partner_flat and self._period_selected((ix, iy)): result.append((ix, iy)) return tuple(result) @property def conjugate_indices(self) -> tuple[tuple[int, int], ...]: nx, ny = self.shape return tuple(((-ix) % nx, (-iy) % ny) for ix, iy in self.paired_indices) @property def parameter_shape(self) -> tuple[int]: return (len(self.self_conjugate_indices) + 2 * len(self.paired_indices),) @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) @property def reciprocal_wave_count(self) -> int: """Number of unique real waves, counting a conjugate pair once.""" return len(self.self_conjugate_indices) + len(self.paired_indices) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError(f"FFT-grid parameters must have shape {self.parameter_shape}, got {parameters.shape}") self_count = len(self.self_conjugate_indices) pair_count = len(self.paired_indices) complex_dtype = jnp.complex128 if parameters.dtype == jnp.float64 else jnp.complex64 spectrum = jnp.zeros(self.shape, dtype=complex_dtype) if self_count: self_indices = jnp.asarray(self.self_conjugate_indices, dtype=jnp.int32) spectrum = spectrum.at[self_indices[:, 0], self_indices[:, 1]].set(parameters[:self_count]) if pair_count: pair_indices = jnp.asarray(self.paired_indices, dtype=jnp.int32) conjugate_indices = jnp.asarray(self.conjugate_indices, dtype=jnp.int32) real = parameters[self_count : self_count + pair_count] imaginary = parameters[self_count + pair_count :] values = real + 1j * imaginary spectrum = spectrum.at[pair_indices[:, 0], pair_indices[:, 1]].set(values) spectrum = spectrum.at[conjugate_indices[:, 0], conjugate_indices[:, 1]].set(jnp.conj(values)) return jnp.real(jnp.fft.ifft2(spectrum, norm="ortho")) def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation(density, self.activation) if latent.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {latent.shape}") spectrum = np.fft.fft2(latent, norm="ortho") self_values = np.asarray([spectrum[index].real for index in self.self_conjugate_indices]) pair_values = np.asarray([spectrum[index] for index in self.paired_indices]) packed = np.concatenate((self_values, pair_values.real, pair_values.imag)) return jnp.asarray(packed, dtype=jnp.float32) @dataclass(frozen=True) class RadialCosineBasis2D: """Concentric circular or elliptical waves with optional learned phase. This basis is useful for bullseye cavities and facing curved Bragg mirrors. ``axis_scale`` controls the ellipse metric and ``window_sigma`` optionally turns the waves into localized radial/Gabor-like features. The parameter order is bias, cosine amplitudes, then (when enabled) sine amplitudes. """ shape: tuple[int, int] spacing: tuple[float, float] periods: tuple[float, ...] center: tuple[float, float] = (0.0, 0.0) axis_scale: tuple[float, float] = (1.0, 1.0) window_sigma: tuple[float, float] | None = None learn_phase: bool = True activation: DesignActivation = "sigmoid" def __post_init__(self) -> None: _validate_design_shape(self.shape) _validate_design_spacing(self.spacing) if len(self.periods) == 0 or any(not np.isfinite(value) or value <= 0 for value in self.periods): raise ValueError("periods must contain positive finite values") if len(set(self.periods)) != len(self.periods): raise ValueError("radial periods must be unique") if len(self.center) != 2 or any(not np.isfinite(value) for value in self.center): raise ValueError("center must contain two finite coordinates") if len(self.axis_scale) != 2 or any(not np.isfinite(value) or value <= 0 for value in self.axis_scale): raise ValueError("axis_scale must contain two positive finite values") if self.window_sigma is not None and ( len(self.window_sigma) != 2 or any(not np.isfinite(value) or value <= 0 for value in self.window_sigma) ): raise ValueError("window_sigma must contain two positive finite values") _activate_design_latent(jnp.asarray(0.0), self.activation) @property def parameter_shape(self) -> tuple[int]: phase_count = 2 if self.learn_phase else 1 return (1 + phase_count * len(self.periods),) @property def parameter_bounds(self) -> tuple[float, float]: return (-math.inf, math.inf) def _basis(self, dtype: jnp.dtype = jnp.float32) -> jax.Array: nx, ny = self.shape dx, dy = self.spacing x = ((jnp.arange(nx, dtype=dtype) + 0.5) - nx / 2.0) * dx - self.center[0] y = ((jnp.arange(ny, dtype=dtype) + 0.5) - ny / 2.0) * dy - self.center[1] radius = jnp.sqrt((x[:, None] / self.axis_scale[0]) ** 2 + (y[None, :] / self.axis_scale[1]) ** 2) periods = jnp.asarray(self.periods, dtype=dtype) phase = 2.0 * jnp.pi * radius[:, :, None] / periods[None, None, :] if self.window_sigma is None: window = jnp.ones(self.shape, dtype=dtype) else: window = jnp.exp( -0.5 * (x[:, None] / self.window_sigma[0]) ** 2 - 0.5 * (y[None, :] / self.window_sigma[1]) ** 2 ) components = [jnp.ones((*self.shape, 1), dtype=dtype), window[:, :, None] * jnp.cos(phase)] if self.learn_phase: components.append(window[:, :, None] * jnp.sin(phase)) return jnp.concatenate(tuple(components), axis=2) def latent(self, parameters: jax.Array) -> jax.Array: parameters = jnp.asarray(parameters) if parameters.shape != self.parameter_shape: raise ValueError(f"radial parameters must have shape {self.parameter_shape}, got {parameters.shape}") return jnp.einsum("xyk,k->xy", self._basis(parameters.dtype), parameters) def decode(self, parameters: jax.Array) -> jax.Array: return _activate_design_latent(self.latent(parameters), self.activation) def encode(self, density: jax.Array | np.ndarray) -> jax.Array: latent = _inverse_design_activation(density, self.activation) if latent.shape != self.shape: raise ValueError(f"density must have shape {self.shape}, got {latent.shape}") matrix = np.asarray(self._basis(jnp.float32), dtype=np.float64).reshape((-1, self.parameter_shape[0])) coefficients, *_ = np.linalg.lstsq(matrix, latent.reshape(-1), rcond=None) return jnp.asarray(coefficients, dtype=jnp.float32) def automatic_pulse_time_window( *, spectral_width_hz: float, path_length_m: float, max_refractive_index: float, source_offset_sigmas: float = 6.0, tail_sigmas: float = 5.0, transit_passes: float = 2.0, max_time: float | None = None, ) -> float: """Choose a conservative fixed window for reversible pulsed FDTD. Reversible adjoints must size their boundary tape before a run, so a data-dependent early-stop cannot reduce their memory allocation. This device-independent estimate includes the complete Gaussian source window and a configurable number of worst-index traversals of the scene. A caller may retain a reference solver's longer run time as ``max_time``; the returned value never exceeds that cap. """ if spectral_width_hz <= 0 or path_length_m <= 0 or max_refractive_index < 1: raise ValueError( "spectral_width_hz and path_length_m must be positive and max_refractive_index must be at least one" ) if source_offset_sigmas < 0 or tail_sigmas <= 0 or transit_passes <= 0: raise ValueError("pulse-window sigma counts and transit_passes must be positive") sigma_t = 1.0 / (2.0 * math.pi * spectral_width_hz) source_window = (source_offset_sigmas + tail_sigmas) * sigma_t transit_window = transit_passes * path_length_m * max_refractive_index / c estimate = source_window + transit_window if max_time is not None: if max_time <= 0: raise ValueError("max_time must be positive when provided") estimate = min(estimate, max_time) return float(estimate) def extend_periodic_mirror_to_boundary( centers: np.ndarray, radii: np.ndarray, *, period: float, radius: float, maximum_extent: float, end_clearance: float = 0.0, ) -> tuple[np.ndarray, np.ndarray]: """Complete a one-sided periodic hole mirror without entering a boundary layer. High-Q cavity optimization is easily limited by an artificially short mirror rather than by the cavity taper itself. This discrete setup helper appends as many complete nominal periods as fit before the caller's non-PML extent. The dielectric beam may and should continue through the PML; the appended holes do not. Geometry controls can subsequently optimize the returned centers/radii. All lengths use the caller's consistent unit system. Existing centers must be positive and strictly increasing, so the function works with metres, micrometres, or normalized lattice units. """ centers = np.asarray(centers, dtype=np.float64) radii = np.asarray(radii, dtype=np.float64) if centers.ndim != 1 or radii.ndim != 1 or centers.shape != radii.shape or centers.size == 0: raise ValueError("centers and radii must be non-empty one-dimensional arrays with equal shapes") if np.any(centers <= 0) or np.any(np.diff(centers) <= 0): raise ValueError("centers must be positive and strictly increasing") if np.any(radii <= 0) or period <= 0 or radius <= 0 or maximum_extent <= 0 or end_clearance < 0: raise ValueError("radii, period, radius, and maximum_extent must be positive; clearance must be non-negative") if maximum_extent <= centers[-1] + radii[-1] + end_clearance: return centers.copy(), radii.copy() out_centers = centers.tolist() out_radii = radii.tolist() next_center = out_centers[-1] + period while next_center + radius + end_clearance <= maximum_extent: out_centers.append(next_center) out_radii.append(radius) next_center += period return np.asarray(out_centers, dtype=np.float64), np.asarray(out_radii, dtype=np.float64) def differentiable_circle_union_fill_fraction( x_edges: jax.Array, y_edges: jax.Array, centers_x: jax.Array, radii: jax.Array, *, supersample: int = 8, edge_width: float, ) -> jax.Array: """Rasterize binary circular geometry with a smooth shape derivative. The returned *forward* value is the exact supersampled area fraction of the union of the circles. Its derivative is supplied by a sigmoid approximation to the same boundary (a straight-through estimator). This prevents an optimizer from exploiting grey interface cells that are absent when a final literal geometry is rebuilt, while retaining useful GPU-native shape derivatives with respect to circle centers and radii. ``x_edges`` and ``y_edges`` are physical cell edges. All circles are centered at ``(centers_x[i], 0)``; this common nanophotonic case covers hole tapers and one-dimensional photonic-crystal mirrors. The output has shape ``(len(x_edges) - 1, len(y_edges) - 1)``. """ x_edges = jnp.asarray(x_edges) y_edges = jnp.asarray(y_edges) centers_x = jnp.asarray(centers_x) radii = jnp.asarray(radii) if x_edges.ndim != 1 or y_edges.ndim != 1 or x_edges.size < 2 or y_edges.size < 2: raise ValueError("x_edges and y_edges must be one-dimensional arrays with at least two entries") if centers_x.ndim != 1 or radii.ndim != 1 or centers_x.shape != radii.shape or centers_x.size == 0: raise ValueError("centers_x and radii must be non-empty one-dimensional arrays with equal shapes") if supersample < 1: raise ValueError("supersample must be at least one") if edge_width <= 0: raise ValueError("edge_width must be positive") dtype = jnp.result_type(x_edges, y_edges, centers_x, radii) offsets = (jnp.arange(supersample, dtype=dtype) + 0.5) / supersample x_sub = x_edges[:-1, None] + offsets[None, :] * jnp.diff(x_edges)[:, None] y_sub = y_edges[:-1, None] + offsets[None, :] * jnp.diff(y_edges)[:, None] hard_outside = jnp.ones((x_sub.shape[0], y_sub.shape[0], supersample, supersample), dtype=bool) soft_outside = jnp.ones(hard_outside.shape, dtype=dtype) for center, radius in zip(centers_x, radii, strict=True): dx = x_sub[:, None, :, None] - center dy = y_sub[None, :, None, :] distance = jnp.sqrt(dx**2 + dy**2) hard_inside = distance < radius soft_inside = jax.nn.sigmoid((radius - distance) / edge_width) hard_outside = jnp.logical_and(hard_outside, jnp.logical_not(hard_inside)) soft_outside = soft_outside * (1.0 - soft_inside) hard_fill = jnp.mean(jnp.logical_not(hard_outside), axis=(2, 3), dtype=dtype) soft_fill = jnp.mean(1.0 - soft_outside, axis=(2, 3)) return soft_fill + jax.lax.stop_gradient(hard_fill - soft_fill) def fill_fraction_interface_normal( fill_fraction: jax.Array, *, spacing: tuple[float, float], ) -> jax.Array: """Return the metric-aware in-plane normal of a 2-D fill fraction.""" fill_fraction = jnp.asarray(fill_fraction) if fill_fraction.ndim != 2 or min(fill_fraction.shape) < 2: raise ValueError("fill_fraction must be a two-dimensional array at least two cells wide") if len(spacing) != 2 or spacing[0] <= 0 or spacing[1] <= 0: raise ValueError("spacing must contain two positive values") grad_x, grad_y = jnp.gradient(fill_fraction, spacing[0], spacing[1]) gradient = -jnp.stack((grad_x, grad_y, jnp.zeros_like(fill_fraction)), axis=0) magnitude_squared = jnp.sum(gradient**2, axis=0) # sqrt has an undefined derivative at exactly zero. The tiny squared floor # leaves every nonzero forward normal unchanged at float precision while # keeping reverse-mode derivatives finite in uniform bulk regions. safe_magnitude = jnp.sqrt(magnitude_squared + 1e-24) return jnp.where( magnitude_squared[None, ...] > 1e-24, gradient / safe_magnitude, 0.0, ) def diagonal_subpixel_inverse_permittivity( fill_fraction: jax.Array, normal: jax.Array, *, background_permittivity: float, inclusion_permittivity: float, ) -> jax.Array: """Apply diagonal Farjadpour smoothing to a binary fill-fraction field.""" fill_fraction = jnp.asarray(fill_fraction) normal = jnp.asarray(normal) if normal.shape != (3, *fill_fraction.shape): raise ValueError(f"normal must have shape {(3, *fill_fraction.shape)}, got {normal.shape}") if background_permittivity <= 0 or inclusion_permittivity <= 0: raise ValueError("permittivities must be positive") eps_background = jnp.asarray(background_permittivity, dtype=fill_fraction.dtype) eps_inclusion = jnp.asarray(inclusion_permittivity, dtype=fill_fraction.dtype) eps_bar = fill_fraction * eps_inclusion + (1.0 - fill_fraction) * eps_background eps_harmonic = 1.0 / (fill_fraction / eps_inclusion + (1.0 - fill_fraction) / eps_background) delta = eps_bar - eps_harmonic epsilon = jnp.stack([eps_bar - delta * normal[axis] ** 2 for axis in range(3)], axis=0) return 1.0 / epsilon def _shape_elements(shape: tuple[int, ...]) -> int: return reduce(mul, shape, 1) def recorder_bytes_per_step( input_shape_dtypes: Mapping[str, jax.ShapeDtypeStruct], *, storage_dtype: jnp.dtype | None = None, ) -> int: """Return bytes required for one stored boundary-history sample.""" total = 0 for spec in input_shape_dtypes.values(): dtype = np.dtype(storage_dtype if storage_dtype is not None else spec.dtype) total += _shape_elements(tuple(spec.shape)) * dtype.itemsize return total def accelerator_memory_limit_bytes(*, fallback_bytes: int = 8 * 1024**3) -> int: """Return the memory visible to JAX without depending on vendor tooling.""" try: stats = jax.devices()[0].memory_stats() or {} limit = int(stats.get("bytes_limit", 0)) except (IndexError, RuntimeError, TypeError, ValueError): limit = 0 return limit if limit > 0 else fallback_bytes def plan_reversible_recorder( input_shape_dtypes: Mapping[str, jax.ShapeDtypeStruct], max_time_steps: int, *, backend: BackendOption = "gpu", device_limit_bytes: int | None = None, tape_budget_fraction: float = 0.12, max_temporal_stride: int = 8, ) -> tuple[Recorder, RecorderMemoryPlan]: """Build the highest-fidelity reversible recorder that fits automatically. The planner first tries an exact float32 tape, then float16 storage, then progressively wider linear-reconstruction strides. Only the boundary tape receives this budget; the conservative default leaves 88% of JAX's visible memory for fields, detector state, compilation, and reverse workspace. A failure is explicit instead of allowing an opaque GPU OOM. """ if max_time_steps < 2: raise ValueError("max_time_steps must be at least 2") if not 0.0 < tape_budget_fraction < 1.0: raise ValueError("tape_budget_fraction must lie strictly between 0 and 1") if max_temporal_stride < 1: raise ValueError("max_temporal_stride must be positive") limit = accelerator_memory_limit_bytes() if device_limit_bytes is None else int(device_limit_bytes) budget = int(limit * tape_budget_fraction) raw_per_step = recorder_bytes_per_step(input_shape_dtypes) raw_bytes = raw_per_step * max_time_steps candidates: list[tuple[jnp.dtype | None, int]] = [(None, 1), (jnp.float16, 1)] stride = 2 while stride <= max_temporal_stride: candidates.append((jnp.float16, stride)) stride *= 2 for storage_dtype, temporal_stride in candidates: latent_steps = max_time_steps if temporal_stride == 1 else math.ceil((max_time_steps - 1) / temporal_stride) + 1 per_step = recorder_bytes_per_step(input_shape_dtypes, storage_dtype=storage_dtype) estimated = per_step * latent_steps if estimated > budget: continue modules = [] if storage_dtype is not None: modules.append(DtypeConversion(dtype=storage_dtype)) if temporal_stride > 1: modules.append(LinearReconstructEveryK(k=temporal_stride)) recorder = Recorder(modules=modules) plan = RecorderMemoryPlan( raw_bytes=raw_bytes, estimated_bytes=estimated, budget_bytes=budget, device_limit_bytes=limit, storage_dtype=( np.dtype(next(iter(input_shape_dtypes.values())).dtype).name if storage_dtype is None else np.dtype(storage_dtype).name ), temporal_stride=temporal_stride, latent_steps=latent_steps, ) return recorder, plan minimum = recorder_bytes_per_step(input_shape_dtypes, storage_dtype=jnp.float16) * ( math.ceil((max_time_steps - 1) / max_temporal_stride) + 1 ) raise MemoryError( "reversible boundary tape does not fit the automatic memory budget: " f"minimum={minimum / 1024**3:.2f} GiB, " f"budget={budget / 1024**3:.2f} GiB, " f"max_temporal_stride={max_temporal_stride}. Use local spatial refinement, " "a shorter converged time window, or a larger explicitly validated stride." )
[docs] def initialize_planned_recorder( input_shape_dtypes: Mapping[str, jax.ShapeDtypeStruct], max_time_steps: int, *, backend: BackendOption = "gpu", **plan_kwargs, ): """Plan a recorder and allocate its state in one call.""" recorder, plan = plan_reversible_recorder( input_shape_dtypes, max_time_steps, backend=backend, **plan_kwargs, ) recorder, state = recorder.init_state( input_shape_dtypes=dict(input_shape_dtypes), max_time_steps=max_time_steps, backend=backend, ) return recorder, state, plan
def conic_filter_kernel(radius: float, spacing: float) -> jax.Array: """Return the normalized conic kernel used by Tidy3D topology examples.""" if radius <= 0 or spacing <= 0: raise ValueError("radius and spacing must be positive") radius_pixels = math.ceil(radius / spacing) coordinates = jnp.linspace(-1.0, 1.0, 2 * radius_pixels + 1, dtype=jnp.float32) xx, yy = jnp.meshgrid(coordinates, coordinates, indexing="ij") kernel = jnp.maximum(0.0, 1.0 - jnp.sqrt(xx**2 + yy**2)) return kernel / jnp.sum(kernel) def conic_filter_kernel_1d(radius: float, spacing: float) -> jax.Array: """Return a normalized one-dimensional conic fabrication kernel. This is the radial analogue of :func:`conic_filter_kernel`. It supports bodies of revolution whose complete mask is controlled by one radial line while retaining a physical minimum-feature radius. """ if radius <= 0 or spacing <= 0: raise ValueError("radius and spacing must be positive") radius_pixels = math.ceil(radius / spacing) coordinate = jnp.arange(-radius_pixels, radius_pixels + 1, dtype=jnp.float32) kernel = jnp.maximum(0.0, 1.0 - jnp.abs(coordinate) * spacing / radius) return kernel / jnp.sum(kernel) def conic_filter_1d( parameters: jax.Array, *, radius: float, spacing: float, padding: str = "reflect", ) -> jax.Array: """Apply a conic minimum-feature filter to a one-dimensional design.""" parameters = jnp.asarray(parameters) if parameters.ndim != 1: raise ValueError("parameters must be one-dimensional") if padding not in {"reflect", "constant"}: raise ValueError("padding must be 'reflect' or 'constant'") kernel = conic_filter_kernel_1d(radius, spacing) pad = kernel.shape[0] // 2 padded = jnp.pad(parameters, (pad, pad), mode=padding) return jnp.convolve(padded, kernel, mode="valid")
[docs] def conic_filter( parameters: jax.Array, *, radius: float, spacing: float, padding: str = "reflect", ) -> jax.Array: """Apply a conic minimum-feature filter with explicit boundary padding.""" kernel = conic_filter_kernel(radius, spacing) pad = kernel.shape[0] // 2 if padding not in {"reflect", "constant"}: raise ValueError("padding must be 'reflect' or 'constant'") padded = jnp.pad(parameters, ((pad, pad), (pad, pad)), mode=padding) return convolve2d(padded, kernel, mode="valid")
[docs] def tanh_projection(values: jax.Array, *, beta: float | jax.Array, eta: float = 0.5) -> jax.Array: """Smoothly project filtered values toward a binary material system.""" beta_array = jnp.asarray(beta, dtype=values.dtype) eta_array = jnp.asarray(eta, dtype=values.dtype) return (jnp.tanh(beta_array * eta_array) + jnp.tanh(beta_array * (values - eta_array))) / ( jnp.tanh(beta_array * eta_array) + jnp.tanh(beta_array * (1.0 - eta_array)) )
def filter_and_project( parameters: jax.Array, *, radius: float, spacing: float, beta: float | jax.Array, eta: float = 0.5, padding: str = "reflect", ) -> jax.Array: """Apply the standard differentiable fabrication transform.""" return tanh_projection( conic_filter(parameters, radius=radius, spacing=spacing, padding=padding), beta=beta, eta=eta, ) def filter_and_project_1d( parameters: jax.Array, *, radius: float, spacing: float, beta: float | jax.Array, eta: float = 0.5, padding: str = "reflect", ) -> jax.Array: """Filter and project a one-dimensional radial or longitudinal design.""" return tanh_projection( conic_filter_1d(parameters, radius=radius, spacing=spacing, padding=padding), beta=beta, eta=eta, ) def fabrication_density_2d( parameters: jax.Array, basis: DesignBasis2D, *, beta: float | jax.Array, eta: float = 0.5, filter_radius: float | None = None, filter_spacing: float = 1.0, trainable_mask: jax.Array | np.ndarray | None = None, fixed_density: jax.Array | np.ndarray | None = None, exact_binary: bool = False, binary_threshold: float = 0.5, padding: str = "reflect", ) -> jax.Array: """Decode a basis and apply one common fabrication-aware density map. This function is the bridge between any :class:`DesignBasis2D` and an FDTD material array. Hard-air regions have ``trainable_mask=0`` and ``fixed_density=0``; fixed material has ``trainable_mask=0`` and a nonzero ``fixed_density``. The constraints are imposed both before filtering and after projection so neither operation can bleed material into a hard gap. ``exact_binary=True`` uses a straight-through estimator: the forward FDTD geometry is literal binary while its reverse derivative is that of the smooth projection. Erosion/dilation robustness can be evaluated by calling this function at several ``eta`` values with the same parameters. """ density = basis.decode(parameters) if density.ndim != 2 or tuple(density.shape) != tuple(basis.shape): raise ValueError(f"decoded density must have shape {basis.shape}, got {density.shape}") if filter_spacing <= 0: raise ValueError("filter_spacing must be positive") if not 0.0 <= binary_threshold <= 1.0: raise ValueError("binary_threshold must lie in [0, 1]") trainable = jnp.ones(basis.shape, dtype=density.dtype) if trainable_mask is None else jnp.asarray(trainable_mask) fixed = jnp.zeros(basis.shape, dtype=density.dtype) if fixed_density is None else jnp.asarray(fixed_density) if trainable.shape != basis.shape or fixed.shape != basis.shape: raise ValueError("trainable_mask and fixed_density must match the decoded basis shape") trainable = trainable.astype(density.dtype) fixed = fixed.astype(density.dtype) pinned = density * trainable + fixed if filter_radius is None: filtered = pinned else: if filter_radius <= 0: raise ValueError("filter_radius must be positive when provided") filtered = conic_filter( pinned, radius=filter_radius, spacing=filter_spacing, padding=padding, ) projected = tanh_projection(filtered, beta=beta, eta=eta) if exact_binary: binary = (projected >= binary_threshold).astype(projected.dtype) projected = projected + jax.lax.stop_gradient(binary - projected) return jnp.clip(projected * trainable + fixed, 0.0, 1.0) @jax.custom_jvp def _slab_fill_fractions_traced( edges: jax.Array, thickness: jax.Array, center: jax.Array, ) -> jax.Array: lower = center - 0.5 * thickness upper = center + 0.5 * thickness overlap = jnp.maximum( 0.0, jnp.minimum(edges[1:], upper) - jnp.maximum(edges[:-1], lower) ) return jnp.clip(overlap / jnp.diff(edges), 0.0, 1.0) @_slab_fill_fractions_traced.defjvp def _slab_fill_fractions_jvp(primals, tangents): """Use the centered shape derivative when an interface lies on an edge.""" edges, thickness, center = primals _, thickness_tangent, center_tangent = tangents fractions = _slab_fill_fractions_traced(edges, thickness, center) lower = center - 0.5 * thickness upper = center + 0.5 * thickness cell_lower = edges[:-1] cell_upper = edges[1:] inverse_width = 1.0 / jnp.diff(edges) def interface_weight(position): interior = (position > cell_lower) & (position < cell_upper) on_edge = (position == cell_lower) | (position == cell_upper) return (interior.astype(edges.dtype) + 0.5 * on_edge.astype(edges.dtype)) * inverse_width upper_weight = interface_weight(upper) lower_weight = interface_weight(lower) thickness_derivative = 0.5 * (upper_weight + lower_weight) center_derivative = upper_weight - lower_weight tangent = ( thickness_derivative * thickness_tangent + center_derivative * center_tangent ) return fractions, tangent def slab_fill_fractions( z_edges: jax.Array | np.ndarray, *, thickness: float | jax.Array, center: float | jax.Array = 0.0, ) -> jax.Array: """Return exact, differentiable cell overlaps for an extruded slab. ``thickness`` and ``center`` may be traced scalar arrays, allowing layer thicknesses to be optimized directly through an FDTD material raster. A Python scalar is still validated eagerly; traced callers are responsible for applying positive bounds (normally with a sigmoid parameterization). """ edges = jnp.asarray(z_edges) if edges.ndim != 1 or edges.shape[0] < 2: raise ValueError("z_edges must be a one-dimensional array with at least two entries") thickness_array = jnp.asarray(thickness, dtype=edges.dtype) center_array = jnp.asarray(center, dtype=edges.dtype) if thickness_array.ndim != 0 or center_array.ndim != 0: raise ValueError("thickness and center must be scalars") if isinstance(thickness, (int, float, np.integer, np.floating)) and ( float(thickness) <= 0 or not np.isfinite(float(thickness)) ): raise ValueError("thickness must be positive and finite") if isinstance(center, (int, float, np.integer, np.floating)) and not np.isfinite( float(center) ): raise ValueError("center must be finite") return _slab_fill_fractions_traced(edges, thickness_array, center_array) def extrude_density_2d( density: jax.Array, z_fill_fractions: jax.Array | np.ndarray, ) -> jax.Array: """Extrude a 2-D material density through fractional slab cells.""" density = jnp.asarray(density) z_fill = jnp.asarray(z_fill_fractions, dtype=density.dtype) if density.ndim != 2: raise ValueError("density must be two-dimensional") if z_fill.ndim != 1 or z_fill.shape[0] < 1: raise ValueError("z_fill_fractions must be a non-empty one-dimensional array") return density[:, :, None] * z_fill[None, None, :]
[docs] def erosion_dilation_penalty( parameters: jax.Array, *, radius: float, spacing: float, beta: float = 10.0, padding: str = "reflect", ) -> jax.Array: """Return the RMS morphological close/open discrepancy.""" def operation(values: jax.Array, eta: float) -> jax.Array: return filter_and_project( values, radius=radius, spacing=spacing, beta=beta, eta=eta, padding=padding, ) opened = operation(operation(parameters, 0.99), 0.01) closed = operation(operation(parameters, 0.01), 0.99) difference = closed - opened return jnp.linalg.norm(difference) / jnp.sqrt(difference.size)
[docs] def binary_density( parameters: jax.Array, *, radius: float, spacing: float, threshold: float = 0.5, padding: str = "reflect", ) -> jax.Array: """Create the deterministic fully binary fabrication design. Thresholding occurs after the same minimum-feature filter used during optimization. It is intentionally fixed at the projection threshold; objective-aware threshold sweeps would grant a hidden per-device optimization stage. """ filtered = conic_filter(parameters, radius=radius, spacing=spacing, padding=padding) return (filtered >= threshold).astype(parameters.dtype)
def binary_density_1d( parameters: jax.Array, *, radius: float, spacing: float, threshold: float = 0.5, padding: str = "reflect", ) -> jax.Array: """Create a deterministic binary one-dimensional fabrication profile.""" filtered = conic_filter_1d( parameters, radius=radius, spacing=spacing, padding=padding, ) return (filtered >= threshold).astype(parameters.dtype) def binary_fraction(values: jax.Array, *, tolerance: float = 1e-7) -> jax.Array: """Return the fraction of entries exactly at either material endpoint.""" return jnp.mean((values <= tolerance) | (values >= 1.0 - tolerance))
[docs] def d4_symmetrize(values: jax.Array) -> jax.Array: """Average a square material map over the eight D4 transformations. This is the material symmetry used by four-port crossings: four rotations and their mirror images. Applying it after filtering and projection matches the topology transform used by Tidy3D's S-matrix example while retaining one unconstrained parameter per design pixel. """ if values.ndim != 2 or values.shape[0] != values.shape[1]: raise ValueError("D4 symmetry requires a square two-dimensional array") rotations = tuple(jnp.rot90(values, index) for index in range(4)) return sum(rotations + tuple(value[:, ::-1] for value in rotations)) / 8.0
def density_from_permittivity( permittivity: jax.Array | np.ndarray, *, low: float, high: float, shape: tuple[int, int] | None = None, ) -> jax.Array: """Initialize bounded topology parameters from an existing geometry. The material is normalized between its endpoint permittivities, clipped to the legal design interval, and optionally resampled to the optimizer grid. This is the local equivalent of initializing parameters from a base simulation rather than supplying unpublished optimized pixels. """ if high <= low: raise ValueError("high permittivity must exceed low permittivity") density = jnp.clip( (jnp.asarray(permittivity, dtype=jnp.float32) - low) / (high - low), 0.0, 1.0, ) if density.ndim != 2: raise ValueError("permittivity initializer requires a two-dimensional map") if shape is not None and tuple(density.shape) != tuple(shape): density = jax.image.resize(density, shape, method="linear") return density def linear_continuation( step: int | jax.Array, num_steps: int, *, start: float, end: float, ) -> jax.Array: """Inclusive linear continuation shared by Tidy3D-style optimizers.""" if num_steps < 1: raise ValueError("num_steps must be positive") fraction = jnp.asarray(step, dtype=jnp.float32) / max(num_steps - 1, 1) return start * (1.0 - fraction) + end * fraction def smooth_minimum( values: jax.Array, *, temperature: float | jax.Array = 1.0, normalize: bool = True, ) -> jax.Array: """Differentiable minimum for bounded multi-objective efficiencies. ``normalize=True`` removes the constant ``-tau*log(N)`` offset so equal channel efficiencies retain their physical scale. It does not change the gradient and is convenient for parity metrics. """ values = jnp.asarray(values) temperature = jnp.asarray(temperature, dtype=values.dtype) result = -temperature * jax.nn.logsumexp(-values / temperature) if normalize: result = result + temperature * jnp.log(values.size) return result def balanced_smooth_minimum( values: jax.Array, fraction: float | jax.Array, *, start_temperature: float = 1.0, end_temperature: float = 0.05, ) -> jax.Array: """Smooth-min continuation that prevents a weak objective from dying. Multi-port photonic optimizations often collapse one channel because a high-temperature soft minimum behaves almost like an average on the physical 0--1 efficiency scale. Exponentially lowering the temperature keeps the broad, stable initial gradient while progressively emphasizing the worst channel. Final validation should still use the problem's published metric; this function is an optimizer aid, not a score change. """ if start_temperature <= 0 or end_temperature <= 0: raise ValueError("smooth-min temperatures must be positive") if end_temperature > start_temperature: raise ValueError("end_temperature must not exceed start_temperature") fraction = jnp.clip(jnp.asarray(fraction, dtype=jnp.float32), 0.0, 1.0) temperature = start_temperature * (end_temperature / start_temperature) ** fraction return smooth_minimum(values, temperature=temperature, normalize=True)
[docs] def optimize_topology( objective: Callable[[jax.Array, jax.Array, jax.Array], tuple[jax.Array, Any]], initial_parameters: jax.Array | np.ndarray, config: TopologyOptimizerConfig, *, accept: Callable[[Any], bool] | None = None, callback: Callable[[TopologyStepRecord, np.ndarray, Any], None] | None = None, ) -> TopologyOptimizationResult: """Run a bounded Adam optimization with automatic projection continuation. ``objective(parameters, beta, fraction)`` returns ``(value, auxiliary)``. The scalar ``fraction`` progresses inclusively from zero to one and lets a device objective schedule additional weights without owning an optimizer loop. ``accept`` may reject numerically nonphysical candidates (for example, passive-power violations) from best-design selection. """ if config.iterations < 1: raise ValueError("iterations must be positive") if config.learning_rate <= 0: raise ValueError("learning_rate must be positive") if config.lower_bound >= config.upper_bound: raise ValueError("lower_bound must be smaller than upper_bound") if not 0.0 < config.final_learning_rate_fraction <= 1.0: raise ValueError("final_learning_rate_fraction must lie in (0, 1]") if config.early_stop_patience is not None and config.early_stop_patience < 1: raise ValueError("early_stop_patience must be positive when provided") if config.early_stop_min_delta < 0: raise ValueError("early_stop_min_delta must be nonnegative") if config.algorithm not in ("adam", "sgd", "lbfgs"): raise ValueError("algorithm must be 'adam', 'sgd', or 'lbfgs'") if not 0.0 <= config.momentum < 1.0: raise ValueError("momentum must lie in [0, 1)") if config.lbfgs_memory_size < 1: raise ValueError("lbfgs_memory_size must be positive") continuation_steps = config.iterations if config.continuation_steps is None else config.continuation_steps if continuation_steps < 1 or continuation_steps > config.iterations: raise ValueError("continuation_steps must lie between 1 and iterations") parameters = jnp.asarray(initial_parameters, dtype=jnp.float32) if config.algorithm == "adam": optimizer = optax.adam(config.learning_rate) elif config.algorithm == "sgd": optimizer = optax.sgd( config.learning_rate, momentum=config.momentum, nesterov=config.momentum > 0.0, ) else: # A fixed-step limited-memory BFGS transform avoids multiplying the # cost of an FDTD iteration by a line search. Bounds are enforced after # the quasi-Newton update, just as for Adam and SGD. optimizer = optax.chain( optax.scale_by_lbfgs(memory_size=config.lbfgs_memory_size), optax.scale(-config.learning_rate), ) optimizer_state = optimizer.init(parameters) records: list[TopologyStepRecord] = [] best_parameters = np.asarray(parameters) best_objective = -np.inf if config.maximize else np.inf best_step = 0 progress_objective = best_objective last_progress_step = 0 def optimization_step(parameters, optimizer_state, beta, fraction, learning_rate_scale): (value, auxiliary), gradient = jax.value_and_grad( objective, argnums=0, has_aux=True, )(parameters, beta, fraction) update_gradient = -gradient if config.maximize else gradient updates, next_optimizer_state = optimizer.update( update_gradient, optimizer_state, parameters, ) updates = jax.tree.map( lambda update: update * learning_rate_scale, updates, ) next_parameters = jnp.clip( optax.apply_updates(parameters, updates), config.lower_bound, config.upper_bound, ) return ( next_parameters, next_optimizer_state, value, auxiliary, jnp.linalg.norm(gradient), ) compiled_optimization_step = jax.jit(optimization_step) evaluate_objective = jax.jit(objective) use_compiled_step = True def consider( step: int, value: jax.Array, beta: jax.Array, gradient_norm: float | None, auxiliary: Any, ) -> None: nonlocal best_parameters, best_objective, best_step value_host = float(value) accepted = True if accept is None else bool(accept(auxiliary)) record = TopologyStepRecord( step=step, objective=value_host, beta=float(beta), gradient_norm=gradient_norm, accepted=accepted, ) records.append(record) better = value_host > best_objective if config.maximize else value_host < best_objective if accepted and better: best_objective = value_host best_parameters = np.asarray(parameters) best_step = step if callback is not None: callback(record, np.asarray(parameters), auxiliary) for step in range(config.iterations): fraction = jnp.asarray(min(step / max(continuation_steps - 1, 1), 1.0), dtype=jnp.float32) beta = linear_continuation( min(step, continuation_steps - 1), continuation_steps, start=config.beta_start, end=config.beta_end, ) if step >= continuation_steps and config.iterations > continuation_steps: tail_fraction = (step - continuation_steps + 1) / (config.iterations - continuation_steps) learning_rate_scale = config.final_learning_rate_fraction**tail_fraction else: learning_rate_scale = 1.0 step_arguments = ( parameters, optimizer_state, beta, fraction, jnp.asarray(learning_rate_scale, dtype=parameters.dtype), ) try: step_output = ( compiled_optimization_step(*step_arguments) if use_compiled_step else optimization_step(*step_arguments) ) except jax.errors.ConcretizationTypeError: # Preserve compatibility with objectives that intentionally perform # host-side scalar inspection or bookkeeping. Production numerical # objectives stay on the compiled path. use_compiled_step = False step_output = optimization_step(*step_arguments) next_parameters, optimizer_state, value, auxiliary, gradient_norm = step_output accepted = True if accept is None else bool(accept(auxiliary)) value_host = float(value) progress = ( value_host > progress_objective + config.early_stop_min_delta if config.maximize else value_host < progress_objective - config.early_stop_min_delta ) if accepted and progress: progress_objective = value_host last_progress_step = step consider(step, value, beta, float(gradient_norm), auxiliary) parameters = next_parameters if ( config.early_stop_patience is not None and step + 1 >= continuation_steps and step - last_progress_step >= config.early_stop_patience ): break final_fraction = jnp.asarray(1.0, dtype=jnp.float32) final_beta = jnp.asarray(config.beta_end, dtype=jnp.float32) if use_compiled_step: final_value, final_auxiliary = evaluate_objective(parameters, final_beta, final_fraction) else: final_value, final_auxiliary = objective(parameters, final_beta, final_fraction) consider( config.iterations, final_value, final_beta, None, final_auxiliary, ) return TopologyOptimizationResult( final_parameters=np.asarray(parameters), best_parameters=best_parameters, best_objective=best_objective, best_step=best_step, records=tuple(records), )