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