miepython 3.2.0__py3-none-any.whl → 3.3.1__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.
- miepython/__init__.py +33 -2
- miepython/core.py +172 -40
- miepython/field.py +239 -293
- miepython/mie_jit.py +202 -97
- miepython/mie_nojit.py +166 -86
- miepython/monte_carlo.py +157 -66
- miepython/rayleigh.py +185 -47
- miepython/util.py +25 -5
- miepython/vsh.py +16 -7
- {miepython-3.2.0.dist-info → miepython-3.3.1.dist-info}/METADATA +8 -8
- miepython-3.3.1.dist-info/RECORD +18 -0
- {miepython-3.2.0.dist-info → miepython-3.3.1.dist-info}/WHEEL +1 -1
- miepython-3.2.0.dist-info/RECORD +0 -18
- {miepython-3.2.0.dist-info → miepython-3.3.1.dist-info}/licenses/LICENSE.txt +0 -0
- {miepython-3.2.0.dist-info → miepython-3.3.1.dist-info}/top_level.txt +0 -0
miepython/mie_nojit.py
CHANGED
|
@@ -111,9 +111,73 @@ def _D_upwards(z, N, D):
|
|
|
111
111
|
D[n] = 1 / (n / z - D[n - 1]) - n / z
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def _psi_downwards_py(z, nstop):
|
|
115
|
+
"""
|
|
116
|
+
Compute the Riccati-Bessel functions psi_0(z)..psi_nstop(z).
|
|
117
|
+
|
|
118
|
+
The three-term upwards recurrence
|
|
119
|
+
``psi_{n+1} = (2n+1)/z psi_n - psi_{n-1}`` is contaminated by the growing
|
|
120
|
+
chi_n solution once n exceeds ``|z|``, which is the normal case for psi_n(mx)
|
|
121
|
+
whenever the relative index is below one. Miller's algorithm is used
|
|
122
|
+
instead: seed the recurrence well above ``|z|`` where psi_n is the decaying
|
|
123
|
+
solution, run it downwards, then fix the arbitrary scale against whichever
|
|
124
|
+
of psi_0 or psi_1 is larger. sin(z) and cos(z) cannot both be small, so one
|
|
125
|
+
of those two seeds is always well conditioned.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
z: function argument (the refractive index times the size parameter)
|
|
129
|
+
nstop: highest order needed
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Array of psi_k(z) for k=0 to nstop
|
|
133
|
+
"""
|
|
134
|
+
abs_z = abs(z)
|
|
135
|
+
n_start = int(max(float(nstop), abs_z)) + 25 + int(1.5 * np.sqrt(abs_z))
|
|
136
|
+
|
|
137
|
+
psi = np.zeros(n_start + 2, dtype=np.complex128)
|
|
138
|
+
psi[n_start] = 1e-50
|
|
139
|
+
for n in range(n_start, 0, -1):
|
|
140
|
+
psi[n - 1] = (2 * n + 1) / z * psi[n] - psi[n + 1]
|
|
141
|
+
if abs(psi[n - 1]) > 1e200: # keep the growing tail in range
|
|
142
|
+
psi[n - 1 :] /= 1e200
|
|
143
|
+
|
|
144
|
+
sin_z = np.sin(z)
|
|
145
|
+
psi_0 = sin_z
|
|
146
|
+
psi_1 = sin_z / z - np.cos(z)
|
|
147
|
+
if abs(psi_1) > abs(psi_0):
|
|
148
|
+
psi *= psi_1 / psi[1]
|
|
149
|
+
else:
|
|
150
|
+
psi *= psi_0 / psi[0]
|
|
151
|
+
|
|
152
|
+
return psi[: nstop + 1]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _D_calc_down_py(z, N):
|
|
156
|
+
"""
|
|
157
|
+
Compute D_1(z)..D_N(z) using only the downwards recurrence.
|
|
158
|
+
|
|
159
|
+
``_D_calc_py`` picks between the upwards and downwards recurrences with
|
|
160
|
+
Wiscombe's criterion, which considers the refractive index but not the number
|
|
161
|
+
of terms. The upwards recurrence loses accuracy once N exceeds ``|z|`` (about
|
|
162
|
+
4 digits for a small sphere), and the resulting noise differs between the
|
|
163
|
+
numba and pure-python backends. The internal-field coefficients are not on
|
|
164
|
+
the hot path, so they always take the stable route.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
z: function argument (already multiplied by the refractive index)
|
|
168
|
+
N: highest order needed
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Array of logarithmic derivatives D_k(z) for k=1 to N
|
|
172
|
+
"""
|
|
173
|
+
D = np.zeros(N + 1, dtype=np.complex128)
|
|
174
|
+
_D_downwards(z, N, D)
|
|
175
|
+
return D[1:]
|
|
176
|
+
|
|
177
|
+
|
|
114
178
|
def _D_calc_py(m, x, N):
|
|
115
179
|
"""
|
|
116
|
-
Compute the logarithmic derivative of ψ_n(z) using the
|
|
180
|
+
Compute the logarithmic derivative of ψ_n(z) using the downwards recurrence.
|
|
117
181
|
|
|
118
182
|
D_n(z) = d[log ψ_n(z)] = ψ_n'(z)/ψ_n(z)
|
|
119
183
|
|
|
@@ -122,6 +186,14 @@ def _D_calc_py(m, x, N):
|
|
|
122
186
|
|
|
123
187
|
The zero-based array, D[:], is shifted so that D[0] = D₁(z) = ψ₁'(z)/ψ₁(z)
|
|
124
188
|
|
|
189
|
+
Wiscombe's criterion used to pick between the upwards and downwards
|
|
190
|
+
recurrences from the refractive index alone. The upwards recurrence is
|
|
191
|
+
contaminated once N passes ``|mx|``, which is the case for every small sphere,
|
|
192
|
+
and it left the Mie coefficients wrong by as much as 1% near m=1. The
|
|
193
|
+
downwards recurrence is stable everywhere, and the criterion already chose it
|
|
194
|
+
for most large spheres, so always taking it costs little. ``_D_upwards`` is
|
|
195
|
+
kept for comparison and testing.
|
|
196
|
+
|
|
125
197
|
Args:
|
|
126
198
|
m: the np.complex128 index of refraction of the sphere
|
|
127
199
|
x: the size parameter of the sphere
|
|
@@ -130,16 +202,7 @@ def _D_calc_py(m, x, N):
|
|
|
130
202
|
Returns:
|
|
131
203
|
Array of logarithmic derivatives D_k(z) for k=1 to N-1.
|
|
132
204
|
"""
|
|
133
|
-
|
|
134
|
-
kappa = np.abs(m.imag)
|
|
135
|
-
D = np.zeros(N + 1, dtype=np.complex128)
|
|
136
|
-
mx = np.complex128(m * x) # ensure np.complex128
|
|
137
|
-
|
|
138
|
-
if n < 1 or n > 10 or kappa > 10 or x * kappa >= 3.9 - 10.8 * n + 13.78 * n**2:
|
|
139
|
-
_D_downwards(mx, N, D)
|
|
140
|
-
else:
|
|
141
|
-
_D_upwards(mx, N, D)
|
|
142
|
-
return D[1:]
|
|
205
|
+
return _D_calc_down_py(np.complex128(m * x), N)
|
|
143
206
|
|
|
144
207
|
|
|
145
208
|
def _an_bn_py(m, x, n_pole=0):
|
|
@@ -150,8 +213,8 @@ def _an_bn_py(m, x, n_pole=0):
|
|
|
150
213
|
formula. The length of the arrays is chosen so that the error when the series
|
|
151
214
|
is summed is around 1e-6.
|
|
152
215
|
|
|
153
|
-
If n_pole>0, then the
|
|
154
|
-
trying to isolate the behavior of a particular multipole.
|
|
216
|
+
If n_pole>0, then the arrays hold exactly n_pole terms, orders 1 to n_pole.
|
|
217
|
+
This is useful when trying to isolate the behavior of a particular multipole.
|
|
155
218
|
|
|
156
219
|
To support resonance calculations, one can specify the number of terms
|
|
157
220
|
to be calculated. In general, using too few or too many terms increases the
|
|
@@ -164,58 +227,64 @@ def _an_bn_py(m, x, n_pole=0):
|
|
|
164
227
|
n_pole: the number of An and Bn terms (0 does autosizing)
|
|
165
228
|
|
|
166
229
|
Returns:
|
|
167
|
-
a, b: arrays of Mie coefficents An and Bn
|
|
230
|
+
a, b: arrays of Mie coefficents An and Bn, one entry per order,
|
|
231
|
+
with no padding. ``_cn_dn`` returns the same number of terms.
|
|
168
232
|
"""
|
|
169
233
|
if m.imag > 0: # ensure imaginary part of refractive index is negative
|
|
170
234
|
m = np.conj(m)
|
|
171
235
|
|
|
236
|
+
# Wiscombe's truncation: the series is summed through order n_terms
|
|
172
237
|
if n_pole == 0:
|
|
173
|
-
|
|
238
|
+
n_terms = int(x + 4.05 * x**0.33333 + 2.0)
|
|
174
239
|
else:
|
|
175
|
-
|
|
240
|
+
n_terms = n_pole
|
|
176
241
|
|
|
177
|
-
a = np.zeros(
|
|
178
|
-
b = np.zeros(
|
|
242
|
+
a = np.zeros(n_terms, dtype=np.complex128)
|
|
243
|
+
b = np.zeros(n_terms, dtype=np.complex128)
|
|
179
244
|
if x <= 0:
|
|
180
245
|
return a, b
|
|
181
246
|
|
|
182
247
|
inv_x = 1.0 / x
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
248
|
+
sin_x = np.sin(x)
|
|
249
|
+
cos_x = np.cos(x)
|
|
250
|
+
|
|
251
|
+
# psi comes from Miller's downwards recurrence. The upwards one is fine while
|
|
252
|
+
# n stays below x, which is all Wiscombe's truncation needs, but its relative
|
|
253
|
+
# accuracy is already down to about 1e-6 at n = n_terms and it collapses
|
|
254
|
+
# completely past that, so a caller asking for extra orders used to get noise.
|
|
255
|
+
psi = _psi_downwards_py(np.complex128(x), n_terms).real
|
|
256
|
+
|
|
257
|
+
# chi is the growing solution, so its upwards recurrence is the stable one.
|
|
258
|
+
# Building xi as psi + i*chi keeps xi exactly consistent with the psi above,
|
|
259
|
+
# which the lossless identity Re(a_n) == |a_n|**2 depends on.
|
|
260
|
+
chi_nm1 = cos_x
|
|
261
|
+
chi_n = cos_x * inv_x + sin_x
|
|
187
262
|
|
|
188
263
|
if m.real > 0.0:
|
|
189
|
-
D = _D_calc_py(m, x,
|
|
264
|
+
D = _D_calc_py(m, x, n_terms + 2)
|
|
190
265
|
|
|
191
|
-
for n in range(1,
|
|
266
|
+
for n in range(1, n_terms + 1):
|
|
192
267
|
n_over_x = n * inv_x
|
|
268
|
+
xi_nm1 = psi[n - 1] + 1j * chi_nm1
|
|
269
|
+
xi_n = psi[n] + 1j * chi_n
|
|
193
270
|
temp = D[n - 1] / m + n_over_x
|
|
194
|
-
a[n - 1] = (temp *
|
|
271
|
+
a[n - 1] = (temp * psi[n] - psi[n - 1]) / (temp * xi_n - xi_nm1)
|
|
195
272
|
temp = D[n - 1] * m + n_over_x
|
|
196
|
-
b[n - 1] = (temp *
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
xi_nm1 = xi_n
|
|
201
|
-
xi_n = xi
|
|
202
|
-
psi_nm1 = psi_n
|
|
203
|
-
psi_n = psi
|
|
273
|
+
b[n - 1] = (temp * psi[n] - psi[n - 1]) / (temp * xi_n - xi_nm1)
|
|
274
|
+
chi = (2 * n + 1) * inv_x * chi_n - chi_nm1
|
|
275
|
+
chi_nm1 = chi_n
|
|
276
|
+
chi_n = chi
|
|
204
277
|
|
|
205
278
|
else:
|
|
206
|
-
for n in range(1,
|
|
279
|
+
for n in range(1, n_terms + 1):
|
|
207
280
|
n_over_x = n * inv_x
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if n_pole != 0:
|
|
217
|
-
a = a[:-1]
|
|
218
|
-
b = b[:-1]
|
|
281
|
+
xi_nm1 = psi[n - 1] + 1j * chi_nm1
|
|
282
|
+
xi_n = psi[n] + 1j * chi_n
|
|
283
|
+
a[n - 1] = (n_over_x * psi[n] - psi[n - 1]) / (n_over_x * xi_n - xi_nm1)
|
|
284
|
+
b[n - 1] = psi[n] / xi_n
|
|
285
|
+
chi = (2 * n + 1) * inv_x * chi_n - chi_nm1
|
|
286
|
+
chi_nm1 = chi_n
|
|
287
|
+
chi_n = chi
|
|
219
288
|
|
|
220
289
|
return np.conjugate(a), np.conjugate(b)
|
|
221
290
|
|
|
@@ -237,55 +306,46 @@ def _cn_dn_py(m, x, n_pole):
|
|
|
237
306
|
m = np.conj(m)
|
|
238
307
|
mx = m * x
|
|
239
308
|
|
|
309
|
+
# same truncation as _an_bn_py so the internal and external series match
|
|
240
310
|
if n_pole == 0:
|
|
241
|
-
|
|
311
|
+
n_terms = int(x + 4.05 * x**0.33333 + 2.0)
|
|
242
312
|
else:
|
|
243
|
-
|
|
313
|
+
n_terms = n_pole
|
|
244
314
|
|
|
245
|
-
c = np.zeros(
|
|
246
|
-
d = np.zeros(
|
|
315
|
+
c = np.zeros(n_terms, dtype=np.complex128)
|
|
316
|
+
d = np.zeros(n_terms, dtype=np.complex128)
|
|
247
317
|
if x <= 0:
|
|
248
318
|
return c, d
|
|
249
319
|
|
|
250
320
|
inv_x = 1.0 / x
|
|
251
321
|
# no need to calculate anything when sphere is perfectly conducting
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
xi_nm1 = np.complex128(
|
|
261
|
-
xi_n = np.complex128(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
322
|
+
# (m.real <= 0 or an infinite index): there is no internal field, so c and d
|
|
323
|
+
# stay zero. The `and` chain must not be mixed with `or` here, otherwise the
|
|
324
|
+
# test is true for every finite index and m=0 divides by zero below.
|
|
325
|
+
if m.real > 0.0 and not np.isinf(m.real) and not np.isinf(m.imag):
|
|
326
|
+
sin_x = np.sin(x)
|
|
327
|
+
cos_x = np.cos(x)
|
|
328
|
+
|
|
329
|
+
# xi is the growing solution, so seeding it and running upwards is stable
|
|
330
|
+
xi_nm1 = np.complex128(sin_x + 1j * cos_x)
|
|
331
|
+
xi_n = np.complex128((sin_x * inv_x - cos_x) + 1j * (cos_x * inv_x + sin_x))
|
|
332
|
+
|
|
333
|
+
psi_x = _psi_downwards_py(np.complex128(x), n_terms + 1)
|
|
334
|
+
psi_mx = _psi_downwards_py(np.complex128(mx), n_terms + 1)
|
|
335
|
+
Dmx = _D_calc_down_py(np.complex128(mx), n_terms + 2)
|
|
336
|
+
Dx = _D_calc_down_py(np.complex128(x), n_terms + 2)
|
|
337
|
+
|
|
338
|
+
for n in range(1, n_terms + 1):
|
|
267
339
|
n_over_x = n * inv_x
|
|
268
|
-
common = (
|
|
340
|
+
common = (psi_x[n] / psi_mx[n]) * ((Dx[n - 1] + n_over_x) * xi_n - xi_nm1)
|
|
269
341
|
|
|
270
342
|
c[n - 1] = m * common / ((m * Dmx[n - 1] + n_over_x) * xi_n - xi_nm1)
|
|
271
343
|
d[n - 1] = common / ((Dmx[n - 1] / m + n_over_x) * xi_n - xi_nm1)
|
|
272
344
|
|
|
273
|
-
|
|
274
|
-
psi = two_np1 * inv_x * psi_n - psi_nm1
|
|
275
|
-
psi_nm1 = psi_n
|
|
276
|
-
psi_n = psi
|
|
277
|
-
|
|
278
|
-
psi_mx = two_np1 * inv_mx * psi_n_mx - psi_nm1_mx
|
|
279
|
-
psi_nm1_mx = psi_n_mx
|
|
280
|
-
psi_n_mx = psi_mx
|
|
281
|
-
|
|
282
|
-
xi = two_np1 * inv_x * xi_n - xi_nm1
|
|
345
|
+
xi = (2 * n + 1) * inv_x * xi_n - xi_nm1
|
|
283
346
|
xi_nm1 = xi_n
|
|
284
347
|
xi_n = xi
|
|
285
348
|
|
|
286
|
-
if n_pole != 0:
|
|
287
|
-
c = c[:-1]
|
|
288
|
-
d = d[:-1]
|
|
289
349
|
return np.conjugate(c), np.conjugate(d)
|
|
290
350
|
|
|
291
351
|
|
|
@@ -322,6 +382,9 @@ def _pi_tau_py(mu, pi, tau):
|
|
|
322
382
|
pi[n] = ((2 * n + 1) * mu * temp - (n + 1) * pi_nm2) / n
|
|
323
383
|
pi_nm2 = temp
|
|
324
384
|
|
|
385
|
+
# each pass sets tau one order behind pi, so the highest order is still due
|
|
386
|
+
tau[n_terms - 1] = n_terms * mu * pi[n_terms - 1] - (n_terms + 1) * pi_nm2
|
|
387
|
+
|
|
325
388
|
|
|
326
389
|
def _S1_S2_py(m, x, mu, n_pole):
|
|
327
390
|
"""
|
|
@@ -343,6 +406,9 @@ def _S1_S2_py(m, x, mu, n_pole):
|
|
|
343
406
|
"""
|
|
344
407
|
a, b = _an_bn_py(m, x, 0)
|
|
345
408
|
N = len(a)
|
|
409
|
+
if n_pole < 0 or n_pole > N:
|
|
410
|
+
raise ValueError("n_pole must be 0 (all terms) or a multipole order in 1.." + str(N))
|
|
411
|
+
|
|
346
412
|
pi = np.zeros(N)
|
|
347
413
|
tau = np.zeros(N)
|
|
348
414
|
scale = _series_scale_factors(N)
|
|
@@ -353,14 +419,17 @@ def _S1_S2_py(m, x, mu, n_pole):
|
|
|
353
419
|
S1 = np.zeros(nangles, dtype=np.complex128)
|
|
354
420
|
S2 = np.zeros(nangles, dtype=np.complex128)
|
|
355
421
|
|
|
422
|
+
# zero-based arrays: multipole order n lives at index n-1
|
|
423
|
+
j = n_pole - 1
|
|
424
|
+
|
|
356
425
|
for k in range(nangles):
|
|
357
426
|
_pi_tau_py(mu[k], pi, tau)
|
|
358
427
|
if n_pole == 0:
|
|
359
428
|
S1[k] = np.dot(pi, scale_a) + np.dot(tau, scale_b)
|
|
360
429
|
S2[k] = np.dot(tau, scale_a) + np.dot(pi, scale_b)
|
|
361
430
|
else:
|
|
362
|
-
S1[k] = scale[
|
|
363
|
-
S2[k] = scale[
|
|
431
|
+
S1[k] = scale[j] * (pi[j] * a[j] + tau[j] * b[j])
|
|
432
|
+
S2[k] = scale[j] * (tau[j] * a[j] + pi[j] * b[j])
|
|
364
433
|
|
|
365
434
|
return np.conjugate(S1), np.conjugate(S2)
|
|
366
435
|
|
|
@@ -457,7 +526,9 @@ def _single_sphere_py(m, x, n_pole, e_field):
|
|
|
457
526
|
m: the complex index of refraction of the sphere
|
|
458
527
|
x: the size parameter of the sphere
|
|
459
528
|
n_pole: a non-zero value returns the contribution by the n_pole multipole
|
|
460
|
-
e_field:
|
|
529
|
+
e_field: selects which multipole of order n_pole contributes, the
|
|
530
|
+
electric one a_n (True) or the magnetic one b_n (False).
|
|
531
|
+
Ignored when n_pole == 0.
|
|
461
532
|
|
|
462
533
|
Returns:
|
|
463
534
|
qext: the total extinction efficiency
|
|
@@ -465,7 +536,10 @@ def _single_sphere_py(m, x, n_pole, e_field):
|
|
|
465
536
|
qback: the backscatter efficiency
|
|
466
537
|
g: the average cosine of the scattering phase function
|
|
467
538
|
"""
|
|
468
|
-
|
|
539
|
+
# a sphere of zero size scatters nothing; every efficiency tends to zero,
|
|
540
|
+
# including qback, whose small-sphere form is 0/0 at x=0
|
|
541
|
+
if x <= 0:
|
|
542
|
+
return 0.0, 0.0, 0.0, 0.0
|
|
469
543
|
|
|
470
544
|
# case when sphere matches its environment
|
|
471
545
|
if abs(m.real - 1) <= 1e-8 and abs(m.imag) < 1e-8:
|
|
@@ -507,12 +581,18 @@ def _single_sphere_py(m, x, n_pole, e_field):
|
|
|
507
581
|
g = 4.0 * np.sum(asy1 + asy2) / qsca / x2
|
|
508
582
|
|
|
509
583
|
else:
|
|
584
|
+
# isolate one multipole: the electric term a_n or the magnetic term b_n
|
|
510
585
|
cn = 2.0 * n_pole + 1
|
|
511
|
-
|
|
512
|
-
qext = 2.0 * cn *
|
|
586
|
+
coeff = a[-1] if e_field else b[-1]
|
|
587
|
+
qext = 2.0 * cn * coeff.real / x2
|
|
588
|
+
# the (-1)**n_pole and the sign of b_n drop out of the modulus
|
|
589
|
+
qback = np.abs(cn * coeff) ** 2 / x2
|
|
513
590
|
qsca = qext
|
|
514
591
|
if m.imag < 0:
|
|
515
|
-
qsca = 2.0 * cn *
|
|
516
|
-
|
|
592
|
+
qsca = 2.0 * cn * np.abs(coeff) ** 2 / x2
|
|
593
|
+
|
|
594
|
+
# pi_n**2 and tau_n**2 are both even in mu, so an isolated multipole of
|
|
595
|
+
# one parity scatters symmetrically about 90 degrees and <cos(theta)>=0
|
|
596
|
+
g = 0.0
|
|
517
597
|
|
|
518
598
|
return qext, qsca, qback, g
|
miepython/monte_carlo.py
CHANGED
|
@@ -1,7 +1,64 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Sample random scattering angles from the Mie phase function.
|
|
3
|
+
|
|
4
|
+
A Monte Carlo simulation of light transport needs, at every scattering event, a
|
|
5
|
+
random direction in which the photon leaves the sphere. For unpolarized light
|
|
6
|
+
only the polar angle theta depends on the sphere, and it must be drawn so that
|
|
7
|
+
its cosine mu follows the phase function. This module does that by inverse
|
|
8
|
+
transform sampling: build the cumulative distribution of mu once, then map each
|
|
9
|
+
uniform random number u to the cosine at which the distribution reaches u.
|
|
10
|
+
|
|
11
|
+
``cdf``
|
|
12
|
+
The cumulative distribution at evenly spaced cosines.
|
|
13
|
+
``mu_with_uniform_cdf``
|
|
14
|
+
The cosines at which the cumulative distribution reaches evenly spaced
|
|
15
|
+
probabilities, a lookup table that makes each draw cheap.
|
|
16
|
+
``generate_mie_costheta``
|
|
17
|
+
One random cosine drawn from that table.
|
|
18
|
+
|
|
19
|
+
``docs/06_random_deviates.ipynb`` works through the whole procedure.
|
|
20
|
+
|
|
21
|
+
This module is *not* imported with the package, because it imports
|
|
22
|
+
``miepython`` itself and doing so from ``miepython/__init__.py`` would be
|
|
23
|
+
circular. Import it by name, as the examples below do.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
Invert the distribution with ``np.interp`` to draw many cosines at once.
|
|
27
|
+
Their average is the asymmetry parameter g:
|
|
28
|
+
|
|
29
|
+
>>> import numpy as np
|
|
30
|
+
>>> import miepython as mie
|
|
31
|
+
>>> import miepython.monte_carlo as mc
|
|
32
|
+
>>> m, x = 1.33, 2.0 # a water droplet
|
|
33
|
+
>>> mu, cdf = mc.cdf(m, x, 1000)
|
|
34
|
+
>>> rng = np.random.default_rng(42)
|
|
35
|
+
>>> cosines = np.interp(rng.random(100_000), cdf, mu)
|
|
36
|
+
>>> print(f"{np.mean(cosines):.2f}")
|
|
37
|
+
0.67
|
|
38
|
+
>>> qext, qsca, qback, g = mie.efficiencies_mx(m, x)
|
|
39
|
+
>>> print(f"{g:.2f}")
|
|
40
|
+
0.67
|
|
41
|
+
|
|
42
|
+
Or build a lookup table once and draw one cosine at a time, as a photon
|
|
43
|
+
transport loop does:
|
|
44
|
+
|
|
45
|
+
>>> mu_table, _ = mc.mu_with_uniform_cdf(m, x, 1000)
|
|
46
|
+
>>> cosines = [mc.generate_mie_costheta(mu_table, rng=rng) for _ in range(100_000)]
|
|
47
|
+
>>> print(f"{np.mean(cosines):.2f}")
|
|
48
|
+
0.67
|
|
49
|
+
|
|
50
|
+
Building either table costs a full Mie calculation, while each draw costs only a
|
|
51
|
+
random number or two, so build it once and reuse it. Evenly spaced cosines
|
|
52
|
+
resolve the narrow forward peak of a large sphere poorly; ``cdf`` explains how
|
|
53
|
+
many are needed and warns when there are too few, while ``mu_with_uniform_cdf``
|
|
54
|
+
chooses enough by itself.
|
|
55
|
+
|
|
56
|
+
Passing ``rng`` keeps the draws reproducible without disturbing the process-wide
|
|
57
|
+
``numpy.random``; omitting it falls back to that global generator.
|
|
3
58
|
"""
|
|
4
59
|
|
|
60
|
+
import warnings
|
|
61
|
+
|
|
5
62
|
import numpy as np
|
|
6
63
|
import miepython as mie
|
|
7
64
|
|
|
@@ -11,92 +68,107 @@ __all__ = (
|
|
|
11
68
|
"generate_mie_costheta",
|
|
12
69
|
)
|
|
13
70
|
|
|
71
|
+
# Largest acceptable relative error in the integral of the phase function
|
|
72
|
+
CDF_TOLERANCE = 0.01
|
|
73
|
+
|
|
14
74
|
|
|
15
75
|
def cdf(m, x, num):
|
|
16
76
|
"""
|
|
17
|
-
|
|
77
|
+
Cumulative distribution of the unpolarized phase function in cos(theta).
|
|
78
|
+
|
|
79
|
+
The cosines mu are evenly spaced from -1 (backscattering) to 1 (forward
|
|
80
|
+
scattering), and cdf[i] is the probability that a scattered photon leaves
|
|
81
|
+
with a cosine no greater than mu[i]. It is found by integrating the phase
|
|
82
|
+
function with the trapezoidal rule, so cdf[0] is exactly 0 and the values
|
|
83
|
+
never decrease.
|
|
18
84
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
85
|
+
A large sphere scatters much of its light into a diffraction peak about
|
|
86
|
+
4/x radians wide around the forward direction. Near theta = 0, mu = cos(theta)
|
|
87
|
+
is close to 1 - theta**2/2, so that peak spans only about 8/x**2 in mu,
|
|
88
|
+
while the cosines are 2/(num - 1) apart. To put several cosines inside the
|
|
89
|
+
peak, num must therefore grow roughly as x**2; x = 30 needs about 5000.
|
|
24
90
|
|
|
25
|
-
|
|
26
|
-
|
|
91
|
+
The phase function integrates to exactly one, so the raw trapezoidal sum
|
|
92
|
+
shows how well num resolves it. If that sum is off by more than
|
|
93
|
+
``CDF_TOLERANCE``, a ``RuntimeWarning`` is issued; increase num until it
|
|
94
|
+
stops. The result is then divided by the sum so that cdf[-1] is exactly 1.
|
|
27
95
|
|
|
28
96
|
Args:
|
|
29
|
-
m:
|
|
30
|
-
x:
|
|
31
|
-
num:
|
|
97
|
+
m: complex index of refraction of the sphere
|
|
98
|
+
x: size parameter of the sphere
|
|
99
|
+
num: number of cosines, at least two
|
|
32
100
|
|
|
33
101
|
Returns:
|
|
34
|
-
mu:
|
|
35
|
-
cdf:
|
|
102
|
+
mu: evenly spaced cosines of the scattering angle, from -1 to 1
|
|
103
|
+
cdf: cumulative probability at each cosine, from 0 to 1
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: if num is less than two or the phase function is not finite
|
|
36
107
|
"""
|
|
108
|
+
if num < 2:
|
|
109
|
+
raise ValueError("num must be at least 2")
|
|
110
|
+
return _trapezoid_cdf(m, x, num, stacklevel=3)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _trapezoid_cdf(m, x, num, stacklevel):
|
|
114
|
+
"""Integrate the phase function at num cosines, warning at the caller's caller."""
|
|
37
115
|
mu = np.linspace(-1, 1, num)
|
|
38
|
-
intensity_per_mu = mie.i_unpolarized(m, x, mu, norm="4pi") /
|
|
39
|
-
cdf_values = np.
|
|
116
|
+
intensity_per_mu = mie.i_unpolarized(m, x, mu, norm="4pi") / 2
|
|
117
|
+
cdf_values = np.zeros(num)
|
|
118
|
+
cdf_values[1:] = np.cumsum((intensity_per_mu[1:] + intensity_per_mu[:-1]) * np.diff(mu) / 2)
|
|
119
|
+
total = cdf_values[-1]
|
|
120
|
+
if not np.isfinite(total):
|
|
121
|
+
raise ValueError(f"phase function is not finite for m={m}, x={x}")
|
|
122
|
+
if abs(total - 1) > CDF_TOLERANCE:
|
|
123
|
+
warnings.warn(
|
|
124
|
+
f"phase function under-resolved for x={x}: integral over {num} angles is {total:.4g}, "
|
|
125
|
+
"not 1, so sampled angles will be inaccurate; use more angles",
|
|
126
|
+
RuntimeWarning,
|
|
127
|
+
stacklevel=stacklevel,
|
|
128
|
+
)
|
|
129
|
+
cdf_values /= total
|
|
40
130
|
return mu, cdf_values
|
|
41
131
|
|
|
42
132
|
|
|
43
133
|
def mu_with_uniform_cdf(m, x, num):
|
|
44
134
|
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
The CDF covers scattered (exit) angles ranging from 180 to 0 degrees.
|
|
48
|
-
(The cosines are uniformly distributed over -1 to 1.) These angles mu
|
|
49
|
-
correspond to uniform spacing of the cumulative distribution function
|
|
50
|
-
for unpolarized Mie scattering where cdf[i] = i/(num-1).
|
|
135
|
+
Inverse-transform table for sampling the unpolarized phase function.
|
|
51
136
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
137
|
+
The table holds num cosines mu running from -1 (backscattering) to 1
|
|
138
|
+
(forward scattering), spaced so that a scattered photon is equally likely
|
|
139
|
+
to fall between any two neighbours: cdf[i] is exactly i/(num - 1). The
|
|
140
|
+
cosines are therefore bunched where scattering is strong and sparse where
|
|
141
|
+
it is weak. ``generate_mie_costheta`` draws random cosines from this table.
|
|
55
142
|
|
|
56
|
-
|
|
57
|
-
|
|
143
|
+
The table is found by calculating ``cdf`` at max(2000, 5*x**2) evenly
|
|
144
|
+
spaced cosines, enough to resolve the forward peak whatever num is (see
|
|
145
|
+
``cdf``), and interpolating to find where it reaches each step. Because
|
|
146
|
+
that count grows as x**2, building the table takes seconds at x = 100 and
|
|
147
|
+
over a minute at x = 300.
|
|
58
148
|
|
|
59
149
|
Args:
|
|
60
|
-
m:
|
|
61
|
-
x:
|
|
62
|
-
num:
|
|
150
|
+
m: complex index of refraction of the sphere
|
|
151
|
+
x: size parameter of the sphere
|
|
152
|
+
num: number of entries in the table, at least two
|
|
63
153
|
|
|
64
154
|
Returns:
|
|
65
|
-
mu:
|
|
66
|
-
cdf:
|
|
67
|
-
"""
|
|
68
|
-
big_num = 2000 # large to work with x up to 10
|
|
69
|
-
big_mu, big_cdf = cdf(m, x, big_num)
|
|
70
|
-
mu = np.empty(num)
|
|
71
|
-
cdf_values = np.empty(num)
|
|
72
|
-
|
|
73
|
-
mu[0] = -1 # cos[180 degrees] is -1
|
|
74
|
-
cdf_values[0] = 0 # initial cdf is zero
|
|
155
|
+
mu: unevenly spaced cosines of the scattering angle, from -1 to 1
|
|
156
|
+
cdf: evenly spaced cumulative probabilities, from 0 to 1
|
|
75
157
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
delta_mu = big_mu[big_k] - big_mu[big_k - 1]
|
|
86
|
-
|
|
87
|
-
mu[k] = big_mu[big_k] - delta / delta_cdf * delta_mu # interpolate
|
|
88
|
-
cdf_values[k] = target
|
|
89
|
-
|
|
90
|
-
# print(' mu[',k,']=% .5f'%mu[k],' cdf[',k,']=% .5f'%cdf[k],
|
|
91
|
-
# 'cdf=',big_cdf[big_k], fraction)
|
|
92
|
-
|
|
93
|
-
mu[num - 1] = 1 # cos[0 degrees] is 1
|
|
94
|
-
cdf_values[num - 1] = 1 # last cdf is one
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: if num is less than two or the phase function is not finite
|
|
160
|
+
"""
|
|
161
|
+
if num < 2:
|
|
162
|
+
raise ValueError("num must be at least 2")
|
|
163
|
+
big_num = max(2000, int(5 * x**2)) # resolve the forward peak, see cdf
|
|
164
|
+
big_mu, big_cdf = _trapezoid_cdf(m, x, big_num, stacklevel=3)
|
|
165
|
+
cdf_values = np.arange(num) / (num - 1)
|
|
166
|
+
mu = np.interp(cdf_values, big_cdf, big_mu)
|
|
95
167
|
|
|
96
168
|
return [mu, cdf_values]
|
|
97
169
|
|
|
98
170
|
|
|
99
|
-
def generate_mie_costheta(mu_cdf):
|
|
171
|
+
def generate_mie_costheta(mu_cdf, rng=None):
|
|
100
172
|
"""
|
|
101
173
|
Generate a new scattering angle using a cdf.
|
|
102
174
|
|
|
@@ -107,18 +179,37 @@ def generate_mie_costheta(mu_cdf):
|
|
|
107
179
|
|
|
108
180
|
Args:
|
|
109
181
|
mu_cdf: a cumulative distribution function
|
|
182
|
+
rng: optional source of randomness, anything with a ``random()`` method
|
|
183
|
+
such as ``numpy.random.default_rng()``. Omitting it uses the process
|
|
184
|
+
wide ``numpy.random``, whose stream a caller cannot control without
|
|
185
|
+
reseeding the whole program, so pass a generator when reproducibility
|
|
186
|
+
matters.
|
|
110
187
|
|
|
111
188
|
Returns:
|
|
112
189
|
The cosine of the scattering angle
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
Draw a reproducible angle without disturbing the global stream:
|
|
193
|
+
|
|
194
|
+
>>> import numpy as np
|
|
195
|
+
>>> import miepython.monte_carlo as mc
|
|
196
|
+
>>> mu, cdf = mc.mu_with_uniform_cdf(1.33, 1.7, 50)
|
|
197
|
+
>>> rng = np.random.default_rng(42)
|
|
198
|
+
>>> first = mc.generate_mie_costheta(mu, rng=rng)
|
|
199
|
+
>>> bool(-1.0 <= first <= 1.0)
|
|
200
|
+
True
|
|
201
|
+
>>> bool(first == mc.generate_mie_costheta(mu, rng=np.random.default_rng(42)))
|
|
202
|
+
True
|
|
113
203
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
204
|
+
if rng is None:
|
|
205
|
+
rng = np.random
|
|
206
|
+
|
|
207
|
+
# rng.random() draws from [0, 1), so int(r * num) never reaches num and
|
|
208
|
+
# index + 1 stays inside the table. No clamp is needed.
|
|
116
209
|
num = len(mu_cdf) - 1
|
|
117
|
-
index = int(
|
|
118
|
-
if index >= num:
|
|
119
|
-
index = num - 1
|
|
210
|
+
index = int(rng.random() * num)
|
|
120
211
|
|
|
121
212
|
x = mu_cdf[index]
|
|
122
|
-
x += (mu_cdf[index + 1] - mu_cdf[index]) *
|
|
213
|
+
x += (mu_cdf[index + 1] - mu_cdf[index]) * rng.random()
|
|
123
214
|
|
|
124
215
|
return x
|