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/solver.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Solver configuration and analytical, volume, and ensemble calculations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import hashlib
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
from .media import Medium
|
|
8
|
+
from .model import AnalyticalMedium, angular_scattering, optical_properties
|
|
9
|
+
from .results import Result
|
|
10
|
+
from ._validation import _integer
|
|
11
|
+
from .volume import Volume
|
|
12
|
+
from .series import BornSeries
|
|
13
|
+
from .ensemble import ensemble_scattering
|
|
14
|
+
from .grid import Grid
|
|
15
|
+
from .directions import Directions
|
|
16
|
+
from .sampling import AngularSampling
|
|
17
|
+
from .ensemble_sampling import EnsembleSampling
|
|
18
|
+
from .source import Source
|
|
19
|
+
from .units import Quantity, validate_units, ureg
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _provenance(*, order, **settings):
|
|
23
|
+
return {
|
|
24
|
+
"bornsim_version": __version__,
|
|
25
|
+
"numpy_version": np.__version__,
|
|
26
|
+
"order": int(order),
|
|
27
|
+
"dielectric_contrast": "2 * background_refractive_index * delta_refractive_index",
|
|
28
|
+
"green_self_cell": "equal-volume sphere with longitudinal contact term",
|
|
29
|
+
**settings,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, kw_only=True)
|
|
34
|
+
class Solver:
|
|
35
|
+
"""Configure analytical and finite-volume Born scattering calculations.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
source : Source
|
|
40
|
+
Incident vacuum wavelength and unpolarized illumination.
|
|
41
|
+
sampling : AngularSampling, optional
|
|
42
|
+
Shared output angles and solid-angle quadrature. Defaults to
|
|
43
|
+
AngularSampling(). Used for full-volume solves and ensembles.
|
|
44
|
+
order : int, optional
|
|
45
|
+
Highest cumulative numerical Born order, from 1 to 12. Default is 3.
|
|
46
|
+
Analytical calculations always use first order.
|
|
47
|
+
quadrature_order : int, optional
|
|
48
|
+
Gauss-Legendre order for analytical integrated coefficients. Must be
|
|
49
|
+
at least 16; default is 256. Numerical integration uses sampling's
|
|
50
|
+
polar_samples and azimuth_samples instead.
|
|
51
|
+
|
|
52
|
+
Attributes
|
|
53
|
+
----------
|
|
54
|
+
source : Source
|
|
55
|
+
Incident source shared by all calculations.
|
|
56
|
+
order : int
|
|
57
|
+
Highest numerical Born order.
|
|
58
|
+
quadrature_order : int
|
|
59
|
+
Analytical integration order.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
TypeError
|
|
64
|
+
If ``source`` is not a Source.
|
|
65
|
+
ValueError
|
|
66
|
+
If either order parameter is outside its supported integer range.
|
|
67
|
+
|
|
68
|
+
Notes
|
|
69
|
+
-----
|
|
70
|
+
Numerical calculations retain the linearized dielectric contrast
|
|
71
|
+
``2 * n0 * delta_n``. Green-tensor self interactions use an equal-volume
|
|
72
|
+
sphere including the longitudinal contact term. Increasing Born order
|
|
73
|
+
does not restore the omitted quadratic constitutive term. Decreasing
|
|
74
|
+
successive terms do not establish universal Born convergence.
|
|
75
|
+
|
|
76
|
+
Examples
|
|
77
|
+
--------
|
|
78
|
+
>>> from bornsim import AnalyticalMedium, Solver, Source
|
|
79
|
+
...
|
|
80
|
+
>>> from bornsim.units import ureg
|
|
81
|
+
...
|
|
82
|
+
>>> solver = Solver(
|
|
83
|
+
... source=Source(
|
|
84
|
+
... wavelength=633e-9 * ureg.meter,
|
|
85
|
+
... ),
|
|
86
|
+
... order=3,
|
|
87
|
+
... )
|
|
88
|
+
>>> result = solver.solve(
|
|
89
|
+
... target=AnalyticalMedium(
|
|
90
|
+
... background_refractive_index=1.33,
|
|
91
|
+
... refractive_index_std=0.01,
|
|
92
|
+
... correlation_length=100e-9 * ureg.meter,
|
|
93
|
+
... correlation="gaussian",
|
|
94
|
+
... )
|
|
95
|
+
... )
|
|
96
|
+
>>> result.differential.shape
|
|
97
|
+
(1, 121, 8)
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
source: Source
|
|
101
|
+
sampling: AngularSampling = field(default_factory=AngularSampling)
|
|
102
|
+
order: int = 3
|
|
103
|
+
quadrature_order: int = 256
|
|
104
|
+
|
|
105
|
+
def __post_init__(self):
|
|
106
|
+
if not isinstance(self.sampling, AngularSampling):
|
|
107
|
+
raise TypeError("sampling must be an AngularSampling.")
|
|
108
|
+
|
|
109
|
+
if not isinstance(self.source, Source):
|
|
110
|
+
raise TypeError("source must be a Source.")
|
|
111
|
+
|
|
112
|
+
_integer(
|
|
113
|
+
value=self.order,
|
|
114
|
+
name="order",
|
|
115
|
+
low=1,
|
|
116
|
+
high=12,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
invalid_quadrature_order = (
|
|
120
|
+
isinstance(self.quadrature_order, bool)
|
|
121
|
+
or not isinstance(self.quadrature_order, int)
|
|
122
|
+
or self.quadrature_order < 16
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if invalid_quadrature_order:
|
|
126
|
+
raise ValueError("quadrature_order must be an integer of at least 16.")
|
|
127
|
+
|
|
128
|
+
def __repr__(self):
|
|
129
|
+
wavelength = getattr(self.source, "wavelength").to("nanometer")
|
|
130
|
+
|
|
131
|
+
return f"Solver(wavelength={wavelength.magnitude:g} nm, order={self.order}, sampling={self.sampling!r})"
|
|
132
|
+
|
|
133
|
+
def solve(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
target: Volume | AnalyticalMedium,
|
|
137
|
+
sampling: AngularSampling | None = None,
|
|
138
|
+
angles: Quantity | None = None,
|
|
139
|
+
directions: Directions | None = None,
|
|
140
|
+
) -> Result:
|
|
141
|
+
"""Compute a full angular scattering distribution with normalization.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
target : Volume or AnalyticalMedium
|
|
146
|
+
Fixed numerical sample or analytical first-order statistics.
|
|
147
|
+
sampling : AngularSampling, optional
|
|
148
|
+
Override the solver's shared angular settings for this call.
|
|
149
|
+
angles : array_like or Quantity, optional
|
|
150
|
+
Deprecated analytical output-angle keyword. Use AngularSampling.
|
|
151
|
+
Numerical angular cuts use solve_cut instead.
|
|
152
|
+
directions : Directions, optional
|
|
153
|
+
Rejected here; arbitrary numerical observations use solve_cut.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
result : Result
|
|
158
|
+
Directional intensities and normalized phase densities, with
|
|
159
|
+
integrated coefficients. Numerical amplitudes are retained.
|
|
160
|
+
Coefficients for finite samples are cross sections divided by
|
|
161
|
+
voxel-box volume, not intrinsic infinite-medium properties.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
if not isinstance(target, (AnalyticalMedium, Volume)):
|
|
165
|
+
raise TypeError("target must be an AnalyticalMedium or Volume.")
|
|
166
|
+
|
|
167
|
+
if directions is not None or (isinstance(target, Volume) and angles is not None):
|
|
168
|
+
raise ValueError("Use solve_cut for explicit angles or directions; solve always computes full scattering.")
|
|
169
|
+
|
|
170
|
+
if sampling is not None and angles is not None:
|
|
171
|
+
raise ValueError("Supply sampling or analytical angles, not both.")
|
|
172
|
+
|
|
173
|
+
sampling = self.sampling if sampling is None else AngularSampling._resolve(sampling=sampling)
|
|
174
|
+
|
|
175
|
+
if isinstance(target, Volume):
|
|
176
|
+
return self._solve_volume(volume=target, sampling=sampling)
|
|
177
|
+
|
|
178
|
+
if angles is not None:
|
|
179
|
+
validate_units(
|
|
180
|
+
angles,
|
|
181
|
+
unit="radian",
|
|
182
|
+
name="angles",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
theta = sampling.angles.copy() if angles is None else angles.copy()
|
|
186
|
+
|
|
187
|
+
invalid_angles = (
|
|
188
|
+
theta.ndim != 1
|
|
189
|
+
or theta.size == 0
|
|
190
|
+
or np.any(~np.isfinite(theta))
|
|
191
|
+
or np.any((theta < 0) | (theta > np.pi * ureg.radian))
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if invalid_angles:
|
|
195
|
+
raise ValueError("angles must be a nonempty 1D array of finite angles between 0 and pi.")
|
|
196
|
+
|
|
197
|
+
if angles is not None:
|
|
198
|
+
import warnings
|
|
199
|
+
|
|
200
|
+
warnings.warn("Use AngularSampling for analytical output angles.", DeprecationWarning, stacklevel=2)
|
|
201
|
+
|
|
202
|
+
wavelength = self.source.wavelength
|
|
203
|
+
|
|
204
|
+
differential = angular_scattering(
|
|
205
|
+
medium=target,
|
|
206
|
+
wavelength=wavelength,
|
|
207
|
+
theta=theta,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
coefficients = optical_properties(
|
|
211
|
+
medium=target,
|
|
212
|
+
wavelength=wavelength,
|
|
213
|
+
quadrature_order=self.quadrature_order,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
directional = np.broadcast_to(differential[None, :, None], (1, len(theta), sampling.azimuth_samples)).copy()
|
|
217
|
+
|
|
218
|
+
return Result(
|
|
219
|
+
source=self.source,
|
|
220
|
+
kind="analytical",
|
|
221
|
+
angles=theta if theta is not None else None,
|
|
222
|
+
azimuths=sampling.azimuths,
|
|
223
|
+
differential=directional,
|
|
224
|
+
mu_s=coefficients["mu_s"].reshape(1),
|
|
225
|
+
g=np.array([np.nan if coefficients["g"] is None else coefficients["g"]]),
|
|
226
|
+
mu_s_prime=coefficients["mu_s_prime"].reshape(1),
|
|
227
|
+
provenance=_provenance(
|
|
228
|
+
order=1,
|
|
229
|
+
medium=target.metadata,
|
|
230
|
+
quadrature_order=self.quadrature_order,
|
|
231
|
+
sampling={**sampling.metadata, "angles_rad": theta.to("radian").magnitude.tolist()},
|
|
232
|
+
coefficient_scope="infinite-medium",
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def solve_cut(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
target: Volume,
|
|
240
|
+
angles: Quantity | None = None,
|
|
241
|
+
directions: Directions | None = None,
|
|
242
|
+
) -> Result:
|
|
243
|
+
"""Compute an unnormalized angular cut through a fixed Volume.
|
|
244
|
+
|
|
245
|
+
Supply polar angles on the x-z meridian or a Directions configuration,
|
|
246
|
+
but not both. Explicit angular units are required. Default angles span 0 to pi
|
|
247
|
+
with 121 samples. Cut amplitudes and intensities retain every requested
|
|
248
|
+
direction. A cut alone cannot determine a solid-angle integral;
|
|
249
|
+
integrated coefficients and normalized phase functions are unavailable.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
if not isinstance(target, Volume):
|
|
253
|
+
raise TypeError("target must be a Volume.")
|
|
254
|
+
|
|
255
|
+
if angles is not None and directions is not None:
|
|
256
|
+
raise ValueError("Supply angles or directions, not both.")
|
|
257
|
+
|
|
258
|
+
if angles is not None:
|
|
259
|
+
validate_units(
|
|
260
|
+
angles,
|
|
261
|
+
unit="radian",
|
|
262
|
+
name="angles",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
theta = None
|
|
266
|
+
|
|
267
|
+
if directions is None:
|
|
268
|
+
theta = np.linspace(0, np.pi, 121) * ureg.radian if angles is None else angles.copy()
|
|
269
|
+
|
|
270
|
+
invalid_angles = (
|
|
271
|
+
theta.ndim != 1
|
|
272
|
+
or theta.size == 0
|
|
273
|
+
or np.any(~np.isfinite(theta))
|
|
274
|
+
or np.any((theta < 0) | (theta > np.pi * ureg.radian))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if invalid_angles:
|
|
278
|
+
raise ValueError("angles must be a nonempty 1D array of finite angles between 0 and pi.")
|
|
279
|
+
|
|
280
|
+
if directions is None:
|
|
281
|
+
assert theta is not None
|
|
282
|
+
|
|
283
|
+
directions = Directions.from_angles(
|
|
284
|
+
polar_angles=theta,
|
|
285
|
+
azimuth_angles=0 * ureg.radian,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
engine = BornSeries(
|
|
289
|
+
grid=target.grid,
|
|
290
|
+
background_refractive_index=target.background_refractive_index,
|
|
291
|
+
wavelength=self.source.wavelength,
|
|
292
|
+
directions=directions,
|
|
293
|
+
order=self.order,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
result = engine.solve(volume=target)
|
|
297
|
+
|
|
298
|
+
return Result(
|
|
299
|
+
source=self.source,
|
|
300
|
+
kind="volume",
|
|
301
|
+
sample_volume=target.volume,
|
|
302
|
+
angles=theta if theta is not None else None,
|
|
303
|
+
directions=result.directions,
|
|
304
|
+
differential=result.differential * (1 / ureg.meter / ureg.steradian),
|
|
305
|
+
amplitudes=result.amplitudes * ureg.meter,
|
|
306
|
+
term_differential=result.term_differential * (1 / ureg.meter / ureg.steradian),
|
|
307
|
+
field_norms=result.field_norms,
|
|
308
|
+
warnings=result.warnings,
|
|
309
|
+
provenance=_provenance(
|
|
310
|
+
order=self.order,
|
|
311
|
+
grid={
|
|
312
|
+
"shape": list(target.delta_refractive_index.shape),
|
|
313
|
+
"spacing_m": float(target.spacing.to("meter").magnitude),
|
|
314
|
+
"background_refractive_index": target.background_refractive_index,
|
|
315
|
+
"delta_refractive_index_sha256": hashlib.sha256(
|
|
316
|
+
np.asarray(target.delta_refractive_index, dtype="<f8").tobytes(order="C")
|
|
317
|
+
).hexdigest(),
|
|
318
|
+
},
|
|
319
|
+
medium=None if target.medium is None else target.medium.metadata,
|
|
320
|
+
seed=target.seed,
|
|
321
|
+
coefficient_scope="finite-sample",
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _solve_volume(self, *, volume, sampling):
|
|
326
|
+
sampling.check_work(grid=volume.grid, order=self.order)
|
|
327
|
+
|
|
328
|
+
engine = BornSeries(
|
|
329
|
+
grid=volume.grid,
|
|
330
|
+
background_refractive_index=volume.background_refractive_index,
|
|
331
|
+
wavelength=self.source.wavelength,
|
|
332
|
+
directions=sampling.directions,
|
|
333
|
+
order=self.order,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
born = engine.solve(volume=volume)
|
|
337
|
+
|
|
338
|
+
sampled = sampling.summarize(result=born)
|
|
339
|
+
|
|
340
|
+
integrated = sampled["integrals"]
|
|
341
|
+
|
|
342
|
+
mu = integrated[:, 0]
|
|
343
|
+
|
|
344
|
+
g = np.divide(integrated[:, 1], mu, out=np.full_like(mu, np.nan), where=mu != 0)
|
|
345
|
+
|
|
346
|
+
return Result(
|
|
347
|
+
source=self.source,
|
|
348
|
+
kind="volume",
|
|
349
|
+
sample_volume=volume.volume,
|
|
350
|
+
angles=sampling.angles,
|
|
351
|
+
azimuths=sampling.azimuths,
|
|
352
|
+
differential=sampled["directional_differential"] * (1 / ureg.meter / ureg.steradian),
|
|
353
|
+
amplitudes=sampled["directional_amplitudes"] * ureg.meter,
|
|
354
|
+
term_differential=sampled["directional_terms"] * (1 / ureg.meter / ureg.steradian),
|
|
355
|
+
mu_s=mu * (1 / ureg.meter),
|
|
356
|
+
g=g,
|
|
357
|
+
mu_s_prime=integrated[:, 2] * (1 / ureg.meter),
|
|
358
|
+
field_norms=born.field_norms,
|
|
359
|
+
warnings=born.warnings,
|
|
360
|
+
provenance=_provenance(
|
|
361
|
+
order=self.order,
|
|
362
|
+
grid={
|
|
363
|
+
**volume.grid.metadata,
|
|
364
|
+
"background_refractive_index": volume.background_refractive_index,
|
|
365
|
+
"delta_refractive_index_sha256": hashlib.sha256(
|
|
366
|
+
np.asarray(volume.delta_refractive_index, dtype="<f8").tobytes(order="C")
|
|
367
|
+
).hexdigest(),
|
|
368
|
+
},
|
|
369
|
+
sampling=sampling.metadata,
|
|
370
|
+
medium=None if volume.medium is None else volume.medium.metadata,
|
|
371
|
+
seed=volume.seed,
|
|
372
|
+
coefficient_scope="finite-sample",
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def ensemble(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
medium: Medium,
|
|
380
|
+
grid=None,
|
|
381
|
+
sampling=None,
|
|
382
|
+
shape=None,
|
|
383
|
+
spacing=None,
|
|
384
|
+
ensemble_sampling=None,
|
|
385
|
+
realizations=None,
|
|
386
|
+
seed=None,
|
|
387
|
+
angles=None,
|
|
388
|
+
azimuth_samples=None,
|
|
389
|
+
polar_samples=None,
|
|
390
|
+
):
|
|
391
|
+
"""Average sampled-volume intensities and integrate over solid angle.
|
|
392
|
+
|
|
393
|
+
Parameters
|
|
394
|
+
----------
|
|
395
|
+
medium : Medium
|
|
396
|
+
Random medium or structured medium with a random background.
|
|
397
|
+
Deterministic structures use solve on their generated Volume.
|
|
398
|
+
grid : Grid, optional
|
|
399
|
+
Explicit spatial configuration for every realization. Cannot be
|
|
400
|
+
combined with shape or spacing.
|
|
401
|
+
sampling : AngularSampling, optional
|
|
402
|
+
Override shared angular settings. Defaults to the solver's sampling
|
|
403
|
+
when individual angular keywords are omitted. Cannot be combined
|
|
404
|
+
with angles, polar_samples, or azimuth_samples.
|
|
405
|
+
shape : tuple of int, optional
|
|
406
|
+
Three grid dimensions, each from 2 to 32. Required with spacing when grid is omitted.
|
|
407
|
+
spacing : Quantity, optional
|
|
408
|
+
Positive, finite cubic voxel spacing. Explicit length units are required;
|
|
409
|
+
must be supplied with shape when grid is omitted.
|
|
410
|
+
ensemble_sampling : EnsembleSampling, optional
|
|
411
|
+
Realization counts and ordered independent seeds. Defaults to
|
|
412
|
+
four consecutive seeds starting at zero.
|
|
413
|
+
realizations : int, optional
|
|
414
|
+
Deprecated; use EnsembleSampling.
|
|
415
|
+
Independent sample count, from 1 to 32. Default is 4.
|
|
416
|
+
seed : int, optional
|
|
417
|
+
Deprecated; use EnsembleSampling.
|
|
418
|
+
First sample seed, from 0 to 2**32 - 1. Default is 0. Samples use
|
|
419
|
+
consecutive seeds; the entire seed range must remain valid.
|
|
420
|
+
angles : array_like or Quantity, optional
|
|
421
|
+
One-dimensional plot angles in [0, pi], with 1 to 181 observations.
|
|
422
|
+
Angular values require explicit units. Default is 121 evenly spaced angles.
|
|
423
|
+
azimuth_samples : int, optional
|
|
424
|
+
Uniform azimuth samples for both curves and quadrature, from 4 to 32.
|
|
425
|
+
Default is 8.
|
|
426
|
+
polar_samples : int, optional
|
|
427
|
+
Gauss-Legendre nodes for integration, from 16 to 128. Default is 32.
|
|
428
|
+
These nodes are independent of the plot angles.
|
|
429
|
+
|
|
430
|
+
Returns
|
|
431
|
+
-------
|
|
432
|
+
result : Result
|
|
433
|
+
Directional cumulative and isolated-term intensities through
|
|
434
|
+
``self.order``, standard errors, finite-sample effective coefficients,
|
|
435
|
+
per-realization field norms, and numerical diagnostics, all with units.
|
|
436
|
+
Directional intensities retain azimuth for 3D phase plotting.
|
|
437
|
+
Complex amplitudes are not retained in the ensemble result.
|
|
438
|
+
|
|
439
|
+
Raises
|
|
440
|
+
------
|
|
441
|
+
TypeError
|
|
442
|
+
If ``medium`` is not a Medium.
|
|
443
|
+
ValueError
|
|
444
|
+
If a grid, sampling, seed, unit, or order constraint is violated,
|
|
445
|
+
the ensemble exceeds the synchronous work limit, or a generated
|
|
446
|
+
volume has nonpositive linearized permittivity.
|
|
447
|
+
|
|
448
|
+
See Also
|
|
449
|
+
--------
|
|
450
|
+
bornsim.ensemble.ensemble_scattering : Numeric SI ensemble function.
|
|
451
|
+
|
|
452
|
+
Notes
|
|
453
|
+
-----
|
|
454
|
+
Intensities are averaged after coherent summation within each realization;
|
|
455
|
+
random amplitudes are never averaged together. Standard errors describe
|
|
456
|
+
realization sampling, not spatial or angular discretization error. One
|
|
457
|
+
realization gives NaN errors. Numerical integrated coefficients are
|
|
458
|
+
finite-sample cross sections divided by volume. Check voxel refinement,
|
|
459
|
+
angular quadrature, sample size, and realization count independently.
|
|
460
|
+
Deterministic structures have no realization sampling and must use
|
|
461
|
+
solve. A single random realization reports unknown sampling errors
|
|
462
|
+
(NaN), rather than inferred uncertainty. Individual shape/spacing, angular and realization keywords remain
|
|
463
|
+
available with deprecation warnings.
|
|
464
|
+
|
|
465
|
+
Examples
|
|
466
|
+
--------
|
|
467
|
+
>>> from bornsim import RandomMedium, Solver, Source
|
|
468
|
+
>>> from bornsim.units import ureg
|
|
469
|
+
...
|
|
470
|
+
...
|
|
471
|
+
>>> ensemble_solver = Solver(
|
|
472
|
+
... source=Source(
|
|
473
|
+
... wavelength=633e-9 * ureg.meter,
|
|
474
|
+
... ),
|
|
475
|
+
... order=2,
|
|
476
|
+
... )
|
|
477
|
+
|
|
478
|
+
...
|
|
479
|
+
>>> result = ensemble_solver.ensemble(
|
|
480
|
+
... medium=RandomMedium(
|
|
481
|
+
... correlation="gaussian",
|
|
482
|
+
... background_refractive_index=1.33,
|
|
483
|
+
... refractive_index_std=0.01,
|
|
484
|
+
... correlation_length=100e-9 * ureg.meter,
|
|
485
|
+
... ),
|
|
486
|
+
... shape=(2, 2, 2),
|
|
487
|
+
... realizations=2,
|
|
488
|
+
... seed=42,
|
|
489
|
+
... angles=[0, 1] * ureg.radian,
|
|
490
|
+
... azimuth_samples=4,
|
|
491
|
+
... polar_samples=16,
|
|
492
|
+
... spacing=50e-9 * ureg.meter,
|
|
493
|
+
... )
|
|
494
|
+
>>> result.stderr.shape
|
|
495
|
+
(2, 2, 4)
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
if not isinstance(medium, Medium):
|
|
499
|
+
raise TypeError("medium must be a Medium.")
|
|
500
|
+
|
|
501
|
+
grid = Grid._resolve(
|
|
502
|
+
grid=grid,
|
|
503
|
+
shape=shape,
|
|
504
|
+
spacing=spacing,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if sampling is None and all(value is None for value in (angles, azimuth_samples, polar_samples)):
|
|
508
|
+
sampling = self.sampling
|
|
509
|
+
|
|
510
|
+
sampling = AngularSampling._resolve(
|
|
511
|
+
sampling=sampling,
|
|
512
|
+
angles=angles,
|
|
513
|
+
polar_samples=polar_samples,
|
|
514
|
+
azimuth_samples=azimuth_samples,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
ensemble_sampling = EnsembleSampling._resolve(
|
|
518
|
+
ensemble_sampling=ensemble_sampling,
|
|
519
|
+
realizations=realizations,
|
|
520
|
+
seed=seed,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
result = ensemble_scattering(
|
|
524
|
+
medium=medium,
|
|
525
|
+
wavelength=self.source.wavelength,
|
|
526
|
+
grid=grid,
|
|
527
|
+
sampling=sampling,
|
|
528
|
+
order=self.order,
|
|
529
|
+
ensemble_sampling=ensemble_sampling,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return Result(
|
|
533
|
+
source=self.source,
|
|
534
|
+
kind="ensemble",
|
|
535
|
+
sample_volume=grid.volume,
|
|
536
|
+
angles=result["angles"].copy(),
|
|
537
|
+
differential=result["directional_differential"] * (1 / ureg.meter / ureg.steradian),
|
|
538
|
+
azimuths=result["azimuths"],
|
|
539
|
+
term_differential=result["directional_terms"] * (1 / ureg.meter / ureg.steradian),
|
|
540
|
+
stderr=result["directional_stderr"] * (1 / ureg.meter / ureg.steradian),
|
|
541
|
+
azimuth_stderr=result["stderr"] * (1 / ureg.meter / ureg.steradian),
|
|
542
|
+
mu_s=result["mu_s"] * (1 / ureg.meter),
|
|
543
|
+
g=result["g"],
|
|
544
|
+
mu_s_prime=result["mu_s_prime"] * (1 / ureg.meter),
|
|
545
|
+
field_norms=result["field_norms"],
|
|
546
|
+
warnings=result["warnings"],
|
|
547
|
+
realizations=result["realizations"],
|
|
548
|
+
provenance=_provenance(
|
|
549
|
+
order=self.order,
|
|
550
|
+
medium=medium.metadata,
|
|
551
|
+
grid=grid.metadata,
|
|
552
|
+
sampling=sampling.metadata,
|
|
553
|
+
seed=ensemble_sampling.seed,
|
|
554
|
+
seeds=list(ensemble_sampling.seeds),
|
|
555
|
+
ensemble_sampling=ensemble_sampling.metadata,
|
|
556
|
+
realizations=result["realizations"],
|
|
557
|
+
polar_samples=sampling.polar_samples,
|
|
558
|
+
azimuth_samples=sampling.azimuth_samples,
|
|
559
|
+
coefficient_scope="finite-sample",
|
|
560
|
+
),
|
|
561
|
+
)
|
bornsim/source.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Incident illumination for BornSim scattering calculations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .units import Quantity, validate_units
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, kw_only=True)
|
|
9
|
+
class Source:
|
|
10
|
+
"""Define an unpolarized plane wave propagating along the positive z axis.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
wavelength : Quantity
|
|
15
|
+
Positive, finite vacuum wavelength. Explicit length units are required;
|
|
16
|
+
the supplied units are preserved. A wavelength must be supplied.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
wavelength : Quantity
|
|
21
|
+
Scalar vacuum wavelength with its supplied length units.
|
|
22
|
+
|
|
23
|
+
Raises
|
|
24
|
+
------
|
|
25
|
+
ValueError
|
|
26
|
+
If the wavelength is nonpositive, nonfinite, nonscalar, or has units
|
|
27
|
+
incompatible with length.
|
|
28
|
+
|
|
29
|
+
Notes
|
|
30
|
+
-----
|
|
31
|
+
The transverse x and y incident polarizations have equal weights and are
|
|
32
|
+
averaged incoherently. Arbitrary incidence directions and polarized
|
|
33
|
+
illumination are not currently supported.
|
|
34
|
+
|
|
35
|
+
Examples
|
|
36
|
+
--------
|
|
37
|
+
>>> from bornsim import Source
|
|
38
|
+
>>> from bornsim.units import ureg
|
|
39
|
+
...
|
|
40
|
+
...
|
|
41
|
+
>>> source = Source(wavelength=633 * ureg.nanometer)
|
|
42
|
+
>>> source.wavelength.check("[length]")
|
|
43
|
+
True
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
wavelength: Quantity
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
validate_units(
|
|
50
|
+
self.wavelength,
|
|
51
|
+
unit="meter",
|
|
52
|
+
name="wavelength",
|
|
53
|
+
scalar=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not np.isfinite(self.wavelength) or self.wavelength <= 0:
|
|
57
|
+
raise ValueError("wavelength must be finite and positive.")
|
bornsim/units.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared TypedUnit registry and explicit quantity validation.
|
|
2
|
+
|
|
3
|
+
Validation preserves the supplied units. Dimensional inputs require quantities;
|
|
4
|
+
dimensionless inputs may be bare numbers. Numerical kernels, plotting and
|
|
5
|
+
serialization explicitly select units when they need magnitudes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Literal, overload
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
from TypedUnit import Angle, Dimensionless, Length, Quantity, RefractiveIndex, ureg
|
|
12
|
+
|
|
13
|
+
__all__ = ["ureg", "Quantity", "Length", "Angle", "Dimensionless", "RefractiveIndex", "validate_units"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_units(value: object, *, unit: str, name: str, scalar: bool = False) -> None:
|
|
17
|
+
"""Check units and shape without converting or stripping the quantity."""
|
|
18
|
+
|
|
19
|
+
if not isinstance(value, Quantity):
|
|
20
|
+
raise ValueError(f"{name} requires an explicit quantity with units compatible with {unit}.")
|
|
21
|
+
|
|
22
|
+
if unit == "radian" and value.units == ureg.dimensionless:
|
|
23
|
+
raise ValueError(f"{name} requires explicit angular units, such as degree or radian.")
|
|
24
|
+
|
|
25
|
+
if not value.is_compatible_with(unit):
|
|
26
|
+
raise ValueError(f"{name} must have units compatible with {unit}.")
|
|
27
|
+
|
|
28
|
+
if scalar and np.ndim(value.magnitude) != 0:
|
|
29
|
+
raise ValueError(f"{name} must be a scalar.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@overload
|
|
33
|
+
def _dimensionless(*, value: Any, name: str, scalar: Literal[True]) -> float: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@overload
|
|
37
|
+
def _dimensionless(*, value: Any, name: str, scalar: Literal[False] = False) -> NDArray[np.float64]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dimensionless(*, value: Any, name: str, scalar: bool = False) -> float | NDArray[np.float64]:
|
|
41
|
+
"""Validate dimensionless numbers, including scaled units such as percent."""
|
|
42
|
+
|
|
43
|
+
if isinstance(value, Quantity):
|
|
44
|
+
validate_units(
|
|
45
|
+
value,
|
|
46
|
+
unit="dimensionless",
|
|
47
|
+
name=name,
|
|
48
|
+
scalar=scalar,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
value = value.to("dimensionless").magnitude
|
|
52
|
+
|
|
53
|
+
data = np.asarray(value, dtype=float)
|
|
54
|
+
|
|
55
|
+
if scalar:
|
|
56
|
+
if data.ndim != 0:
|
|
57
|
+
raise ValueError(f"{name} must be a scalar.")
|
|
58
|
+
|
|
59
|
+
return float(data)
|
|
60
|
+
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@overload
|
|
65
|
+
def _refractive_index_values(*, value: Any, name: str, scalar: Literal[True]) -> float: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@overload
|
|
69
|
+
def _refractive_index_values(*, value: Any, name: str, scalar: Literal[False] = False) -> NDArray[np.float64]: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _refractive_index_values(*, value: Any, name: str, scalar: bool = False) -> float | NDArray[np.float64]:
|
|
73
|
+
"""Require plain numerical refractive indices and fluctuations, without units."""
|
|
74
|
+
|
|
75
|
+
if isinstance(value, Quantity):
|
|
76
|
+
raise ValueError(f"{name} must be unitless; supply plain numbers without units.")
|
|
77
|
+
|
|
78
|
+
if scalar:
|
|
79
|
+
return _dimensionless(value=value, name=name, scalar=True)
|
|
80
|
+
|
|
81
|
+
return _dimensionless(value=value, name=name)
|