miepython 3.3.0__tar.gz → 3.3.1__tar.gz

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.
Files changed (51) hide show
  1. {miepython-3.3.0 → miepython-3.3.1}/CHANGELOG.rst +78 -0
  2. {miepython-3.3.0 → miepython-3.3.1}/CITATION.cff +3 -3
  3. {miepython-3.3.0 → miepython-3.3.1}/PKG-INFO +5 -6
  4. {miepython-3.3.0 → miepython-3.3.1}/README.rst +2 -2
  5. {miepython-3.3.0 → miepython-3.3.1}/miepython/__init__.py +1 -1
  6. {miepython-3.3.0 → miepython-3.3.1}/miepython/core.py +67 -0
  7. {miepython-3.3.0 → miepython-3.3.1}/miepython/field.py +2 -1
  8. miepython-3.3.1/miepython/monte_carlo.py +215 -0
  9. miepython-3.3.1/miepython/rayleigh.py +430 -0
  10. {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/PKG-INFO +5 -6
  11. {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/SOURCES.txt +1 -0
  12. {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/requires.txt +0 -1
  13. {miepython-3.3.0 → miepython-3.3.1}/pyproject.toml +6 -5
  14. miepython-3.3.1/tests/test_file_encodings.py +123 -0
  15. {miepython-3.3.0 → miepython-3.3.1}/tests/test_makefile.py +1 -1
  16. {miepython-3.3.0 → miepython-3.3.1}/tests/test_mie.py +63 -0
  17. {miepython-3.3.0 → miepython-3.3.1}/tests/test_monte_carlo.py +76 -21
  18. {miepython-3.3.0 → miepython-3.3.1}/tests/test_rayleigh.py +161 -0
  19. {miepython-3.3.0 → miepython-3.3.1}/tests/test_scattnlay_reference.py +1 -1
  20. miepython-3.3.0/miepython/monte_carlo.py +0 -173
  21. miepython-3.3.0/miepython/rayleigh.py +0 -266
  22. {miepython-3.3.0 → miepython-3.3.1}/LICENSE.txt +0 -0
  23. {miepython-3.3.0 → miepython-3.3.1}/MANIFEST.in +0 -0
  24. {miepython-3.3.0 → miepython-3.3.1}/miepython/_backend.py +0 -0
  25. {miepython-3.3.0 → miepython-3.3.1}/miepython/bessel.py +0 -0
  26. {miepython-3.3.0 → miepython-3.3.1}/miepython/data/Johnson.txt +0 -0
  27. {miepython-3.3.0 → miepython-3.3.1}/miepython/data/segelstein81_index.txt +0 -0
  28. {miepython-3.3.0 → miepython-3.3.1}/miepython/mie_jit.py +0 -0
  29. {miepython-3.3.0 → miepython-3.3.1}/miepython/mie_nojit.py +0 -0
  30. {miepython-3.3.0 → miepython-3.3.1}/miepython/util.py +0 -0
  31. {miepython-3.3.0 → miepython-3.3.1}/miepython/vsh.py +0 -0
  32. {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/dependency_links.txt +0 -0
  33. {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/top_level.txt +0 -0
  34. {miepython-3.3.0 → miepython-3.3.1}/setup.cfg +0 -0
  35. {miepython-3.3.0 → miepython-3.3.1}/tests/benchmark_efficiencies.py +0 -0
  36. {miepython-3.3.0 → miepython-3.3.1}/tests/conftest.py +0 -0
  37. {miepython-3.3.0 → miepython-3.3.1}/tests/reference.py +0 -0
  38. {miepython-3.3.0 → miepython-3.3.1}/tests/test_D.py +0 -0
  39. {miepython-3.3.0 → miepython-3.3.1}/tests/test_abcd.py +0 -0
  40. {miepython-3.3.0 → miepython-3.3.1}/tests/test_all_examples.py +0 -0
  41. {miepython-3.3.0 → miepython-3.3.1}/tests/test_all_notebooks.py +0 -0
  42. {miepython-3.3.0 → miepython-3.3.1}/tests/test_backend_parity.py +0 -0
  43. {miepython-3.3.0 → miepython-3.3.1}/tests/test_bessel.py +0 -0
  44. {miepython-3.3.0 → miepython-3.3.1}/tests/test_docstrings.py +0 -0
  45. {miepython-3.3.0 → miepython-3.3.1}/tests/test_field.py +0 -0
  46. {miepython-3.3.0 → miepython-3.3.1}/tests/test_kernels.py +0 -0
  47. {miepython-3.3.0 → miepython-3.3.1}/tests/test_optional_scipy.py +0 -0
  48. {miepython-3.3.0 → miepython-3.3.1}/tests/test_package_surface.py +0 -0
  49. {miepython-3.3.0 → miepython-3.3.1}/tests/test_unpolarized_intensity.py +0 -0
  50. {miepython-3.3.0 → miepython-3.3.1}/tests/test_util.py +0 -0
  51. {miepython-3.3.0 → miepython-3.3.1}/tests/test_vsh.py +0 -0
@@ -1,6 +1,84 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ 3.3.1 (10/28/2026)
5
+ -------------------
6
+ * fix ``rayleigh.efficiencies_mx`` subtracting scattering from extinction. With
7
+ the m = n - ik convention the absorption term is negative, so
8
+ ``abs(absorption + qsca)`` gave absorption minus scattering, and qext for
9
+ m = 1.5 - 1e-6j at x = 0.01 came out 21% low (thanks @antonsoo). The x**4 term
10
+ now follows Bohren & Huffman's ``Re(ratio**2)`` rather than ``|ratio|**2``,
11
+ which agrees with Mie to 2e-5 instead of 6e-4 at m = 1.5 - 1j, x = 0.1. ``m``
12
+ is conjugated first, as ``core.py`` does. The Rayleigh ``qext`` also sets the
13
+ ``albedo`` and ``qext`` normalizations, so ``rayleigh.S1_S2`` and the
14
+ intensities change too
15
+ * handle perfectly conducting spheres in ``rayleigh``. As elsewhere in the
16
+ package, m.real == 0 now means a perfect conductor, which radiates a magnetic
17
+ dipole b1 = -a1/2 alongside its electric one: qext = qsca = 10/3 x**4,
18
+ qback = 9 x**4 and g = -2/5. The dielectric formulas were used before, so
19
+ m = -1j gave a qext about three times the Mie value; it now agrees to 2e-5 at
20
+ x = 0.01
21
+ * validate input to the ``rayleigh`` functions as ``core.py`` now does. A NaN or
22
+ infinite m or x, a negative x, or a negative real part of m raises
23
+ ``ValueError``; before, ``rayleigh.efficiencies_mx(nan, 0.05)`` returned NaN and
24
+ ``m = -1.5 - 0.1j`` or ``x = -0.05`` returned normal-looking efficiencies
25
+ * say why ``S1_S2`` and the functions built on it reject an array of spheres, in
26
+ both ``core`` and ``rayleigh``. They vectorize over mu and describe one sphere,
27
+ but an array m or x failed with numpy's "truth value of an array is ambiguous"
28
+ * return ``g`` from ``rayleigh.efficiencies_mx`` with the same shape and type as
29
+ the other efficiencies. It was the int 0 even for an array of spheres
30
+ * document the ``rayleigh`` module: what the approximation is, which expressions
31
+ go beyond the leading Rayleigh term, its accuracy against Mie, and tested
32
+ examples
33
+ * support Python 3.11 through 3.15. ``requires-python`` is now ``>=3.11``, 3.10 is
34
+ dropped from the classifiers, the black target versions and the CI matrix, and
35
+ 3.15 is added to all three
36
+ * raise ``ValueError`` for a refractive index or size parameter the kernels would
37
+ misread. The kernels treat any index that fails ``m.real > 0`` as a perfect
38
+ conductor, so ``m = nan`` or ``m = -1.5`` silently returned the answer for a metal
39
+ sphere, and an infinite m returned NaN. A NaN x raised "cannot convert float
40
+ NaN to integer" on the pure-Python backend while numba returned NaN, an infinite
41
+ x raised a different unhelpful error on each backend, and a negative x returned
42
+ zeros. ``coefficients``, ``efficiencies_mx``, ``S1_S2`` and the near fields, and
43
+ everything built on them, now reject a NaN or infinite m or x, a negative x, and
44
+ a negative real part of m, checking every entry of an array. ``m.real == 0``
45
+ still means a perfect conductor and x = 0 is still allowed
46
+ * fix ``monte_carlo.mu_with_uniform_cdf`` returning wrong angles for fine tables.
47
+ ``cdf`` began at I(-1)/num rather than zero, so a target below that value left
48
+ the inversion loop at index zero and interpolated against the last entry.
49
+ ``cdf`` now integrates the phase function with the trapezoidal rule, starting at
50
+ exactly 0 and ending at exactly 1 (it used to end slightly above 1), so tables
51
+ differ slightly from 3.3.0. ``cdf`` and ``mu_with_uniform_cdf`` also raise
52
+ ``ValueError`` when num is less than 2 (thanks @antonsoo)
53
+ * replace the inversion loop in ``monte_carlo.mu_with_uniform_cdf`` with
54
+ ``np.interp``
55
+ * warn when ``monte_carlo.cdf`` uses too few angles to resolve the phase function.
56
+ The raw integral should be one, so a ``RuntimeWarning`` is issued when it is off
57
+ by more than 1%; normalizing used to hide the error silently. A large sphere
58
+ needs roughly x**2 angles, because its forward peak is only about 8/x**2 wide in
59
+ cos(theta)
60
+ * size the table inside ``monte_carlo.mu_with_uniform_cdf`` from x, as
61
+ max(2000, 5*x**2) angles instead of a fixed 2000. At x = 100 the sampled mean
62
+ cosine was 0.930 against g = 0.868; it is now 0.869. Large spheres are slower
63
+ to tabulate: seconds at x = 100, about a minute at x = 300
64
+ * raise ``ValueError`` from ``monte_carlo.cdf`` and ``mu_with_uniform_cdf`` when the
65
+ phase function is not finite
66
+ * rewrite the ``monte_carlo`` docstrings, with tested examples of drawing random
67
+ scattering angles
68
+ * name the encoding on every text read and write. ``open(path)`` and
69
+ ``Path.read_text()`` decode with the locale's preferred encoding, which is UTF-8
70
+ on Linux and macOS but cp1252 on Windows, so ``tests/test_makefile.py`` passed
71
+ everywhere except the four Windows jobs, where it died on the emoji in the
72
+ Makefile's echo lines. Two more latent cases are fixed alongside it: the
73
+ scattnlay metadata read in ``tests/test_scattnlay_reference.py``, which survived
74
+ only because that JSON happens to be pure ASCII, and the ``write_text`` in
75
+ ``docs/data/generate_scattnlay_reference_fields.py``
76
+ * check for locale-dependent text I/O in the test suite, by walking the syntax trees
77
+ of every Python file in the repository and requiring an explicit ``encoding=``.
78
+ The failure is invisible on the platforms this package is developed on, so a
79
+ Windows-only break was waiting for whoever next added a non-ASCII character to a
80
+ file something reads
81
+
4
82
  3.3.0 (07/28/2026)
5
83
  -------------------
6
84
  * fix the docstring examples in ``core.py``, which had rotted unnoticed. The
@@ -2,9 +2,9 @@ cff-version: 1.2.0
2
2
  type: software
3
3
  message: If you use this software, please cite it as below.
4
4
  title: 'miepython: Pure python calculation of Mie scattering'
5
- version: 3.2.0
5
+ version: 3.3.0
6
6
  doi: 10.5281/zenodo.7949264
7
- date-released: '2026-03-06'
7
+ date-released: '2026-07-28'
8
8
  url: https://doi.org/10.5281/zenodo.7949264
9
9
  repository-code: https://github.com/scottprahl/miepython
10
10
  license: MIT
@@ -33,7 +33,7 @@ preferred-citation:
33
33
  type: software
34
34
  title: 'miepython: Pure python calculation of Mie scattering'
35
35
  year: 2026
36
- version: 3.2.0
36
+ version: 3.3.0
37
37
  doi: 10.5281/zenodo.7949264
38
38
  url: https://doi.org/10.5281/zenodo.7949264
39
39
  authors:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: miepython
3
- Version: 3.3.0
3
+ Version: 3.3.1
4
4
  Summary: Mie scattering of a plane wave by a sphere
5
5
  Author-email: Scott Prahl <scott.prahl@oit.edu>
6
6
  License-Expression: MIT
@@ -14,12 +14,12 @@ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Science/Research
15
15
  Classifier: Programming Language :: Python
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
20
19
  Classifier: Programming Language :: Python :: 3.13
21
20
  Classifier: Programming Language :: Python :: 3.14
22
- Requires-Python: >=3.10
21
+ Classifier: Programming Language :: Python :: 3.15
22
+ Requires-Python: >=3.11
23
23
  Description-Content-Type: text/x-rst
24
24
  License-File: LICENSE.txt
25
25
  Requires-Dist: numpy
@@ -47,7 +47,6 @@ Requires-Dist: build; extra == "dev"
47
47
  Requires-Dist: check-manifest; extra == "dev"
48
48
  Requires-Dist: libarchive-c; extra == "dev"
49
49
  Requires-Dist: pyyaml; extra == "dev"
50
- Requires-Dist: setuptools; extra == "dev"
51
50
  Requires-Dist: jupyterlab; extra == "dev"
52
51
  Requires-Dist: nbconvert; extra == "dev"
53
52
  Requires-Dist: nbformat; extra == "dev"
@@ -211,7 +210,7 @@ Citation
211
210
 
212
211
  If you use ``miepython`` in academic, instructional, or applied technical work, please cite:
213
212
 
214
- Prahl, S. (2026). *miepython: Pure python calculation of Mie scattering* (Version 3.2.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7949263
213
+ Prahl, S. (2026). *miepython: Pure python calculation of Mie scattering* (Version 3.3.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7949263
215
214
 
216
215
  BibTeX:
217
216
 
@@ -223,7 +222,7 @@ BibTeX:
223
222
  url = {https://github.com/scottprahl/miepython},
224
223
  doi = {10.5281/zenodo.7949263},
225
224
  year = {2026},
226
- version = {3.2.0},
225
+ version = {3.3.0},
227
226
  publisher = {Zenodo}
228
227
  }
229
228
 
@@ -153,7 +153,7 @@ Citation
153
153
 
154
154
  If you use ``miepython`` in academic, instructional, or applied technical work, please cite:
155
155
 
156
- Prahl, S. (2026). *miepython: Pure python calculation of Mie scattering* (Version 3.2.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7949263
156
+ Prahl, S. (2026). *miepython: Pure python calculation of Mie scattering* (Version 3.3.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7949263
157
157
 
158
158
  BibTeX:
159
159
 
@@ -165,7 +165,7 @@ BibTeX:
165
165
  url = {https://github.com/scottprahl/miepython},
166
166
  doi = {10.5281/zenodo.7949263},
167
167
  year = {2026},
168
- version = {3.2.0},
168
+ version = {3.3.0},
169
169
  publisher = {Zenodo}
170
170
  }
171
171
 
@@ -178,7 +178,7 @@ __all__ = (
178
178
  "_D_downwards",
179
179
  )
180
180
 
181
- __version__ = "3.3.0"
181
+ __version__ = "3.3.1"
182
182
  __author__ = "Scott Prahl"
183
183
  __email__ = "scott.prahl@oit.edu"
184
184
  __copyright__ = "2017-2026, Scott Prahl"
@@ -69,6 +69,55 @@ def wiscombe_terms(x):
69
69
  return int(x + 4.05 * x**0.33333 + 2.0)
70
70
 
71
71
 
72
+ def _check_m_x(m, x):
73
+ """
74
+ Reject an index or size parameter that the kernels would silently misread.
75
+
76
+ The kernels treat any index whose real part fails ``m.real > 0`` as a perfect
77
+ conductor, so a NaN or negative real part gave the answer for a metal sphere
78
+ instead of an error. A NaN or infinite x failed differently on each backend.
79
+
80
+ Args:
81
+ m: refractive index of the sphere, scalar or array
82
+ x: size parameter of the sphere, scalar or array
83
+
84
+ Raises:
85
+ ValueError: if m or x is not finite, x is negative, or m.real is negative
86
+ """
87
+ m = np.asarray(m)
88
+ x = np.asarray(x)
89
+ if not np.all(np.isfinite(m)):
90
+ raise ValueError(f"refractive index must be finite, got m={m}")
91
+ if np.any(m.real < 0):
92
+ raise ValueError(
93
+ f"real part of the refractive index must not be negative, got m={m}; "
94
+ "use m.real == 0 for a perfect conductor"
95
+ )
96
+ if not np.all(np.isfinite(x)):
97
+ raise ValueError(f"size parameter must be finite, got x={x}")
98
+ if np.any(x < 0):
99
+ raise ValueError(f"size parameter must not be negative, got x={x}")
100
+
101
+
102
+ def _check_one_sphere(m, x):
103
+ """
104
+ Reject arrays of m or x in functions that describe a single sphere.
105
+
106
+ The angular functions vectorize over mu, so an array of spheres has no
107
+ unambiguous shape to return. Without this check numpy raised "truth value of
108
+ an array is ambiguous" from deep inside.
109
+
110
+ Args:
111
+ m: refractive index of the sphere
112
+ x: size parameter of the sphere
113
+
114
+ Raises:
115
+ ValueError: if m or x is not a scalar
116
+ """
117
+ if np.ndim(m) > 0 or np.ndim(x) > 0:
118
+ raise ValueError("this function takes one sphere, so m and x must be scalars; loop over spheres for more")
119
+
120
+
72
121
  def coefficients(m, x, n_pole=0, internal=False):
73
122
  """
74
123
  Computes the Mie coefficients for a sphere.
@@ -103,6 +152,10 @@ def coefficients(m, x, n_pole=0, internal=False):
103
152
  automatically corrected to its conjugate value to ensure a valid input.
104
153
  - Every returned coefficient is a real term; the arrays carry no padding.
105
154
 
155
+ Raises:
156
+ ValueError: if m or x is not finite, x is negative, or the real part of
157
+ m is negative. A perfect conductor is m.real == 0, not m.real < 0.
158
+
106
159
  Examples:
107
160
  Compute coefficients for a single sphere:
108
161
 
@@ -123,6 +176,7 @@ def coefficients(m, x, n_pole=0, internal=False):
123
176
  >>> print(f"{a[0, 0]:.6f}")
124
177
  0.447946-0.386652j
125
178
  """
179
+ _check_m_x(m, x)
126
180
  mlen = np.size(m) if np.ndim(m) > 0 else 0
127
181
  xlen = np.size(x) if np.ndim(x) > 0 else 0
128
182
 
@@ -208,6 +262,10 @@ def efficiencies_mx(m, x, n_pole=0, e_field=True):
208
262
  - This implementation assumes spherical, homogeneous particles in a
209
263
  non-absorbing medium.
210
264
 
265
+ Raises:
266
+ ValueError: if m or x is not finite, x is negative, or the real part of
267
+ m is negative. A perfect conductor is m.real == 0, not m.real < 0.
268
+
211
269
  Examples:
212
270
  Compute efficiencies for one sphere:
213
271
 
@@ -228,6 +286,8 @@ def efficiencies_mx(m, x, n_pole=0, e_field=True):
228
286
  Use :func:`efficiencies` instead when the diameter and wavelength are
229
287
  known rather than the size parameter.
230
288
  """
289
+ _check_m_x(m, x)
290
+
231
291
  # ensure imaginary part of refractive index is negative
232
292
  if np.isscalar(m):
233
293
  m = np.conj(m) if np.imag(m) > 0 else m
@@ -361,7 +421,14 @@ def S1_S2(m, x, mu, norm="albedo", n_pole=0):
361
421
 
362
422
  Returns:
363
423
  S1, S2: the scattering amplitudes at each angle mu [sr**(-0.5)]
424
+
425
+ Raises:
426
+ ValueError: if m or x is not finite, x is negative, or the real part of
427
+ m is negative. A perfect conductor is m.real == 0, not m.real < 0.
428
+ Also if m or x is an array, since this describes one sphere.
364
429
  """
430
+ _check_m_x(m, x)
431
+ _check_one_sphere(m, x)
365
432
  if np.imag(m) > 0: # ensure imaginary part of refractive index is negative
366
433
  m = np.conj(m)
367
434
 
@@ -82,7 +82,7 @@ import numpy as np
82
82
  from scipy.special import factorial2, spherical_jn
83
83
  from ._backend import D_calc, pi_tau
84
84
  from .bessel import spherical_h1
85
- from .core import S1_S2, coefficients, wiscombe_terms
85
+ from .core import S1_S2, _check_m_x, coefficients, wiscombe_terms
86
86
  from .util import cartesian_to_spherical, spherical_vector_to_cartesian
87
87
 
88
88
  __all__ = (
@@ -287,6 +287,7 @@ def _coefficients_abcd(lambda0, d_sphere, m_sphere, n_env, n_pole):
287
287
  """
288
288
  x = np.pi * d_sphere * n_env / lambda0
289
289
  m_rel = m_sphere / n_env
290
+ _check_m_x(m_rel, x) # before wiscombe_terms, which cannot take a NaN x
290
291
  if n_pole == 0:
291
292
  # Wiscombe's criterion truncates the scattered series, which converges
292
293
  # faster than the field evaluated right at the surface. Now that an_bn
@@ -0,0 +1,215 @@
1
+ """
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.
58
+ """
59
+
60
+ import warnings
61
+
62
+ import numpy as np
63
+ import miepython as mie
64
+
65
+ __all__ = (
66
+ "cdf",
67
+ "mu_with_uniform_cdf",
68
+ "generate_mie_costheta",
69
+ )
70
+
71
+ # Largest acceptable relative error in the integral of the phase function
72
+ CDF_TOLERANCE = 0.01
73
+
74
+
75
+ def cdf(m, x, num):
76
+ """
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.
84
+
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.
90
+
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.
95
+
96
+ Args:
97
+ m: complex index of refraction of the sphere
98
+ x: size parameter of the sphere
99
+ num: number of cosines, at least two
100
+
101
+ Returns:
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
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."""
115
+ mu = np.linspace(-1, 1, num)
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
130
+ return mu, cdf_values
131
+
132
+
133
+ def mu_with_uniform_cdf(m, x, num):
134
+ """
135
+ Inverse-transform table for sampling the unpolarized phase function.
136
+
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.
142
+
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.
148
+
149
+ Args:
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
153
+
154
+ Returns:
155
+ mu: unevenly spaced cosines of the scattering angle, from -1 to 1
156
+ cdf: evenly spaced cumulative probabilities, from 0 to 1
157
+
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)
167
+
168
+ return [mu, cdf_values]
169
+
170
+
171
+ def generate_mie_costheta(mu_cdf, rng=None):
172
+ """
173
+ Generate a new scattering angle using a cdf.
174
+
175
+ A uniformly spaced cumulative distribution function (CDF) is needed.
176
+ New random angles are generated by selecting a random interval
177
+ mu[i] to mu[i+1] and choosing an angle uniformly distributed over
178
+ the interval.
179
+
180
+ Args:
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.
187
+
188
+ Returns:
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
203
+ """
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.
209
+ num = len(mu_cdf) - 1
210
+ index = int(rng.random() * num)
211
+
212
+ x = mu_cdf[index]
213
+ x += (mu_cdf[index + 1] - mu_cdf[index]) * rng.random()
214
+
215
+ return x