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/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
+ )