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/rotation.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Proper three-dimensional rotations for immutable material shapes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .units import Quantity, validate_units, _dimensionless
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, kw_only=True)
|
|
9
|
+
class Rotation:
|
|
10
|
+
"""Define an active right-handed rotation about a dimensionless axis.
|
|
11
|
+
|
|
12
|
+
angle accepts angular quantities; angular values require explicit units. The axis is
|
|
13
|
+
normalized. matrix maps local column coordinates into global coordinates.
|
|
14
|
+
No numerical scattering or voxel resampling is performed by this class.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
axis: tuple[float, float, float]
|
|
18
|
+
angle: Quantity
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
axis = _dimensionless(value=self.axis, name="axis")
|
|
22
|
+
|
|
23
|
+
validate_units(
|
|
24
|
+
self.angle,
|
|
25
|
+
unit="radian",
|
|
26
|
+
name="angle",
|
|
27
|
+
scalar=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if axis.shape != (3,) or np.any(~np.isfinite(axis)) or np.linalg.norm(axis) == 0:
|
|
31
|
+
raise ValueError("axis must be a finite, nonzero three-vector.")
|
|
32
|
+
|
|
33
|
+
if not np.isfinite(self.angle):
|
|
34
|
+
raise ValueError("angle must be finite.")
|
|
35
|
+
|
|
36
|
+
object.__setattr__(self, "axis", tuple(axis / np.linalg.norm(axis)))
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def matrix(self) -> np.ndarray:
|
|
40
|
+
"""Return Rodrigues' active rotation matrix, shape (3, 3)."""
|
|
41
|
+
|
|
42
|
+
axis = np.asarray(self.axis)
|
|
43
|
+
|
|
44
|
+
x, y, z = axis
|
|
45
|
+
|
|
46
|
+
skew = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]])
|
|
47
|
+
|
|
48
|
+
cosine = float(np.cos(self.angle))
|
|
49
|
+
|
|
50
|
+
sine = float(np.sin(self.angle))
|
|
51
|
+
|
|
52
|
+
return cosine * np.eye(3) + (1 - cosine) * np.outer(axis, axis) + sine * skew
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _matrix(*, rotation):
|
|
56
|
+
matrix = rotation.matrix if isinstance(rotation, Rotation) else _dimensionless(value=rotation, name="rotation")
|
|
57
|
+
|
|
58
|
+
invalid = (
|
|
59
|
+
matrix.shape != (3, 3)
|
|
60
|
+
or np.any(~np.isfinite(matrix))
|
|
61
|
+
or not np.allclose(matrix.T @ matrix, np.eye(3), rtol=0, atol=1e-10)
|
|
62
|
+
or not np.isclose(np.linalg.det(matrix), 1, rtol=0, atol=1e-10)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if invalid:
|
|
66
|
+
raise ValueError("rotation must be a proper orthonormal 3-by-3 matrix or Rotation.")
|
|
67
|
+
|
|
68
|
+
return matrix
|
bornsim/sampling.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Full directional output sampling and independent solid-angle quadrature."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import cast
|
|
5
|
+
import numpy as np
|
|
6
|
+
import warnings
|
|
7
|
+
from ._validation import _integer
|
|
8
|
+
from .directions import Directions
|
|
9
|
+
from .units import Quantity, validate_units, ureg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, kw_only=True, init=False)
|
|
13
|
+
class AngularSampling:
|
|
14
|
+
"""Configure directional plots and their solid-angle normalization.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
angles : array_like or Quantity, optional
|
|
19
|
+
Output polar angles theta, from 0 to pi, with 1 to 181 entries.
|
|
20
|
+
Angular values require explicit units. Cannot be combined with start, end or
|
|
21
|
+
n_points. Omit to generate an evenly spaced range.
|
|
22
|
+
start, end : Quantity, optional
|
|
23
|
+
Inclusive range endpoints between 0 and pi. Angular values require explicit units.
|
|
24
|
+
Defaults are 0 and pi; descending ranges are supported.
|
|
25
|
+
n_points : int, optional
|
|
26
|
+
Number of evenly spaced output angles, from 1 to 181. Default is 121.
|
|
27
|
+
A single point samples start.
|
|
28
|
+
polar_samples : int, optional
|
|
29
|
+
Gauss-Legendre integration nodes in cos(theta), from 16 to 128.
|
|
30
|
+
Default is 32. These are independent of the output angles.
|
|
31
|
+
azimuth_samples : int, optional
|
|
32
|
+
Uniform azimuth directions in [0, 2*pi), from 4 to 32. Default is 8.
|
|
33
|
+
Used for both directional output and solid-angle integration.
|
|
34
|
+
|
|
35
|
+
Notes
|
|
36
|
+
-----
|
|
37
|
+
Intensities are retained at every output (theta, phi). One-dimensional
|
|
38
|
+
curves select sampled meridians; azimuth averages require an explicit
|
|
39
|
+
azimuth_average() call. 3D phase surfaces use directional data.
|
|
40
|
+
Integration uses ``dOmega = dphi * d(cos(theta))``. Each Born order's
|
|
41
|
+
phase density is its intensity divided by its integrated coefficient.
|
|
42
|
+
Refine both quadratures independently of output plot resolution.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
angles: Quantity
|
|
46
|
+
polar_samples: int = 32
|
|
47
|
+
azimuth_samples: int = 8
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
angles: Quantity | None = None,
|
|
53
|
+
start: Quantity | None = None,
|
|
54
|
+
end: Quantity | None = None,
|
|
55
|
+
n_points: int | None = None,
|
|
56
|
+
polar_samples: int = 32,
|
|
57
|
+
azimuth_samples: int = 8,
|
|
58
|
+
) -> None:
|
|
59
|
+
if angles is not None:
|
|
60
|
+
if any(value is not None for value in (start, end, n_points)):
|
|
61
|
+
raise ValueError("Supply angles or start/end/n_points, not both.")
|
|
62
|
+
else:
|
|
63
|
+
start = 0 * ureg.radian if start is None else start
|
|
64
|
+
|
|
65
|
+
validate_units(
|
|
66
|
+
start,
|
|
67
|
+
unit="radian",
|
|
68
|
+
name="start",
|
|
69
|
+
scalar=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
end = np.pi * ureg.radian if end is None else end
|
|
73
|
+
|
|
74
|
+
validate_units(
|
|
75
|
+
end,
|
|
76
|
+
unit="radian",
|
|
77
|
+
name="end",
|
|
78
|
+
scalar=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
invalid_endpoints = not (
|
|
82
|
+
np.isfinite(start)
|
|
83
|
+
and np.isfinite(end)
|
|
84
|
+
and 0 <= start <= np.pi * ureg.radian
|
|
85
|
+
and 0 <= end <= np.pi * ureg.radian
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if invalid_endpoints:
|
|
89
|
+
raise ValueError("start and end must be finite angles between 0 and pi.")
|
|
90
|
+
|
|
91
|
+
count = _integer(value=121 if n_points is None else n_points, name="n_points", low=1, high=181)
|
|
92
|
+
|
|
93
|
+
angles = cast(Quantity, np.linspace(start, end, count))
|
|
94
|
+
|
|
95
|
+
validate_units(
|
|
96
|
+
angles,
|
|
97
|
+
unit="radian",
|
|
98
|
+
name="angles",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
angles = angles.copy()
|
|
102
|
+
|
|
103
|
+
invalid_angles = (
|
|
104
|
+
angles.ndim != 1
|
|
105
|
+
or not 1 <= angles.size <= 181
|
|
106
|
+
or np.any(~np.isfinite(angles))
|
|
107
|
+
or np.any((angles < 0) | (angles > np.pi * ureg.radian))
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if invalid_angles:
|
|
111
|
+
raise ValueError("angles must contain 1–181 finite angles between 0 and pi.")
|
|
112
|
+
|
|
113
|
+
angles.magnitude.setflags(write=False)
|
|
114
|
+
|
|
115
|
+
object.__setattr__(self, "angles", angles)
|
|
116
|
+
|
|
117
|
+
for name, value, low, high in (
|
|
118
|
+
("polar_samples", polar_samples, 16, 128),
|
|
119
|
+
("azimuth_samples", azimuth_samples, 4, 32),
|
|
120
|
+
):
|
|
121
|
+
object.__setattr__(self, name, _integer(value=value, name=name, low=low, high=high))
|
|
122
|
+
|
|
123
|
+
def __repr__(self) -> str:
|
|
124
|
+
angles = self.angles.to("degree").magnitude
|
|
125
|
+
|
|
126
|
+
low, high = angles.min(), angles.max()
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
f"AngularSampling(angles={len(angles)} in [{low:g}, {high:g}] deg, "
|
|
130
|
+
f"polar_samples={self.polar_samples}, azimuth_samples={self.azimuth_samples})"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def azimuths(self) -> Quantity:
|
|
135
|
+
"""Uniform output and integration azimuths in radians."""
|
|
136
|
+
|
|
137
|
+
return np.arange(self.azimuth_samples) * 2 * np.pi / self.azimuth_samples * ureg.radian
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def directions(self) -> Directions:
|
|
141
|
+
"""Unit directions: output grid first, then the integration grid."""
|
|
142
|
+
|
|
143
|
+
cosine, _ = np.polynomial.legendre.leggauss(self.polar_samples)
|
|
144
|
+
|
|
145
|
+
theta = np.concatenate([self.angles.to("radian").magnitude, np.arccos(cosine)]) * ureg.radian
|
|
146
|
+
|
|
147
|
+
return Directions.from_angles(
|
|
148
|
+
polar_angles=theta[:, None],
|
|
149
|
+
azimuth_angles=self.azimuths[None, :],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def metadata(self):
|
|
154
|
+
"""Fresh SI sampling settings for reproducible result archives."""
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"angles_rad": self.angles.to("radian").magnitude.tolist(),
|
|
158
|
+
"polar_samples": self.polar_samples,
|
|
159
|
+
"azimuth_samples": self.azimuth_samples,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def check_work(self, *, grid, order, realizations=1):
|
|
163
|
+
"""Reject synchronous workloads above 100 million voxel-direction-orders."""
|
|
164
|
+
|
|
165
|
+
directions = (len(self.angles) + self.polar_samples) * self.azimuth_samples
|
|
166
|
+
|
|
167
|
+
if realizations * np.prod(grid.shape) * directions * order > 100_000_000:
|
|
168
|
+
raise ValueError("Requested scattering is too large; reduce grid, angles, order, or realizations.")
|
|
169
|
+
|
|
170
|
+
def summarize(self, *, result):
|
|
171
|
+
"""Reduce numeric Born data while retaining all directional intensities.
|
|
172
|
+
|
|
173
|
+
Output amplitudes retain (order, theta, phi, incident polarization,
|
|
174
|
+
vector component). Integrated coefficients are finite-sample cross
|
|
175
|
+
sections divided by voxel-box volume, not intrinsic material values.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
count = len(self.angles)
|
|
179
|
+
|
|
180
|
+
order = len(result.differential)
|
|
181
|
+
|
|
182
|
+
shape = (order, count + self.polar_samples, self.azimuth_samples)
|
|
183
|
+
|
|
184
|
+
directional = result.differential.reshape(shape)
|
|
185
|
+
|
|
186
|
+
averaged = directional.mean(axis=-1)
|
|
187
|
+
|
|
188
|
+
directional_terms = result.term_differential.reshape(shape)
|
|
189
|
+
|
|
190
|
+
terms = directional_terms.mean(axis=-1)
|
|
191
|
+
|
|
192
|
+
cosine, weights = np.polynomial.legendre.leggauss(self.polar_samples)
|
|
193
|
+
|
|
194
|
+
quadrature = averaged[:, count:]
|
|
195
|
+
|
|
196
|
+
mu = 2 * np.pi * np.sum(quadrature * weights, axis=-1)
|
|
197
|
+
|
|
198
|
+
moment = 2 * np.pi * np.sum(quadrature * weights * cosine, axis=-1)
|
|
199
|
+
|
|
200
|
+
reduced = 2 * np.pi * np.sum(quadrature * weights * (1 - cosine), axis=-1)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"mean": averaged[:, :count],
|
|
204
|
+
"terms": terms[:, :count],
|
|
205
|
+
"directional_terms": directional_terms[:, :count],
|
|
206
|
+
"directional_differential": directional[:, :count],
|
|
207
|
+
"directional_amplitudes": result.amplitudes.reshape(*shape, 2, 3)[:, :count],
|
|
208
|
+
"integrals": np.stack([mu, moment, reduced], axis=-1),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def _resolve(cls, *, sampling=None, angles=None, polar_samples=None, azimuth_samples=None):
|
|
213
|
+
if sampling is not None:
|
|
214
|
+
if not isinstance(sampling, cls):
|
|
215
|
+
raise TypeError("sampling must be an AngularSampling.")
|
|
216
|
+
|
|
217
|
+
if any(value is not None for value in (angles, polar_samples, azimuth_samples)):
|
|
218
|
+
raise ValueError("Supply sampling or individual angular settings, not both.")
|
|
219
|
+
|
|
220
|
+
return sampling
|
|
221
|
+
|
|
222
|
+
if any(value is not None for value in (angles, polar_samples, azimuth_samples)):
|
|
223
|
+
warnings.warn(
|
|
224
|
+
"Use AngularSampling instead of individual angular keywords.", DeprecationWarning, stacklevel=3
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return cls(
|
|
228
|
+
angles=angles,
|
|
229
|
+
polar_samples=32 if polar_samples is None else polar_samples,
|
|
230
|
+
azimuth_samples=8 if azimuth_samples is None else azimuth_samples,
|
|
231
|
+
)
|
bornsim/series.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Reusable finite-volume vector Born-series engine.
|
|
2
|
+
|
|
3
|
+
Time convention exp(-i omega t). Relative dielectric contrast is linearized
|
|
4
|
+
as 2 n0 delta_n at every order. Amplitudes interfere coherently before the
|
|
5
|
+
unpolarized intensity is evaluated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import numpy as np
|
|
10
|
+
from ._validation import _integer
|
|
11
|
+
from .green import GreenOperator
|
|
12
|
+
from .grid import Grid
|
|
13
|
+
from .directions import Directions
|
|
14
|
+
from .results import BornResult
|
|
15
|
+
from .units import Quantity, validate_units
|
|
16
|
+
from .volume import Volume
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, kw_only=True, eq=False)
|
|
20
|
+
class BornSeries:
|
|
21
|
+
"""Own the fixed geometry and propagation operator for numerical Born orders.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
grid : Grid, optional
|
|
26
|
+
Shared spatial configuration, also used by the input Volume.
|
|
27
|
+
Cannot be combined with legacy shape or spacing keywords.
|
|
28
|
+
shape : tuple of int, optional
|
|
29
|
+
Three grid dimensions, each from 2 to 32.
|
|
30
|
+
spacing : Quantity, optional
|
|
31
|
+
Positive cubic voxel width; explicit length units are required.
|
|
32
|
+
background_refractive_index : float
|
|
33
|
+
Positive uniform background refractive index, dimensionless.
|
|
34
|
+
wavelength : Quantity
|
|
35
|
+
Positive vacuum wavelength; explicit length units are required.
|
|
36
|
+
directions : Directions
|
|
37
|
+
Validated, immutable Cartesian unit observation vectors.
|
|
38
|
+
order : int, optional
|
|
39
|
+
Highest cumulative Born order, from 1 to 12; default 3.
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
operator : bornsim.green.GreenOperator
|
|
44
|
+
One open-boundary FFT Green operator reused for every compatible volume.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
The cached incident plane wave travels along +z with x and y polarizations.
|
|
49
|
+
For each call, ``E[0] = E_inc`` and ``E[m] = K(chi*E[m-1])``, where
|
|
50
|
+
``chi = 2*n0*delta_refractive_index`` and K integrates the background dyadic Green tensor
|
|
51
|
+
multiplied by ``k0**2``. Order-m far-field amplitude is computed from
|
|
52
|
+
``chi*E[m-1]``. Cumulative intensity is ``|sum_m f[m]|**2``, averaged over the two
|
|
53
|
+
polarizations and divided by the entire voxel-box volume.
|
|
54
|
+
|
|
55
|
+
Geometry, incident field, and the Green tensor are reused; contrast,
|
|
56
|
+
propagated fields, amplitudes, and diagnostics are local to each solve.
|
|
57
|
+
Compatible volumes must have exactly the configured grid and background.
|
|
58
|
+
Configuration is frozen to prevent stale propagation settings. Decreasing
|
|
59
|
+
field terms do not establish universal convergence; the dielectric
|
|
60
|
+
linearization is unchanged at higher orders.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
shape: tuple | None = None
|
|
64
|
+
spacing: Quantity = None
|
|
65
|
+
grid: Grid | None = None
|
|
66
|
+
background_refractive_index: float
|
|
67
|
+
wavelength: Quantity
|
|
68
|
+
directions: Directions
|
|
69
|
+
order: int = 3
|
|
70
|
+
operator: GreenOperator = field(init=False, repr=False)
|
|
71
|
+
_positions: Quantity = field(init=False, repr=False)
|
|
72
|
+
_incident_field: np.ndarray = field(init=False, repr=False)
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
grid = Grid._resolve(
|
|
76
|
+
grid=self.grid,
|
|
77
|
+
shape=self.shape,
|
|
78
|
+
spacing=self.spacing,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
object.__setattr__(self, "grid", grid)
|
|
82
|
+
|
|
83
|
+
object.__setattr__(self, "shape", grid.shape)
|
|
84
|
+
|
|
85
|
+
object.__setattr__(self, "spacing", grid.spacing)
|
|
86
|
+
|
|
87
|
+
volume = Volume(
|
|
88
|
+
delta_refractive_index=np.zeros(grid.shape),
|
|
89
|
+
grid=grid,
|
|
90
|
+
background_refractive_index=self.background_refractive_index,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
shape = grid.shape
|
|
94
|
+
|
|
95
|
+
wavelength = self.wavelength
|
|
96
|
+
|
|
97
|
+
validate_units(
|
|
98
|
+
self.wavelength,
|
|
99
|
+
unit="meter",
|
|
100
|
+
name="wavelength",
|
|
101
|
+
scalar=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if not np.isfinite(wavelength) or wavelength <= 0:
|
|
105
|
+
raise ValueError("wavelength must be finite and positive.")
|
|
106
|
+
|
|
107
|
+
order = _integer(
|
|
108
|
+
value=self.order,
|
|
109
|
+
name="order",
|
|
110
|
+
low=1,
|
|
111
|
+
high=12,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if not isinstance(self.directions, Directions):
|
|
115
|
+
raise TypeError("directions must be a Directions instance.")
|
|
116
|
+
|
|
117
|
+
directions = self.directions
|
|
118
|
+
|
|
119
|
+
positions = grid.positions
|
|
120
|
+
|
|
121
|
+
positions.magnitude.setflags(write=False)
|
|
122
|
+
|
|
123
|
+
phase = np.exp(1j * 2 * np.pi / wavelength * volume.background_refractive_index * positions[..., 2])
|
|
124
|
+
|
|
125
|
+
incident = phase.to("dimensionless").magnitude[..., None, None] * np.eye(3)[:2]
|
|
126
|
+
|
|
127
|
+
incident.setflags(write=False)
|
|
128
|
+
|
|
129
|
+
operator = GreenOperator(
|
|
130
|
+
shape=shape,
|
|
131
|
+
spacing=grid.spacing,
|
|
132
|
+
wavelength=wavelength,
|
|
133
|
+
background_refractive_index=volume.background_refractive_index,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for name, value in (
|
|
137
|
+
("shape", shape),
|
|
138
|
+
("spacing", grid.spacing),
|
|
139
|
+
("background_refractive_index", volume.background_refractive_index),
|
|
140
|
+
("wavelength", wavelength.copy()),
|
|
141
|
+
("directions", directions),
|
|
142
|
+
("order", order),
|
|
143
|
+
("operator", operator),
|
|
144
|
+
("_positions", positions),
|
|
145
|
+
("_incident_field", incident),
|
|
146
|
+
):
|
|
147
|
+
object.__setattr__(self, name, value)
|
|
148
|
+
|
|
149
|
+
def _far_field(self, *, source: np.ndarray) -> np.ndarray:
|
|
150
|
+
"""Project the voxel source onto transverse outgoing amplitudes in metres."""
|
|
151
|
+
|
|
152
|
+
# Fourier projection uses numeric SI coordinates and amplitudes in metres.
|
|
153
|
+
k0 = 2 * np.pi / float(self.wavelength.to("meter").magnitude)
|
|
154
|
+
|
|
155
|
+
k = k0 * self.background_refractive_index
|
|
156
|
+
|
|
157
|
+
positions = self._positions.to("meter").magnitude.reshape(-1, 3)
|
|
158
|
+
|
|
159
|
+
voxel_spacing_m = float(self.spacing.to("meter").magnitude)
|
|
160
|
+
|
|
161
|
+
values = source.reshape(-1, 2, 3)
|
|
162
|
+
|
|
163
|
+
amplitude = np.empty((len(self.directions), 2, 3), dtype=complex)
|
|
164
|
+
|
|
165
|
+
for start in range(0, len(self.directions), 64):
|
|
166
|
+
direction = self.directions.vectors[start : start + 64]
|
|
167
|
+
|
|
168
|
+
phase = np.exp(-1j * k * (direction @ positions.T))
|
|
169
|
+
|
|
170
|
+
integral = np.einsum("dn,npv->dpv", phase, values) * k0**2 * voxel_spacing_m**3 / (4 * np.pi)
|
|
171
|
+
|
|
172
|
+
amplitude[start : start + 64] = (
|
|
173
|
+
integral - direction[:, None, :] * np.einsum("dv,dpv->dp", direction, integral)[..., None]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return amplitude
|
|
177
|
+
|
|
178
|
+
def solve(self, *, volume: Volume) -> BornResult:
|
|
179
|
+
"""Compute coherent scattering from one compatible finite refractive index volume.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
volume : Volume
|
|
184
|
+
Fixed fluctuation field with positive linearized permittivity,
|
|
185
|
+
matching this engine's grid spacing, dimensions and background.
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
result : bornsim.results.BornResult
|
|
190
|
+
Fresh numeric SI amplitudes, cumulative and isolated intensities,
|
|
191
|
+
relative field norms, and diagnostic messages.
|
|
192
|
+
|
|
193
|
+
Raises
|
|
194
|
+
------
|
|
195
|
+
TypeError
|
|
196
|
+
If volume is not a Volume.
|
|
197
|
+
ValueError
|
|
198
|
+
If grid or background differs from the engine configuration, or
|
|
199
|
+
iteration produces nonfinite fields.
|
|
200
|
+
|
|
201
|
+
Notes
|
|
202
|
+
-----
|
|
203
|
+
Each solve starts with the incident field. Amplitudes are summed
|
|
204
|
+
before squaring, preserving interference. Intensities are finite-sample
|
|
205
|
+
cross sections divided by the entire voxel-box volume, not intrinsic
|
|
206
|
+
infinite-medium transport coefficients. The dielectric contrast is
|
|
207
|
+
``2*n0*delta_refractive_index`` at every order. Growing field terms generate a
|
|
208
|
+
diagnostic; decreasing terms do not certify convergence.
|
|
209
|
+
|
|
210
|
+
Examples
|
|
211
|
+
--------
|
|
212
|
+
>>> from bornsim.units import ureg
|
|
213
|
+
>>> from bornsim import BornSeries, RandomMedium
|
|
214
|
+
...
|
|
215
|
+
...
|
|
216
|
+
>>> medium = RandomMedium(
|
|
217
|
+
... correlation="gaussian",
|
|
218
|
+
... background_refractive_index=1.33,
|
|
219
|
+
... refractive_index_std=0.01,
|
|
220
|
+
... correlation_length=100e-9 * ureg.meter,
|
|
221
|
+
... )
|
|
222
|
+
|
|
223
|
+
...
|
|
224
|
+
>>> volume = medium.to_volume(
|
|
225
|
+
... shape=(2, 2, 2),
|
|
226
|
+
... seed=42,
|
|
227
|
+
... spacing=50e-9 * ureg.meter,
|
|
228
|
+
... )
|
|
229
|
+
>>> directions = Directions(vectors=[[0.0, 0.0, 1.0]])
|
|
230
|
+
>>> engine = BornSeries(
|
|
231
|
+
... shape=volume.delta_refractive_index.shape,
|
|
232
|
+
... spacing=volume.spacing,
|
|
233
|
+
... background_refractive_index=volume.background_refractive_index,
|
|
234
|
+
... wavelength=633 * ureg.nanometer,
|
|
235
|
+
... directions=directions,
|
|
236
|
+
... order=2,
|
|
237
|
+
... )
|
|
238
|
+
>>> result = engine.solve(volume=volume)
|
|
239
|
+
>>> result.amplitudes.shape
|
|
240
|
+
(2, 1, 2, 3)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if not isinstance(volume, Volume):
|
|
244
|
+
raise TypeError("volume must be a Volume.")
|
|
245
|
+
|
|
246
|
+
incompatible_volume = (
|
|
247
|
+
volume.delta_refractive_index.shape != self.shape
|
|
248
|
+
or volume.spacing != self.spacing
|
|
249
|
+
or volume.background_refractive_index != self.background_refractive_index
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if incompatible_volume:
|
|
253
|
+
raise ValueError("volume grid and background_refractive_index must match the BornSeries configuration.")
|
|
254
|
+
|
|
255
|
+
field = self._incident_field
|
|
256
|
+
|
|
257
|
+
incident_norm = np.linalg.norm(field)
|
|
258
|
+
|
|
259
|
+
contrast = 2 * volume.background_refractive_index * volume.delta_refractive_index
|
|
260
|
+
|
|
261
|
+
amplitude_terms, norms = [], []
|
|
262
|
+
|
|
263
|
+
for _ in range(self.order):
|
|
264
|
+
source = contrast[..., None, None] * field
|
|
265
|
+
|
|
266
|
+
amplitude_terms.append(self._far_field(source=source))
|
|
267
|
+
|
|
268
|
+
field = self.operator.apply(source=source)
|
|
269
|
+
|
|
270
|
+
if not np.all(np.isfinite(field)):
|
|
271
|
+
raise ValueError("Born terms overflowed; reduce contrast or order.")
|
|
272
|
+
|
|
273
|
+
norms.append(float(np.linalg.norm(field) / incident_norm))
|
|
274
|
+
|
|
275
|
+
amplitudes = np.asarray(amplitude_terms)
|
|
276
|
+
|
|
277
|
+
cumulative = np.cumsum(amplitudes, axis=0)
|
|
278
|
+
|
|
279
|
+
differential = np.sum(np.abs(cumulative) ** 2, axis=(-1, -2)) / (
|
|
280
|
+
2 * float(volume.volume.to("meter**3").magnitude)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
separate = np.sum(np.abs(amplitudes) ** 2, axis=(-1, -2)) / (2 * float(volume.volume.to("meter**3").magnitude))
|
|
284
|
+
|
|
285
|
+
warnings = []
|
|
286
|
+
|
|
287
|
+
if volume.spacing > self.wavelength / volume.background_refractive_index / 10:
|
|
288
|
+
warnings.append("Voxel spacing exceeds one tenth of the background wavelength; refine the grid.")
|
|
289
|
+
|
|
290
|
+
if len(norms) > 1 and any(b >= a and b > 0 for a, b in zip(norms, norms[1:])):
|
|
291
|
+
warnings.append("Successive field terms are not decreasing; Born convergence is not established.")
|
|
292
|
+
|
|
293
|
+
return BornResult(
|
|
294
|
+
directions=self.directions.vectors.copy(),
|
|
295
|
+
amplitudes=amplitudes,
|
|
296
|
+
differential=differential,
|
|
297
|
+
term_differential=separate,
|
|
298
|
+
field_norms=np.array(norms),
|
|
299
|
+
warnings=tuple(warnings),
|
|
300
|
+
)
|