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/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 best method.
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
- n = m.real
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 array sizes will be n_pole+1. This is useful when
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
- nstop = int(x + 4.05 * x**0.33333 + 2.0) + 1
238
+ n_terms = int(x + 4.05 * x**0.33333 + 2.0)
174
239
  else:
175
- nstop = n_pole + 1
240
+ n_terms = n_pole
176
241
 
177
- a = np.zeros(nstop, dtype=np.complex128)
178
- b = np.zeros(nstop, dtype=np.complex128)
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
- psi_nm1 = np.sin(x) # nm1 = n-1 = 0
184
- psi_n = psi_nm1 * inv_x - np.cos(x)
185
- xi_nm1 = np.complex128(psi_nm1 + 1j * np.cos(x))
186
- xi_n = np.complex128(psi_n + 1j * (np.cos(x) * inv_x + np.sin(x)))
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, nstop + 1)
264
+ D = _D_calc_py(m, x, n_terms + 2)
190
265
 
191
- for n in range(1, nstop):
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 * psi_n - psi_nm1) / (temp * xi_n - xi_nm1)
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 * psi_n - psi_nm1) / (temp * xi_n - xi_nm1)
197
- two_np1_over_x = (2 * n + 1) * inv_x
198
- psi = two_np1_over_x * psi_n - psi_nm1
199
- xi = two_np1_over_x * xi_n - xi_nm1
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, nstop):
279
+ for n in range(1, n_terms + 1):
207
280
  n_over_x = n * inv_x
208
- a[n - 1] = (n_over_x * psi_n - psi_nm1) / (n_over_x * xi_n - xi_nm1)
209
- b[n - 1] = psi_n / xi_n
210
- xi = (2 * n + 1) * inv_x * xi_n - xi_nm1
211
- xi_nm1 = xi_n
212
- xi_n = xi
213
- psi_nm1 = psi_n
214
- psi_n = xi_n.real
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
- nstop = int(x + 4.05 * x**0.33333 + 2.0) + 1
311
+ n_terms = int(x + 4.05 * x**0.33333 + 2.0)
242
312
  else:
243
- nstop = n_pole + 1
313
+ n_terms = n_pole
244
314
 
245
- c = np.zeros(nstop, dtype=np.complex128)
246
- d = np.zeros(nstop, dtype=np.complex128)
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
- if m.real > 0.0 and not np.isinf(m.real) or not np.isinf(m.imag):
253
- psi_nm1 = np.sin(x) # nm1 = n-1 = 0
254
- psi_n = psi_nm1 * inv_x - np.cos(x)
255
-
256
- inv_mx = 1.0 / mx
257
- psi_nm1_mx = np.sin(mx) # nm1 = n-1 = 0
258
- psi_n_mx = psi_nm1_mx * inv_mx - np.cos(mx)
259
-
260
- xi_nm1 = np.complex128(psi_nm1 + 1j * np.cos(x))
261
- xi_n = np.complex128(psi_n + 1j * (np.cos(x) * inv_x + np.sin(x)))
262
-
263
- Dmx = _D_calc_py(np.complex128(m), x, nstop + 1)
264
- Dx = _D_calc_py(np.complex128(1), x, nstop + 1)
265
-
266
- for n in range(1, nstop + 1):
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 = (psi_n / psi_n_mx) * ((Dx[n - 1] + n_over_x) * xi_n - xi_nm1)
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
- two_np1 = 2 * n + 1
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[n_pole] * (pi[n_pole] * a[n_pole] + tau[n_pole] * b[n_pole])
363
- S2[k] = scale[n_pole] * (tau[n_pole] * a[n_pole] + pi[n_pole] * b[n_pole])
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: Electric (True) or Magnetic Field otherwise
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
- _ = e_field # currently unused in scalar aggregate efficiencies
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
- qback = np.abs((-1) ** n_pole * cn * (a[-1] - b[-1])) ** 2 / x2
512
- qext = 2.0 * cn * (a[-1].real + b[-1].real) / x2
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 * (np.abs(a[-1]) ** 2 + np.abs(b[-1]) ** 2) / x2
516
- g = None
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
- Monte Carlo and Mie scattering.
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
- Create a CDF for unpolarized scattering uniformly spaced in cos(theta).
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
- The CDF covers scattered (exit) angles ranging from 180 to 0 degrees.
20
- (The cosines are uniformly distributed over -1 to 1.) Because the angles
21
- are uniformly distributed in cos(theta), the scattering function is not
22
- sampled uniformly and therefore huge array sizes are needed to adequately
23
- sample highly anisotropic phase functions.
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
- Since this is a cumulative distribution function, the maximum value
26
- should be 1.
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: the complex index of refraction of the sphere
30
- x: the size parameter of the sphere
31
- num: length of desired CDF array
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: array of cosines of angles
35
- cdf: array of cumulative distribution function values
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") / num
39
- cdf_values = np.cumsum(intensity_per_mu)
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
- Create a CDF for unpolarized scattering for uniform CDF.
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
- This is a brute force implementation that solves the problem by
53
- calculating the CDF at many points and then scanning to find the
54
- specific angles that correspond to uniform interval of the CDF.
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
- Since this is a cumulative distribution function, the maximum value
57
- should be 1.
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: the complex index of refraction of the sphere
61
- x: the size parameter of the sphere
62
- num: length of desired CDF array
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: array of cosines of angles (irregularly spaced)
66
- cdf: array of cumulative distribution function values
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
- big_k = 0 # index into big_cdf
77
- for k in range(1, num - 1):
78
-
79
- target = k / (num - 1)
80
- while big_cdf[big_k] < target:
81
- big_k += 1
82
-
83
- delta = big_cdf[big_k] - target
84
- delta_cdf = big_cdf[big_k] - big_cdf[big_k - 1]
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
- # the following should be equivalent to these four lines
115
- # index = np.random.randint(0, high=len(mu_cdf))
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(np.random.random() * num)
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]) * np.random.random()
213
+ x += (mu_cdf[index + 1] - mu_cdf[index]) * rng.random()
123
214
 
124
215
  return x