BornSim 0.2.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bornsim/__init__.py +69 -0
- bornsim/_archives.py +172 -0
- bornsim/_result_plotting.py +454 -0
- bornsim/_result_validation.py +89 -0
- bornsim/_validation.py +10 -0
- bornsim/_version.py +1 -0
- bornsim/_volume_plotting.py +395 -0
- bornsim/angular_data.py +338 -0
- bornsim/api.py +26 -0
- bornsim/directions.py +99 -0
- bornsim/ensemble.py +253 -0
- bornsim/ensemble_sampling.py +75 -0
- bornsim/geometry.py +716 -0
- bornsim/green.py +161 -0
- bornsim/grid.py +98 -0
- bornsim/material.py +46 -0
- bornsim/media.py +359 -0
- bornsim/model.py +276 -0
- bornsim/results.py +717 -0
- bornsim/rotation.py +68 -0
- bornsim/sampling.py +231 -0
- bornsim/series.py +300 -0
- bornsim/solver.py +561 -0
- bornsim/source.py +57 -0
- bornsim/units.py +81 -0
- bornsim/volume.py +319 -0
- bornsim-0.2.6.dist-info/METADATA +529 -0
- bornsim-0.2.6.dist-info/RECORD +31 -0
- bornsim-0.2.6.dist-info/WHEEL +5 -0
- bornsim-0.2.6.dist-info/licenses/LICENSE +21 -0
- bornsim-0.2.6.dist-info/top_level.txt +1 -0
bornsim/green.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Outgoing vector Green tensor with open-boundary FFT convolution.
|
|
2
|
+
|
|
3
|
+
Cubic voxels use an equal-volume spherical self cell, including the
|
|
4
|
+
longitudinal contact term, with time convention exp(-i omega t).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from .units import _refractive_index_values, validate_units, Quantity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GreenOperator:
|
|
12
|
+
"""Apply the outgoing background dyadic Green tensor by FFT convolution.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
shape : tuple of int
|
|
17
|
+
Three voxel-grid dimensions, normally supplied by a validated Volume.
|
|
18
|
+
spacing : Quantity
|
|
19
|
+
Cubic voxel spacing; length values require explicit units. Must be positive.
|
|
20
|
+
wavelength : Quantity
|
|
21
|
+
Positive, finite vacuum wavelength; length values require explicit units.
|
|
22
|
+
background_refractive_index : float
|
|
23
|
+
Positive background refractive index; must be dimensionless.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
shape : tuple of int
|
|
28
|
+
Unpadded sample dimensions.
|
|
29
|
+
padded : tuple of int
|
|
30
|
+
Dimensions doubled along each axis for open-boundary convolution.
|
|
31
|
+
spectrum : numpy.ndarray
|
|
32
|
+
Fourier-domain dyadic kernel, shape ``(*padded, 3, 3)``.
|
|
33
|
+
|
|
34
|
+
Raises
|
|
35
|
+
------
|
|
36
|
+
ValueError
|
|
37
|
+
If the wavelength is invalid or a supplied quantity has incompatible units.
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
This low-level operator assumes a valid grid and background. It evaluates
|
|
42
|
+
``k0**2 * integral(G_background(r-r') * source(r'), dV')`` with time
|
|
43
|
+
convention ``exp(-i*omega*t)``. Off-diagonal cells use midpoint quadrature.
|
|
44
|
+
The self cell is an equal-volume sphere including the longitudinal contact
|
|
45
|
+
term. Zero padding prevents periodic propagation across the sample.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
shape: tuple[int, int, int],
|
|
52
|
+
spacing: Quantity,
|
|
53
|
+
wavelength: Quantity,
|
|
54
|
+
background_refractive_index: float,
|
|
55
|
+
) -> None:
|
|
56
|
+
validate_units(
|
|
57
|
+
spacing,
|
|
58
|
+
unit="meter",
|
|
59
|
+
name="spacing",
|
|
60
|
+
scalar=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
validate_units(
|
|
64
|
+
wavelength,
|
|
65
|
+
unit="meter",
|
|
66
|
+
name="wavelength",
|
|
67
|
+
scalar=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# The FFT kernel uses numeric SI distances and a dimensionless field.
|
|
71
|
+
voxel_spacing_m = float(spacing.to("meter").magnitude)
|
|
72
|
+
|
|
73
|
+
vacuum_wavelength_m = float(wavelength.to("meter").magnitude)
|
|
74
|
+
|
|
75
|
+
background_refractive_index = _refractive_index_values(
|
|
76
|
+
value=background_refractive_index,
|
|
77
|
+
name="background_refractive_index",
|
|
78
|
+
scalar=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not np.isfinite(vacuum_wavelength_m) or vacuum_wavelength_m <= 0:
|
|
82
|
+
raise ValueError("wavelength must be finite and positive.")
|
|
83
|
+
|
|
84
|
+
self.shape = tuple(shape)
|
|
85
|
+
|
|
86
|
+
self.padded = tuple(2 * n for n in shape)
|
|
87
|
+
|
|
88
|
+
k0 = 2 * np.pi / vacuum_wavelength_m
|
|
89
|
+
|
|
90
|
+
k = k0 * background_refractive_index
|
|
91
|
+
|
|
92
|
+
axes = [
|
|
93
|
+
np.where(np.arange(2 * n) < n, np.arange(2 * n), np.arange(2 * n) - 2 * n) * voxel_spacing_m for n in shape
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
displacement_between_voxel_centres_m = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1)
|
|
97
|
+
|
|
98
|
+
distance_between_voxel_centres_m = np.linalg.norm(displacement_between_voxel_centres_m, axis=-1)
|
|
99
|
+
|
|
100
|
+
nonzero_distance_m = np.where(
|
|
101
|
+
distance_between_voxel_centres_m == 0, voxel_spacing_m, distance_between_voxel_centres_m
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
direction_between_voxels = displacement_between_voxel_centres_m / nonzero_distance_m[..., None]
|
|
105
|
+
|
|
106
|
+
background_phase = k * nonzero_distance_m
|
|
107
|
+
|
|
108
|
+
scalar = np.exp(1j * background_phase) / (4 * np.pi * nonzero_distance_m)
|
|
109
|
+
|
|
110
|
+
isotropic = 1 + 1j / background_phase - 1 / background_phase**2
|
|
111
|
+
|
|
112
|
+
longitudinal = -1 - 3j / background_phase + 3 / background_phase**2
|
|
113
|
+
|
|
114
|
+
kernel = scalar[..., None, None] * (
|
|
115
|
+
isotropic[..., None, None] * np.eye(3)
|
|
116
|
+
+ longitudinal[..., None, None]
|
|
117
|
+
* direction_between_voxels[..., :, None]
|
|
118
|
+
* direction_between_voxels[..., None, :]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
kernel *= k0**2 * voxel_spacing_m**3
|
|
122
|
+
|
|
123
|
+
# Integrated Green tensor over an equal-volume sphere, including
|
|
124
|
+
# -I delta(r)/(3 k^2). Use a series to avoid cancellation at small ka.
|
|
125
|
+
a = (3 * voxel_spacing_m**3 / (4 * np.pi)) ** (1 / 3)
|
|
126
|
+
|
|
127
|
+
ka = k * a
|
|
128
|
+
|
|
129
|
+
if abs(ka) < 1e-3:
|
|
130
|
+
radial = ka**2 / 2 + 1j * ka**3 / 3 - ka**4 / 8 - 1j * ka**5 / 30
|
|
131
|
+
else:
|
|
132
|
+
radial = np.exp(1j * ka) * (1 - 1j * ka) - 1
|
|
133
|
+
|
|
134
|
+
kernel[0, 0, 0] = np.eye(3) * k0**2 * (2 * radial - 1) / (3 * k**2)
|
|
135
|
+
|
|
136
|
+
self.spectrum = np.fft.fftn(kernel, axes=(0, 1, 2))
|
|
137
|
+
|
|
138
|
+
def apply(self, *, source: np.ndarray) -> np.ndarray:
|
|
139
|
+
"""Propagate a voxel source field through the background Green tensor.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
source : numpy.ndarray
|
|
144
|
+
Numeric source values, shape (nx, ny, nz, polarization, 3).
|
|
145
|
+
Born iteration supplies linearized dielectric contrast times field.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
field : numpy.ndarray
|
|
150
|
+
Complex propagated field with the same shape and field units as source.
|
|
151
|
+
The padded convolution is cropped to the original sample grid.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
# source shape: (nx, ny, nz, polarization, vector component)
|
|
155
|
+
transformed = np.fft.fftn(source, s=self.padded, axes=(0, 1, 2))
|
|
156
|
+
|
|
157
|
+
propagated = np.einsum("...ij,...pj->...pi", self.spectrum, transformed)
|
|
158
|
+
|
|
159
|
+
result = np.fft.ifftn(propagated, axes=(0, 1, 2))
|
|
160
|
+
|
|
161
|
+
return result[tuple(slice(0, n) for n in self.shape)]
|
bornsim/grid.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Reusable centred cubic voxel grids with unit-bearing coordinates."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
import warnings
|
|
6
|
+
from ._validation import _integer
|
|
7
|
+
from .units import Quantity, validate_units
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, kw_only=True)
|
|
11
|
+
class Grid:
|
|
12
|
+
"""Define the spatial discretization once for generation and scattering.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
shape : tuple of int
|
|
17
|
+
Three explicitly chosen voxel counts, each from 2 to 32.
|
|
18
|
+
spacing : Quantity
|
|
19
|
+
Positive, finite cubic voxel width, with its supplied length units. A spacing must be supplied.
|
|
20
|
+
|
|
21
|
+
Notes
|
|
22
|
+
-----
|
|
23
|
+
Voxel centres are ``(i - (N - 1)/2) * spacing`` on each axis. The box
|
|
24
|
+
extends half a voxel beyond its outer centres. It describes the sampled
|
|
25
|
+
domain, not an additional dielectric boundary. Background refractive index belongs
|
|
26
|
+
to the medium or volume, rather than the spatial grid.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
shape: tuple[int, int, int]
|
|
30
|
+
spacing: Quantity
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not isinstance(self.shape, (tuple, list)) or len(self.shape) != 3:
|
|
34
|
+
raise ValueError("shape must have three axes.")
|
|
35
|
+
|
|
36
|
+
shape = tuple(_integer(value=n, name="axis size", low=2, high=32) for n in self.shape)
|
|
37
|
+
|
|
38
|
+
validate_units(
|
|
39
|
+
self.spacing,
|
|
40
|
+
unit="meter",
|
|
41
|
+
name="spacing",
|
|
42
|
+
scalar=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not np.isfinite(self.spacing) or self.spacing <= 0:
|
|
46
|
+
raise ValueError("spacing must be finite and positive.")
|
|
47
|
+
|
|
48
|
+
object.__setattr__(self, "shape", shape)
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return f"Grid(shape={self.shape}, spacing={self.spacing:~g})"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def positions(self) -> Quantity:
|
|
55
|
+
"""Unit-bearing voxel-centre coordinates, shape ``(*shape, 3)``."""
|
|
56
|
+
|
|
57
|
+
axes = [np.arange(n) - (n - 1) / 2 for n in self.shape]
|
|
58
|
+
|
|
59
|
+
return np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1) * self.spacing
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def volume(self) -> Quantity:
|
|
63
|
+
"""Physical voxel-box volume in cubic metres."""
|
|
64
|
+
|
|
65
|
+
return np.prod(self.shape) * self.spacing**3
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def metadata(self) -> dict[str, object]:
|
|
69
|
+
"""Fresh JSON-compatible grid settings in SI units."""
|
|
70
|
+
|
|
71
|
+
return {"shape": list(self.shape), "spacing_m": float(self.spacing.to("meter").magnitude)}
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def _resolve(
|
|
75
|
+
cls,
|
|
76
|
+
*,
|
|
77
|
+
grid: "Grid | None" = None,
|
|
78
|
+
shape: tuple[int, int, int] | None = None,
|
|
79
|
+
spacing: Quantity | None = None,
|
|
80
|
+
) -> "Grid":
|
|
81
|
+
if grid is not None:
|
|
82
|
+
if not isinstance(grid, cls):
|
|
83
|
+
raise TypeError("grid must be a Grid.")
|
|
84
|
+
|
|
85
|
+
if shape is not None or spacing is not None:
|
|
86
|
+
raise ValueError("Supply grid or shape/spacing, not both.")
|
|
87
|
+
|
|
88
|
+
return grid
|
|
89
|
+
|
|
90
|
+
if shape is None or spacing is None:
|
|
91
|
+
raise ValueError("Supply grid or both shape and spacing; physical grid settings have no defaults.")
|
|
92
|
+
|
|
93
|
+
warnings.warn("Use Grid instead of shape/spacing keywords.", DeprecationWarning, stacklevel=3)
|
|
94
|
+
|
|
95
|
+
return cls(
|
|
96
|
+
shape=shape,
|
|
97
|
+
spacing=spacing,
|
|
98
|
+
)
|
bornsim/material.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Immutable, reusable homogeneous optical materials."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .units import _refractive_index_values
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, kw_only=True)
|
|
9
|
+
class Material:
|
|
10
|
+
"""A nondispersive real refractive index shared by any number of shapes.
|
|
11
|
+
|
|
12
|
+
``refractive_index`` is an absolute positive dimensionless refractive index, not a
|
|
13
|
+
contrast. Supply a plain number; quantities are rejected. Absorption and wavelength dispersion are not implemented.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
refractive_index: float
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
refractive_index = _refractive_index_values(value=self.refractive_index, name="refractive_index", scalar=True)
|
|
20
|
+
|
|
21
|
+
if not np.isfinite(refractive_index) or refractive_index <= 0:
|
|
22
|
+
raise ValueError("refractive_index must be finite and positive.")
|
|
23
|
+
|
|
24
|
+
object.__setattr__(self, "refractive_index", refractive_index)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def metadata(self):
|
|
28
|
+
"""Fresh JSON-compatible material description."""
|
|
29
|
+
|
|
30
|
+
return {"refractive_index": self.refractive_index}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _resolve(cls, *, material=None, refractive_index=None):
|
|
34
|
+
if material is not None:
|
|
35
|
+
if not isinstance(material, cls):
|
|
36
|
+
raise TypeError("material must be a Material.")
|
|
37
|
+
|
|
38
|
+
if refractive_index is not None:
|
|
39
|
+
raise ValueError("Supply material or refractive_index, not both.")
|
|
40
|
+
|
|
41
|
+
return material
|
|
42
|
+
|
|
43
|
+
if refractive_index is None:
|
|
44
|
+
raise ValueError("Supply material or refractive_index.")
|
|
45
|
+
|
|
46
|
+
return cls(refractive_index=refractive_index)
|
bornsim/media.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Numerical random-medium statistics, independent of analytical solutions."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
import numpy as np
|
|
7
|
+
from .units import _refractive_index_values, Quantity, validate_units, ureg, _dimensionless
|
|
8
|
+
from ._validation import _integer
|
|
9
|
+
from .grid import Grid
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .volume import Volume
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Medium(ABC):
|
|
16
|
+
"""Abstract interface for media that can be sampled into a finite volume.
|
|
17
|
+
|
|
18
|
+
Instantiate :class:`RandomMedium` or :class:`bornsim.StructuredMedium`,
|
|
19
|
+
not this base class. Concrete media specify a uniform ``background_refractive_index``
|
|
20
|
+
and implement :meth:`to_volume` using cubic voxels, SI spacing, and centred
|
|
21
|
+
coordinates. The resulting field stores ``delta_refractive_index = n(r) - n0``;
|
|
22
|
+
propagation retains ``epsilon_r = n0**2 + 2*n0*delta_refractive_index``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
background_refractive_index: float | None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_random(self):
|
|
29
|
+
"""Whether independent generation seeds represent random realizations."""
|
|
30
|
+
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def metadata(self):
|
|
35
|
+
"""Return a fresh JSON-compatible description with numeric SI values.
|
|
36
|
+
|
|
37
|
+
Concrete media extend this dictionary with their own statistics or
|
|
38
|
+
geometry. Generation seeds and grids belong to individual volumes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
return {"background_refractive_index": self.background_refractive_index}
|
|
42
|
+
|
|
43
|
+
def add_background(self, *, refractive_index=None, medium=None, material=None):
|
|
44
|
+
"""Configure a composable medium's background in place.
|
|
45
|
+
|
|
46
|
+
StructuredMedium accepts exactly one of a positive uniform ``refractive_index``
|
|
47
|
+
or a RandomMedium supplied as ``medium``. Statistical media describe
|
|
48
|
+
homogeneous distributions; use StructuredMedium to compose material.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
raise TypeError("Use StructuredMedium to compose backgrounds and structures.")
|
|
52
|
+
|
|
53
|
+
def add_structures(self, *structures):
|
|
54
|
+
"""Append positional material shapes to a composable medium in place.
|
|
55
|
+
|
|
56
|
+
StructuredMedium supports Layer, Sphere, Ellipsoid, Box, and Cylinder.
|
|
57
|
+
Later structures replace earlier material wherever their masks overlap.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
raise TypeError("Use StructuredMedium to compose backgrounds and structures.")
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def to_volume(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
grid: Grid | None = None,
|
|
67
|
+
shape: tuple[int, int, int] | None = None,
|
|
68
|
+
spacing: Quantity | None = None,
|
|
69
|
+
seed: int = 0,
|
|
70
|
+
) -> "Volume":
|
|
71
|
+
"""Return a finite voxel sample; concrete classes define generation."""
|
|
72
|
+
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, kw_only=True)
|
|
77
|
+
class RandomMedium(Medium):
|
|
78
|
+
r"""Define a Gaussian random refractive index field by its isotropic spatial spectrum.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
background_refractive_index : float
|
|
83
|
+
Positive uniform background refractive index (required).
|
|
84
|
+
refractive_index_std : float
|
|
85
|
+
Nonnegative ensemble standard deviation, not variance (required).
|
|
86
|
+
correlation_length : Quantity
|
|
87
|
+
Positive quantity with explicit length units (required).
|
|
88
|
+
correlation : {'gaussian', 'exponential', 'matern'}
|
|
89
|
+
Explicitly chosen spatial covariance family. This does not change
|
|
90
|
+
the Gaussian probability distribution of the refractive index field.
|
|
91
|
+
smoothness : float or Quantity
|
|
92
|
+
Positive finite Matérn parameter nu; required for 'matern'.
|
|
93
|
+
Other covariance families do not require this parameter. Larger values give smoother continuum fields.
|
|
94
|
+
|
|
95
|
+
Notes
|
|
96
|
+
-----
|
|
97
|
+
With ell = correlation_length and x = sqrt(2*nu)*r/ell, Whittle–Matérn
|
|
98
|
+
covariance is ``C_n(r) = sigma_n**2 * 2**(1-nu)/Gamma(nu) * x**nu*K_nu(x)``.
|
|
99
|
+
Its three-dimensional spectrum is proportional to
|
|
100
|
+
``(1 + (q*ell)**2/(2*nu))**(-nu-3/2)``. Thus nu = 0.5 reproduces exponential
|
|
101
|
+
covariance with the same ell. Gaussian covariance uses
|
|
102
|
+
``exp(-r**2/(2*ell**2))``; exponential uses ``exp(-r/ell)``.
|
|
103
|
+
|
|
104
|
+
:func:`bornsim.random_volume` normalizes the discrete spectrum to preserve
|
|
105
|
+
expected point variance, retains the DC mode, and crops a doubled synthesis
|
|
106
|
+
box. Neither individual sample means nor variances are forced to match the
|
|
107
|
+
ensemble statistics. Grid resolution and box size affect sampled covariance.
|
|
108
|
+
This class is for numerical generation and ensembles, not analytical solve.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
background_refractive_index: float
|
|
112
|
+
refractive_index_std: float
|
|
113
|
+
correlation_length: Quantity
|
|
114
|
+
correlation: str
|
|
115
|
+
smoothness: Quantity | float | None = None
|
|
116
|
+
|
|
117
|
+
def __post_init__(self) -> None:
|
|
118
|
+
validate_units(
|
|
119
|
+
self.correlation_length,
|
|
120
|
+
unit="meter",
|
|
121
|
+
name="correlation_length",
|
|
122
|
+
scalar=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not np.isfinite(self.correlation_length) or self.correlation_length <= 0:
|
|
126
|
+
raise ValueError("correlation_length must be finite and positive.")
|
|
127
|
+
|
|
128
|
+
for name in ("background_refractive_index", "refractive_index_std"):
|
|
129
|
+
value = _refractive_index_values(
|
|
130
|
+
value=getattr(self, name),
|
|
131
|
+
name=name,
|
|
132
|
+
scalar=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not np.isfinite(value) or value < 0 or (name == "background_refractive_index" and value == 0):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{name} must be finite and {'nonnegative' if name == 'refractive_index_std' else 'positive'}."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if self.correlation not in ("gaussian", "exponential", "matern"):
|
|
141
|
+
raise ValueError("correlation must be gaussian, exponential, or matern.")
|
|
142
|
+
|
|
143
|
+
if self.smoothness is None:
|
|
144
|
+
if self.correlation == "matern":
|
|
145
|
+
raise ValueError("smoothness must be explicitly supplied for matern correlation.")
|
|
146
|
+
else:
|
|
147
|
+
smoothness = _dimensionless(
|
|
148
|
+
value=self.smoothness,
|
|
149
|
+
name="smoothness",
|
|
150
|
+
scalar=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if not np.isfinite(smoothness) or smoothness <= 0:
|
|
154
|
+
raise ValueError("smoothness must be finite and positive.")
|
|
155
|
+
|
|
156
|
+
object.__setattr__(self, "smoothness", smoothness)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def is_random(self):
|
|
160
|
+
"""Statistical media support independent seeded realizations."""
|
|
161
|
+
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def metadata(self):
|
|
166
|
+
"""Return independent SI statistics for result provenance.
|
|
167
|
+
|
|
168
|
+
Lengths use the ``_m`` suffix. Smoothness is retained for all covariance
|
|
169
|
+
families, including inherited analytical media, preserving the saved
|
|
170
|
+
result metadata convention. This describes ensemble statistics rather
|
|
171
|
+
than a particular seed or a sample's measured mean and variance.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
**super().metadata,
|
|
176
|
+
"refractive_index_std": self.refractive_index_std,
|
|
177
|
+
"correlation_length_m": float(self.correlation_length.to("meter").magnitude),
|
|
178
|
+
"correlation": self.correlation,
|
|
179
|
+
"smoothness": self.smoothness,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
def spectral_weight(self, *, q_squared: Quantity) -> np.ndarray:
|
|
183
|
+
"""Return dimensionless spectrum shape at squared wavenumbers in m^-2.
|
|
184
|
+
|
|
185
|
+
The zero-wavenumber weight is one. This is not the normalized continuum
|
|
186
|
+
power spectral density; the synthesis code supplies discrete variance
|
|
187
|
+
normalization. Inputs must be finite and nonnegative.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
validate_units(
|
|
191
|
+
q_squared,
|
|
192
|
+
unit="1 / meter**2",
|
|
193
|
+
name="q_squared",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if np.any(~np.isfinite(q_squared)) or np.any(q_squared < 0):
|
|
197
|
+
raise ValueError("q_squared must be finite and nonnegative.")
|
|
198
|
+
|
|
199
|
+
scaled = (q_squared * self.correlation_length**2).to("dimensionless").magnitude
|
|
200
|
+
|
|
201
|
+
if self.correlation == "gaussian":
|
|
202
|
+
return np.exp(-scaled / 2)
|
|
203
|
+
|
|
204
|
+
if self.correlation == "exponential":
|
|
205
|
+
return (1 + scaled) ** -2
|
|
206
|
+
|
|
207
|
+
assert self.smoothness is not None
|
|
208
|
+
|
|
209
|
+
# log1p avoids loss of precision for small q and large smoothness.
|
|
210
|
+
return np.exp(-(self.smoothness + 1.5) * np.log1p(scaled / (2 * self.smoothness)))
|
|
211
|
+
|
|
212
|
+
def to_volume(
|
|
213
|
+
self,
|
|
214
|
+
*,
|
|
215
|
+
grid: Grid | None = None,
|
|
216
|
+
shape: tuple[int, int, int] | None = None,
|
|
217
|
+
spacing: Quantity | None = None,
|
|
218
|
+
seed: int = 0,
|
|
219
|
+
) -> "Volume":
|
|
220
|
+
"""Generate a seeded Gaussian random refractive index field with a chosen covariance.
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
grid : Grid, optional
|
|
225
|
+
Explicit shared spatial configuration. Cannot be
|
|
226
|
+
combined with the legacy shape and spacing keywords.
|
|
227
|
+
shape : tuple of int, optional
|
|
228
|
+
Three sample dimensions, each from 2 to 32. Required with spacing when grid is omitted.
|
|
229
|
+
spacing : Quantity, optional
|
|
230
|
+
Positive, finite cubic voxel spacing; length values require explicit units.
|
|
231
|
+
Must be supplied with shape when grid is omitted.
|
|
232
|
+
seed : int, optional
|
|
233
|
+
Reproducible random seed, from 0 to 2**32 - 1. Default is 0.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
volume : Volume
|
|
238
|
+
Cropped fluctuation field with unit-bearing spacing and dimensionless background refractive index.
|
|
239
|
+
|
|
240
|
+
Raises
|
|
241
|
+
------
|
|
242
|
+
ValueError
|
|
243
|
+
If shape, spacing, units, or seed are invalid, or the generated
|
|
244
|
+
linearized relative permittivity is nonpositive.
|
|
245
|
+
|
|
246
|
+
Notes
|
|
247
|
+
-----
|
|
248
|
+
The probability distribution is Gaussian for every spatial covariance
|
|
249
|
+
choice. Spectral synthesis uses a periodic box doubled along every axis,
|
|
250
|
+
then crops its first half. The discrete spectrum approximates the continuum
|
|
251
|
+
covariance and normalizes the expected point variance. The DC component is
|
|
252
|
+
retained; individual samples are never recentered or rescaled. Resolution
|
|
253
|
+
and finite synthesis-box size can alter the sampled covariance.
|
|
254
|
+
|
|
255
|
+
Examples
|
|
256
|
+
--------
|
|
257
|
+
>>> from bornsim import RandomMedium
|
|
258
|
+
...
|
|
259
|
+
>>> from bornsim.units import ureg
|
|
260
|
+
...
|
|
261
|
+
>>> medium = RandomMedium(
|
|
262
|
+
... correlation="gaussian",
|
|
263
|
+
... background_refractive_index=1.33,
|
|
264
|
+
... refractive_index_std=0.01,
|
|
265
|
+
... correlation_length=100e-9 * ureg.meter,
|
|
266
|
+
... )
|
|
267
|
+
|
|
268
|
+
...
|
|
269
|
+
>>> volume = medium.to_volume(
|
|
270
|
+
... shape=(4, 4, 4),
|
|
271
|
+
... seed=42,
|
|
272
|
+
... spacing=50e-9 * ureg.meter,
|
|
273
|
+
... )
|
|
274
|
+
>>> volume.delta_refractive_index.shape
|
|
275
|
+
(4, 4, 4)
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
from .volume import Volume
|
|
279
|
+
|
|
280
|
+
grid = Grid._resolve(
|
|
281
|
+
grid=grid,
|
|
282
|
+
shape=shape,
|
|
283
|
+
spacing=spacing,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# FFT frequencies require numeric spacing in a chosen length unit.
|
|
287
|
+
sample_grid_shape = grid.shape
|
|
288
|
+
|
|
289
|
+
voxel_spacing_m = float(grid.spacing.to("meter").magnitude)
|
|
290
|
+
|
|
291
|
+
seed = _integer(
|
|
292
|
+
value=seed,
|
|
293
|
+
name="seed",
|
|
294
|
+
low=0,
|
|
295
|
+
high=2**32 - 1,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
synthesis_grid_shape = tuple(2 * voxel_count for voxel_count in sample_grid_shape)
|
|
299
|
+
|
|
300
|
+
axis_wavenumbers_per_meter = [
|
|
301
|
+
2 * np.pi * np.fft.fftfreq(voxel_count, voxel_spacing_m) for voxel_count in synthesis_grid_shape
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
squared_wavenumbers_per_meter_squared = np.zeros(synthesis_grid_shape)
|
|
305
|
+
|
|
306
|
+
for wavenumber_component_per_meter in np.meshgrid(*axis_wavenumbers_per_meter, indexing="ij"):
|
|
307
|
+
squared_wavenumbers_per_meter_squared += wavenumber_component_per_meter**2
|
|
308
|
+
|
|
309
|
+
normalized_spectral_weights = self.spectral_weight(
|
|
310
|
+
q_squared=squared_wavenumbers_per_meter_squared * (1 / ureg.meter**2)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Normalize expected point variance, not each sample's spatial variance.
|
|
314
|
+
normalized_spectral_weights /= normalized_spectral_weights.mean()
|
|
315
|
+
|
|
316
|
+
uncorrelated_gaussian_noise = np.random.default_rng(seed).normal(size=synthesis_grid_shape)
|
|
317
|
+
|
|
318
|
+
synthesized_refractive_index_fluctuations = (
|
|
319
|
+
np.fft.ifftn(np.fft.fftn(uncorrelated_gaussian_noise) * np.sqrt(normalized_spectral_weights)).real
|
|
320
|
+
* self.refractive_index_std
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
sample_crop = tuple(slice(0, voxel_count) for voxel_count in sample_grid_shape)
|
|
324
|
+
|
|
325
|
+
return Volume(
|
|
326
|
+
delta_refractive_index=synthesized_refractive_index_fluctuations[sample_crop],
|
|
327
|
+
grid=grid,
|
|
328
|
+
background_refractive_index=self.background_refractive_index,
|
|
329
|
+
medium=self,
|
|
330
|
+
seed=seed,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def random_volume(
|
|
335
|
+
*,
|
|
336
|
+
medium: RandomMedium,
|
|
337
|
+
grid: Grid | None = None,
|
|
338
|
+
shape: tuple[int, int, int] | None = None,
|
|
339
|
+
spacing: Quantity | None = None,
|
|
340
|
+
seed: int = 0,
|
|
341
|
+
) -> "Volume":
|
|
342
|
+
"""Generate a seeded Gaussian refractive index field via :meth:`RandomMedium.to_volume`.
|
|
343
|
+
|
|
344
|
+
Medium specifies the covariance and ensemble statistics. Shape contains
|
|
345
|
+
three integers from 2 to 32; spacing is a positive SI length or quantity.
|
|
346
|
+
Seed lies in 0 to 2**32-1. Expected point variance is preserved without
|
|
347
|
+
forcing individual sample means or variances. See the medium method for
|
|
348
|
+
the spectral synthesis equations and finite-box conventions.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
if not isinstance(medium, RandomMedium):
|
|
352
|
+
raise TypeError("medium must be a RandomMedium.")
|
|
353
|
+
|
|
354
|
+
return medium.to_volume(
|
|
355
|
+
grid=grid,
|
|
356
|
+
shape=shape,
|
|
357
|
+
spacing=spacing,
|
|
358
|
+
seed=seed,
|
|
359
|
+
)
|