Source code for bornsim.medium.random_spheres

"""Seeded collections of identical, nonoverlapping spheres."""

from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from .base import Medium
from ..grid import Grid
from ..units import Quantity, _integer, validate_units, _refractive_index_values

if TYPE_CHECKING:
    from ..volume import Volume


[docs] @dataclass(frozen=True, kw_only=True) class RandomSphereMedium(Medium): """Place a fixed number of identical spheres by sequential rejection sampling. Centres are proposed uniformly inside the grid box, inset by the radius so entire spheres remain inside. Proposals overlapping an accepted sphere are rejected. This defines a sequential placement distribution, rather than an equilibrium hard-sphere distribution. Dense configurations may fail to fit within ``max_placement_attempts``; failure raises instead of returning fewer spheres. Voxelization samples material at voxel centres, so an unresolved sphere can occupy no voxels. Both refractive indices are required plain numbers. Radius requires scalar length units. Each realization records its medium and generation seed. """ background_refractive_index: float sphere_refractive_index: float radius: Quantity sphere_count: int max_placement_attempts: int = 10000 def __post_init__(self) -> None: validate_units( self.radius, unit="meter", name="radius", scalar=True, ) if not np.isfinite(self.radius) or self.radius <= 0: raise ValueError("radius must be finite and positive.") for name in ("background_refractive_index", "sphere_refractive_index"): refractive_index = _refractive_index_values( value=getattr(self, name), name=name, scalar=True, ) if not np.isfinite(refractive_index) or refractive_index <= 0: raise ValueError(f"{name} must be finite and positive.") if 2 * self.sphere_refractive_index <= self.background_refractive_index: raise ValueError("sphere_refractive_index gives nonpositive linearized permittivity.") _integer( value=self.sphere_count, name="sphere_count", low=1, high=2**31 - 1, ) _integer( value=self.max_placement_attempts, name="max_placement_attempts", low=self.sphere_count, high=2**31 - 1, ) @property def is_random(self) -> bool: return True @property def metadata(self) -> dict[str, object]: return { **super().metadata, "model": "random_spheres", "sphere_refractive_index": self.sphere_refractive_index, "radius_m": float(self.radius.to("meter").magnitude), "sphere_count": self.sphere_count, "max_placement_attempts": self.max_placement_attempts, "placement": "sequential_nonoverlapping_contained", }
[docs] def sphere_centers(self, *, grid: Grid, seed: int = 0) -> Quantity: """Return reproducible sphere centres with shape (sphere_count, 3).""" grid = Grid._resolve(grid=grid) seed = _integer( value=seed, name="seed", low=0, high=2**32 - 1, ) available_half_width = np.asarray(grid.shape) * grid.spacing / 2 - self.radius if np.any(available_half_width < 0): raise ValueError("The sphere diameter must fit inside every grid dimension.") sphere_volume = self.sphere_count * (4 * np.pi / 3) * self.radius**3 if sphere_volume > grid.volume: raise ValueError("Nonoverlapping spheres cannot fit: their total volume exceeds the grid volume.") generator = np.random.default_rng(seed) accepted_centers = np.zeros((self.sphere_count, 3)) * grid.spacing accepted_count = 0 for _ in range(self.max_placement_attempts): proposed_center = generator.uniform(-1, 1, size=3) * available_half_width squared_center_distances = np.sum((accepted_centers[:accepted_count] - proposed_center) ** 2, axis=1) if np.any(squared_center_distances < (2 * self.radius) ** 2): continue accepted_centers[accepted_count] = proposed_center accepted_count += 1 if accepted_count == self.sphere_count: return accepted_centers raise ValueError( f"Could only place {accepted_count} of {self.sphere_count} nonoverlapping spheres " f"after {self.max_placement_attempts} proposals; reduce sphere_count or radius, " "or increase max_placement_attempts." )
[docs] def to_volume(self, *, grid: Grid, seed: int = 0) -> "Volume": """Sample the sphere material at voxel centres on an explicit grid.""" from ..volume import Volume sphere_centers = self.sphere_centers( grid=grid, seed=seed, ) positions = grid.positions delta_refractive_index = np.zeros(grid.shape) for sphere_center in sphere_centers: sphere_mask = np.sum((positions - sphere_center) ** 2, axis=-1) <= self.radius**2 delta_refractive_index[sphere_mask] = self.sphere_refractive_index - self.background_refractive_index return Volume( delta_refractive_index=delta_refractive_index, background_refractive_index=self.background_refractive_index, grid=grid, medium=self, seed=seed, )