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.
- {miepython-3.3.0 → miepython-3.3.1}/CHANGELOG.rst +78 -0
- {miepython-3.3.0 → miepython-3.3.1}/CITATION.cff +3 -3
- {miepython-3.3.0 → miepython-3.3.1}/PKG-INFO +5 -6
- {miepython-3.3.0 → miepython-3.3.1}/README.rst +2 -2
- {miepython-3.3.0 → miepython-3.3.1}/miepython/__init__.py +1 -1
- {miepython-3.3.0 → miepython-3.3.1}/miepython/core.py +67 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/field.py +2 -1
- miepython-3.3.1/miepython/monte_carlo.py +215 -0
- miepython-3.3.1/miepython/rayleigh.py +430 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/PKG-INFO +5 -6
- {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/SOURCES.txt +1 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/requires.txt +0 -1
- {miepython-3.3.0 → miepython-3.3.1}/pyproject.toml +6 -5
- miepython-3.3.1/tests/test_file_encodings.py +123 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_makefile.py +1 -1
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_mie.py +63 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_monte_carlo.py +76 -21
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_rayleigh.py +161 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_scattnlay_reference.py +1 -1
- miepython-3.3.0/miepython/monte_carlo.py +0 -173
- miepython-3.3.0/miepython/rayleigh.py +0 -266
- {miepython-3.3.0 → miepython-3.3.1}/LICENSE.txt +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/MANIFEST.in +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/_backend.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/bessel.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/data/Johnson.txt +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/data/segelstein81_index.txt +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/mie_jit.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/mie_nojit.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/util.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython/vsh.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/dependency_links.txt +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/miepython.egg-info/top_level.txt +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/setup.cfg +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/benchmark_efficiencies.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/conftest.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/reference.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_D.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_abcd.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_all_examples.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_all_notebooks.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_backend_parity.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_bessel.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_docstrings.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_field.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_kernels.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_optional_scipy.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_package_surface.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_unpolarized_intensity.py +0 -0
- {miepython-3.3.0 → miepython-3.3.1}/tests/test_util.py +0 -0
- {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.
|
|
5
|
+
version: 3.3.0
|
|
6
6
|
doi: 10.5281/zenodo.7949264
|
|
7
|
-
date-released: '2026-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
168
|
+
version = {3.3.0},
|
|
169
169
|
publisher = {Zenodo}
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -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
|