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/model.py ADDED
@@ -0,0 +1,276 @@
1
+ """Vector first-order Born model, with SI lengths and unpolarized light.
2
+
3
+ The dielectric covariance uses the weak-fluctuation linearization
4
+ C_epsilon(r) = 4 n_background**2 C_n(r). The spectral convention is
5
+ Phi(q) = integral C(r) exp(-i q.r) d^3 r (no Fourier prefactor).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import TypedDict
10
+ import numpy as np
11
+ from .units import Quantity, validate_units, ureg
12
+ from .media import RandomMedium
13
+
14
+
15
+ class OpticalProperties(TypedDict):
16
+ """Analytical transport coefficients and dimensionless anisotropy."""
17
+
18
+ mu_s: Quantity
19
+ g: float | None
20
+ mu_s_prime: Quantity
21
+
22
+
23
+ @dataclass(frozen=True, kw_only=True)
24
+ class AnalyticalMedium(RandomMedium):
25
+ """Define random-medium statistics for the existing analytical first-order model.
26
+
27
+ Parameters
28
+ ----------
29
+ background_refractive_index : float
30
+ Positive, finite background refractive index. Plain numbers are required; quantities are rejected.
31
+ refractive_index_std : float
32
+ Nonnegative, finite standard deviation of refractive index fluctuations, not their
33
+ variance. Dimensionless;
34
+ correlation_length : Quantity
35
+ Positive, finite covariance length. Explicit length units are required;
36
+ the supplied units are preserved.
37
+ correlation : {'gaussian', 'exponential'}
38
+ Spatial covariance model.
39
+
40
+ Attributes
41
+ ----------
42
+ background_refractive_index, refractive_index_std : float
43
+ Dimensionless statistics stored as numeric SI values.
44
+ correlation_length : Quantity
45
+ Covariance length with its supplied units.
46
+ correlation : str
47
+ Selected spatial covariance model.
48
+
49
+ Raises
50
+ ------
51
+ ValueError
52
+ If a statistic is nonfinite, outside its allowed range, nonscalar,
53
+ has incompatible units, or the covariance model is unsupported.
54
+
55
+ Notes
56
+ -----
57
+ This concrete subclass replaces the former directly instantiated Medium.
58
+ It retains the existing first-order Gaussian and exponential formulas,
59
+ and inherits numerical voxel generation from RandomMedium.
60
+ The refractive index covariance is ``refractive_index_std**2 * exp(-r**2 / (2 * ell**2))``
61
+ for Gaussian correlation and ``refractive_index_std**2 * exp(-r / ell)`` for
62
+ exponential correlation, where ``ell = correlation_length``. Equal length
63
+ parameters therefore do not imply identical correlation profiles.
64
+ Dielectric contrast is linearized as ``delta_epsilon = 2 * n0 * delta_n``.
65
+ The Gaussian probability distribution used by :func:`random_volume` is
66
+ independent of this choice of spatial covariance.
67
+
68
+ Examples
69
+ --------
70
+ >>> from bornsim import AnalyticalMedium
71
+ >>> from bornsim.units import ureg
72
+ ...
73
+ ...
74
+ >>> medium = AnalyticalMedium(
75
+ ... correlation_length=100 * ureg.nanometer,
76
+ ... background_refractive_index=1.33,
77
+ ... refractive_index_std=0.01,
78
+ ... correlation="gaussian",
79
+ ... )
80
+ >>> medium.correlation
81
+ 'gaussian'
82
+ """
83
+
84
+ background_refractive_index: float
85
+ refractive_index_std: float
86
+ correlation_length: Quantity
87
+ correlation: str
88
+
89
+ def __post_init__(self):
90
+ super().__post_init__()
91
+
92
+ if self.correlation not in ("gaussian", "exponential"):
93
+ raise ValueError("AnalyticalMedium correlation must be gaussian or exponential.")
94
+
95
+
96
+ def angular_scattering(*, medium: AnalyticalMedium, wavelength: Quantity, theta: Quantity) -> Quantity:
97
+ """Evaluate the unpolarized first-order differential scattering coefficient.
98
+
99
+ Parameters
100
+ ----------
101
+ medium : AnalyticalMedium
102
+ Background refractive index and isotropic fluctuation statistics.
103
+ wavelength : Quantity
104
+ Positive, finite vacuum wavelength. Explicit length units are required.
105
+ theta : array_like or Quantity
106
+ Finite polar scattering angles in [0, pi]. Explicit angular units are required;
107
+ angular quantities may use degrees or radians. Scalars are accepted.
108
+
109
+ Returns
110
+ -------
111
+ differential : Quantity
112
+ Differential scattering coefficient in m^-1 sr^-1, with the same shape
113
+ as ``theta``. The result retains physical units.
114
+
115
+ Raises
116
+ ------
117
+ ValueError
118
+ If the wavelength is nonpositive, nonfinite, nonscalar, or has
119
+ incompatible units, or angles are invalid or have incompatible units.
120
+
121
+ See Also
122
+ --------
123
+ optical_properties : Integrate the coefficient and its angular moments.
124
+ bornsim.solver.Solver.solve : Return analytical curves as unitful results.
125
+
126
+ Notes
127
+ -----
128
+ With ``k0 = 2*pi/wavelength`` and ``q = 2*n0*k0*sin(theta/2)``, the
129
+ coefficient is ``k0**4 * Phi_epsilon(q) * (1 + cos(theta)**2) / (32*pi**2)``.
130
+ The spectral convention is the three-dimensional Fourier transform without
131
+ an additional normalization prefactor. The dielectric covariance follows
132
+ ``C_epsilon = 4 * n0**2 * C_n`` under weak-fluctuation linearization.
133
+
134
+ For nonzero total scattering, the phase function per steradian is this
135
+ coefficient divided by ``optical_properties(...)["mu_s"]``. Its integral
136
+ over solid angle is one; it is not a probability density per polar angle.
137
+
138
+ Examples
139
+ --------
140
+ >>> from bornsim import AnalyticalMedium, angular_scattering
141
+ >>> from bornsim.units import ureg
142
+ ...
143
+ ...
144
+ >>> curve = angular_scattering(
145
+ ... medium=AnalyticalMedium(
146
+ ... background_refractive_index=1.33,
147
+ ... refractive_index_std=0.01,
148
+ ... correlation_length=100e-9 * ureg.meter,
149
+ ... correlation="gaussian",
150
+ ... ),
151
+ ... wavelength=633 * ureg.nanometer,
152
+ ... theta=[0, 90, 180] * ureg.degree,
153
+ ... )
154
+ >>> curve.shape
155
+ (3,)
156
+ """
157
+
158
+ if not isinstance(medium, AnalyticalMedium):
159
+ raise TypeError("Analytical scattering requires an AnalyticalMedium.")
160
+
161
+ validate_units(
162
+ wavelength,
163
+ unit="meter",
164
+ name="wavelength",
165
+ scalar=True,
166
+ )
167
+
168
+ if not np.isfinite(wavelength) or wavelength <= 0:
169
+ raise ValueError("wavelength must be finite and positive.")
170
+
171
+ validate_units(
172
+ theta,
173
+ unit="radian",
174
+ name="theta",
175
+ )
176
+
177
+ if np.any(~np.isfinite(theta)) or np.any((theta < 0) | (theta > np.pi * ureg.radian)):
178
+ raise ValueError("theta must be finite and between 0 and pi.")
179
+
180
+ k0 = 2 * np.pi / wavelength
181
+
182
+ q = 2 * k0 * medium.background_refractive_index * np.sin(theta / 2)
183
+
184
+ ell = medium.correlation_length
185
+
186
+ variance = (2 * medium.background_refractive_index * medium.refractive_index_std) ** 2
187
+
188
+ if medium.correlation == "gaussian":
189
+ spectrum = variance * (2 * np.pi) ** 1.5 * ell**3 * np.exp(-0.5 * (q * ell) ** 2)
190
+ else:
191
+ spectrum = variance * 8 * np.pi * ell**3 / (1 + (q * ell) ** 2) ** 2
192
+
193
+ return k0**4 / (16 * np.pi**2) * spectrum * (1 + np.cos(theta) ** 2) / 2 / ureg.steradian
194
+
195
+
196
+ def optical_properties(
197
+ *, medium: AnalyticalMedium, wavelength: Quantity, quadrature_order: int = 256
198
+ ) -> OpticalProperties:
199
+ """Integrate analytical scattering and its moments over solid angle.
200
+
201
+ Parameters
202
+ ----------
203
+ medium : AnalyticalMedium
204
+ Background refractive index and isotropic fluctuation statistics.
205
+ wavelength : Quantity
206
+ Positive, finite vacuum wavelength; explicit length units are required.
207
+ quadrature_order : int, optional
208
+ Number of Gauss-Legendre nodes in the cosine of the scattering angle.
209
+ Must be at least 16; default is 256.
210
+
211
+ Returns
212
+ -------
213
+ properties : dict
214
+ Unit-bearing coefficients under the following keys:
215
+
216
+ * ``mu_s`` : total scattering coefficient in m^-1.
217
+ * ``g`` : dimensionless mean cosine of the scattering angle, or
218
+ ``None`` if ``mu_s`` is zero.
219
+ * ``mu_s_prime`` : reduced scattering coefficient in m^-1, equal to
220
+ ``mu_s * (1 - g)`` when ``g`` is defined.
221
+
222
+ Raises
223
+ ------
224
+ ValueError
225
+ If the quadrature order, wavelength, or wavelength units are invalid.
226
+
227
+ See Also
228
+ --------
229
+ angular_scattering : Evaluate the integrand at arbitrary polar angles.
230
+
231
+ Notes
232
+ -----
233
+ Axial symmetry gives ``mu_s = 2*pi*integral(beta(theta)*sin(theta), theta)``
234
+ over [0, pi], where ``beta`` is the differential coefficient. The
235
+ anisotropy uses the same integral with an additional ``cos(theta)`` factor.
236
+ These coefficients describe the analytical infinite-medium first-order
237
+ model, unlike the finite-sample coefficients from numerical ensembles.
238
+ Increase ``quadrature_order`` to check strongly forward-peaked cases.
239
+
240
+ Examples
241
+ --------
242
+ >>> from bornsim import AnalyticalMedium, optical_properties
243
+ ...
244
+ >>> from bornsim.units import ureg
245
+ ...
246
+ >>> properties = optical_properties(
247
+ ... medium=AnalyticalMedium(
248
+ ... refractive_index_std=0,
249
+ ... background_refractive_index=1.33,
250
+ ... correlation_length=100e-9 * ureg.meter,
251
+ ... correlation="gaussian",
252
+ ... ),
253
+ ... wavelength=633 * ureg.nanometer,
254
+ ... )
255
+ >>> float(properties["mu_s"].to("1 / meter").magnitude)
256
+ 0.0
257
+ """
258
+
259
+ if not isinstance(quadrature_order, int) or quadrature_order < 16:
260
+ raise ValueError("quadrature_order must be an integer of at least 16.")
261
+
262
+ cosine, weights = np.polynomial.legendre.leggauss(quadrature_order)
263
+
264
+ differential = angular_scattering(
265
+ medium=medium,
266
+ wavelength=wavelength,
267
+ theta=np.arccos(cosine) * ureg.radian,
268
+ )
269
+
270
+ mu_s = 2 * np.pi * ureg.steradian * np.sum(weights * differential)
271
+
272
+ mu_s_prime = 2 * np.pi * ureg.steradian * np.sum(weights * (1 - cosine) * differential)
273
+
274
+ g = float(2 * np.pi * ureg.steradian * np.sum(weights * cosine * differential) / mu_s) if mu_s else None
275
+
276
+ return {"mu_s": mu_s, "g": g, "mu_s_prime": mu_s_prime}