miepython 3.3.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 CHANGED
@@ -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"
miepython/core.py CHANGED
@@ -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
 
miepython/field.py CHANGED
@@ -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
miepython/monte_carlo.py CHANGED
@@ -1,37 +1,64 @@
1
1
  """
2
- Sample scattering angles from the Mie phase function.
3
-
4
- Monte Carlo photon transport needs a random exit direction distributed according to
5
- the phase function. This module builds the inverse-transform table that provides
6
- one: ``mu_with_uniform_cdf`` returns angles spaced so that equal steps along the
7
- cumulative distribution are equally likely, and ``generate_mie_costheta`` draws from
8
- that table. ``docs/06_random_deviates.ipynb`` works through the whole procedure.
9
-
10
- Importing
11
- ---------
12
-
13
- This module is *not* imported with the package, because it imports ``miepython``
14
- itself and doing so from ``miepython/__init__.py`` would be circular. Ask for it by
15
- name::
16
-
17
- import numpy as np
18
- import miepython.monte_carlo as mc
19
-
20
- m, x = 1.33, 2.0 # a water droplet
21
- mu, cdf = mc.mu_with_uniform_cdf(m, x, 500) # 500-entry lookup table
22
-
23
- rng = np.random.default_rng(12345)
24
- cosines = [mc.generate_mie_costheta(mu, rng=rng) for _ in range(10000)]
25
-
26
- Build the table once and reuse it: it costs a full Mie calculation, while each draw
27
- off it costs one random number. The number of table entries sets how finely the
28
- phase function is resolved, so strongly forward-scattering spheres need more of
29
- them.
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.
30
55
 
31
56
  Passing ``rng`` keeps the draws reproducible without disturbing the process-wide
32
57
  ``numpy.random``; omitting it falls back to that global generator.
33
58
  """
34
59
 
60
+ import warnings
61
+
35
62
  import numpy as np
36
63
  import miepython as mie
37
64
 
@@ -41,87 +68,102 @@ __all__ = (
41
68
  "generate_mie_costheta",
42
69
  )
43
70
 
71
+ # Largest acceptable relative error in the integral of the phase function
72
+ CDF_TOLERANCE = 0.01
73
+
44
74
 
45
75
  def cdf(m, x, num):
46
76
  """
47
- Create a CDF for unpolarized scattering uniformly spaced in cos(theta).
77
+ Cumulative distribution of the unpolarized phase function in cos(theta).
48
78
 
49
- The CDF covers scattered (exit) angles ranging from 180 to 0 degrees.
50
- (The cosines are uniformly distributed over -1 to 1.) Because the angles
51
- are uniformly distributed in cos(theta), the scattering function is not
52
- sampled uniformly and therefore huge array sizes are needed to adequately
53
- sample highly anisotropic phase functions.
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.
54
84
 
55
- Since this is a cumulative distribution function, the maximum value
56
- should be 1.
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.
57
95
 
58
96
  Args:
59
- m: the complex index of refraction of the sphere
60
- x: the size parameter of the sphere
61
- 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
62
100
 
63
101
  Returns:
64
- mu: array of cosines of angles
65
- 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
66
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."""
67
115
  mu = np.linspace(-1, 1, num)
68
- intensity_per_mu = mie.i_unpolarized(m, x, mu, norm="4pi") / num
69
- 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
70
130
  return mu, cdf_values
71
131
 
72
132
 
73
133
  def mu_with_uniform_cdf(m, x, num):
74
134
  """
75
- Create a CDF for unpolarized scattering for uniform CDF.
76
-
77
- The CDF covers scattered (exit) angles ranging from 180 to 0 degrees.
78
- (The cosines are uniformly distributed over -1 to 1.) These angles mu
79
- correspond to uniform spacing of the cumulative distribution function
80
- for unpolarized Mie scattering where cdf[i] = i/(num-1).
135
+ Inverse-transform table for sampling the unpolarized phase function.
81
136
 
82
- This is a brute force implementation that solves the problem by
83
- calculating the CDF at many points and then scanning to find the
84
- 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.
85
142
 
86
- Since this is a cumulative distribution function, the maximum value
87
- 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.
88
148
 
89
149
  Args:
90
- m: the complex index of refraction of the sphere
91
- x: the size parameter of the sphere
92
- 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
93
153
 
94
154
  Returns:
95
- mu: array of cosines of angles (irregularly spaced)
96
- cdf: array of cumulative distribution function values
97
- """
98
- big_num = 2000 # large to work with x up to 10
99
- big_mu, big_cdf = cdf(m, x, big_num)
100
- mu = np.empty(num)
101
- cdf_values = np.empty(num)
102
-
103
- mu[0] = -1 # cos[180 degrees] is -1
104
- cdf_values[0] = 0 # initial cdf is zero
105
-
106
- big_k = 0 # index into big_cdf
107
- for k in range(1, num - 1):
155
+ mu: unevenly spaced cosines of the scattering angle, from -1 to 1
156
+ cdf: evenly spaced cumulative probabilities, from 0 to 1
108
157
 
109
- target = k / (num - 1)
110
- while big_cdf[big_k] < target:
111
- big_k += 1
112
-
113
- delta = big_cdf[big_k] - target
114
- delta_cdf = big_cdf[big_k] - big_cdf[big_k - 1]
115
- delta_mu = big_mu[big_k] - big_mu[big_k - 1]
116
-
117
- mu[k] = big_mu[big_k] - delta / delta_cdf * delta_mu # interpolate
118
- cdf_values[k] = target
119
-
120
- # print(' mu[',k,']=% .5f'%mu[k],' cdf[',k,']=% .5f'%cdf[k],
121
- # 'cdf=',big_cdf[big_k], fraction)
122
-
123
- mu[num - 1] = 1 # cos[0 degrees] is 1
124
- 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)
125
167
 
126
168
  return [mu, cdf_values]
127
169
 
miepython/rayleigh.py CHANGED
@@ -1,7 +1,125 @@
1
- """Rayleigh approximation for scattering by small particles."""
1
+ """
2
+ Rayleigh approximation for scattering by spheres much smaller than the wavelength.
3
+
4
+ When a sphere is small both outside (x << 1) and inside (abs(m) * x << 1), the
5
+ incident field is nearly uniform across it, and it radiates as a single
6
+ oscillating electric dipole. Every scattering quantity then follows in closed
7
+ form from the ratio (m**2 - 1) / (m**2 + 2), with no series of multipoles to
8
+ sum. This module evaluates those expressions, following Bohren and Huffman,
9
+ equations 5.4 and 5.7 to 5.9. A perfect conductor also radiates a magnetic
10
+ dipole, and has its own expressions, described below.
11
+
12
+ The functions mirror those of the same names in ``miepython``, with the same
13
+ arguments and the same ``norm`` choices, so one can stand in for the other:
14
+
15
+ ``efficiencies_mx(m, x)``, ``efficiencies(m, d, lambda0, n_env)``
16
+ qext, qsca, qback and g.
17
+ ``S1_S2(m, x, mu, norm)``
18
+ The scattering amplitudes at each mu = cos(theta).
19
+ ``i_par``, ``i_per``, ``i_unpolarized``, ``intensities``
20
+ Scattered intensities at each mu.
21
+ ``phase_matrix(m, x, mu, norm)``
22
+ The 4x4 Mueller matrix at each mu.
23
+
24
+ Nothing here takes ``n_pole``, since there is only the dipole.
25
+
26
+ Order of approximation:
27
+ Strictly, Rayleigh scattering is only the leading term in x: an electric
28
+ dipole whose strength is set by the static polarizability. Not every
29
+ expression here stops at that term, so this is better described as a
30
+ small-particle approximation.
31
+
32
+ - qsca and qback are the leading terms, 8/3 x**4 abs(ratio)**2 and
33
+ 4 x**4 abs(ratio)**2.
34
+ - qext carries Bohren and Huffman's next-order correction. Its absorption
35
+ term includes a relative x**2 correction, the order at which the
36
+ magnetic dipole and the electric quadrupole first contribute (the
37
+ 2 m**2 + 3 in its denominator is the quadrupole's), and scattering is
38
+ added as 8/3 x**4 Re(ratio**2).
39
+ - ``S1_S2`` gives the electric dipole its own relative x**2 correction, the
40
+ x**5 term, but leaves out the magnetic dipole and quadrupole. The angular
41
+ pattern is therefore exactly that of a dipole, and g is 0.
42
+ - A perfect conductor gets only the leading terms of its electric and
43
+ magnetic dipoles.
44
+
45
+ The corrections are not applied evenly, so a dielectric's qext is more
46
+ accurate than its qsca. ``miepython`` itself goes further for x < 0.1,
47
+ using Wiscombe's expansions, which keep the electric dipole, the magnetic
48
+ dipole and the electric quadrupole, each with its own corrections.
49
+
50
+ Accuracy:
51
+ Relative error in qext against the full Mie solution:
52
+
53
+ ================= ========= ======== ========
54
+ m x = 0.01 x = 0.1 x = 0.5
55
+ ================= ========= ======== ========
56
+ 1.5 -7e-6 -7e-4 -1e-2
57
+ 1.5 - 0.1j 8e-10 6e-6 4e-3
58
+ 0.05 - 1.43j 2e-6 2e-2 14
59
+ -1j (conductor) -2e-5 -2e-3 -4e-2
60
+ ================= ========= ======== ========
61
+
62
+ The last row is a silver-like sphere near its plasmon resonance, where
63
+ m**2 is close to -2 and the denominator of the ratio nearly vanishes. There
64
+ the approximation fails at much smaller x than for an ordinary dielectric.
65
+
66
+ For a dielectric the asymmetry parameter g is 0, because dipole scattering
67
+ is the same forwards and backwards. The Mie value is 0.002 at x = 0.1 and
68
+ 0.05 at x = 0.5 for m = 1.5.
69
+
70
+ Conventions:
71
+ The index is m = n - ik, as in the rest of the package, and an index with a
72
+ positive imaginary part is conjugated. The inputs that ``miepython``
73
+ rejects, a NaN or infinite m or x, a negative x or a negative real part of
74
+ m, raise the same ``ValueError`` here. The efficiencies accept an array of
75
+ m or x and return arrays, g included. The angular functions describe one
76
+ sphere and vectorize over mu only.
77
+
78
+ Perfect conductors:
79
+ As in the rest of the package, m.real == 0 means a perfect conductor,
80
+ whatever the imaginary part, and m = 0 is one too. No field enters such a
81
+ sphere, so the dielectric expressions, which describe the field inside,
82
+ do not apply. Instead the surface currents radiate an electric dipole and
83
+ a magnetic dipole half as strong and opposite in sign, a1 = 2i x**3/3 and
84
+ b1 = -a1/2. The sphere absorbs nothing, qext = qsca = 10/3 x**4, and
85
+ qback = 9 x**4. The two dipoles add backwards and partly cancel forwards,
86
+ so g = -2/5 and the backscattered intensity is nine times the forward one.
87
+
88
+ Examples:
89
+ A small absorbing sphere, compared with the full Mie solution:
90
+
91
+ >>> import numpy as np
92
+ >>> import miepython as mie
93
+ >>> from miepython import rayleigh
94
+ >>> qext, qsca, qback, g = rayleigh.efficiencies_mx(1.5 - 0.1j, 0.1)
95
+ >>> print(f"{qext:.5f} {qsca:.6f} {g:.4f}")
96
+ 0.02006 0.000024 0.0000
97
+ >>> qext, qsca, qback, g = mie.efficiencies_mx(1.5 - 0.1j, 0.1)
98
+ >>> print(f"{qext:.5f} {qsca:.6f} {g:.4f}")
99
+ 0.02006 0.000024 0.0020
100
+
101
+ The dipole pattern scatters equally forwards and backwards, and half as
102
+ much at 90 degrees:
103
+
104
+ >>> mu = np.array([-1.0, 0.0, 1.0])
105
+ >>> print(np.round(rayleigh.i_unpolarized(1.5, 0.1, mu), 4))
106
+ [0.1195 0.0597 0.1195]
107
+ >>> print(np.round(mie.i_unpolarized(1.5, 0.1, mu), 4))
108
+ [0.1188 0.0597 0.1199]
109
+
110
+ A perfect conductor scatters mostly backwards:
111
+
112
+ >>> qext, qsca, qback, g = rayleigh.efficiencies_mx(-1j, 0.1)
113
+ >>> print(f"{qsca:.4e} {qback:.4e} {g:.4f}")
114
+ 3.3333e-04 9.0000e-04 -0.4000
115
+ >>> qext, qsca, qback, g = mie.efficiencies_mx(-1j, 0.1)
116
+ >>> print(f"{qsca:.4e} {qback:.4e} {g:.4f}")
117
+ 3.3413e-04 8.9834e-04 -0.3973
118
+ """
2
119
 
3
120
  import numpy as np
4
121
 
122
+ from .core import _check_m_x, _check_one_sphere
5
123
  from .core import normalization_factor as core_normalization_factor
6
124
 
7
125
  __all__ = (
@@ -20,7 +138,9 @@ def efficiencies_mx(m, x):
20
138
  """
21
139
  Calculate the efficiencies for a small sphere.
22
140
 
23
- Based on equations 5.7 - 5.9 in Bohren and Huffman
141
+ Based on equations 5.7 - 5.9 in Bohren and Huffman. A perfect conductor,
142
+ m.real == 0, uses the electric and magnetic dipoles instead; the module
143
+ docstring gives the details.
24
144
 
25
145
  Args:
26
146
  m: the complex index of refraction of the sphere
@@ -30,14 +150,43 @@ def efficiencies_mx(m, x):
30
150
  qext: the total extinction efficiency
31
151
  qsca: the scattering efficiency
32
152
  qback: the backscatter efficiency
33
- g: the average cosine of the scattering phase function
153
+ g: the average cosine of the scattering phase function, 0 for a dielectric, -0.4 for a conductor
154
+
155
+ Raises:
156
+ ValueError: if m or x is not finite, x is negative, or the real part of
157
+ m is negative, exactly as for the full Mie functions
34
158
  """
35
- ratio = (m**2 - 1) / (m**2 + 2)
36
- qsca = 8 / 3 * x**4 * abs(ratio) ** 2
37
- qext = 4 * x * ratio * (1 + x**2 / 15 * ratio * (m**4 + 27 * m**2 + 38) / (2 * m**2 + 3))
38
- qext = abs(qext.imag + qsca)
39
- qback = 4 * x**4 * abs(ratio) ** 2
40
- g = 0
159
+ _check_m_x(m, x)
160
+
161
+ # ensure imaginary part of refractive index is negative, as core.py does
162
+ if np.isscalar(m):
163
+ m = np.conj(m) if np.imag(m) > 0 else m
164
+ else:
165
+ m = np.where(np.imag(m) > 0, np.conj(m), m)
166
+
167
+ # the dielectric expressions are evaluated for every entry and then replaced
168
+ # where m is a conductor; a few conductor indices, m = -1j*sqrt(2) and
169
+ # m = -1j*sqrt(1.5), make their denominators vanish, so silence the warnings
170
+ with np.errstate(divide="ignore", invalid="ignore"):
171
+ ratio = (m**2 - 1) / (m**2 + 2)
172
+ qsca = 8 / 3 * x**4 * abs(ratio) ** 2
173
+ # Bohren & Huffman write m = n + ik, so with m = n - ik the absorption term
174
+ # changes sign. The x**4 term is Re(ratio**2), not |ratio|**2: the two only
175
+ # agree for a non-absorbing sphere, and the difference matters near resonance.
176
+ qabs = -4 * x * (ratio * (1 + x**2 / 15 * ratio * (m**4 + 27 * m**2 + 38) / (2 * m**2 + 3))).imag
177
+ qext = qabs + 8 / 3 * x**4 * (ratio**2).real
178
+ qback = 4 * x**4 * abs(ratio) ** 2
179
+
180
+ # A perfect conductor (m.real == 0) radiates a magnetic dipole half as strong
181
+ # as, and opposite to, its electric one: a1 = 2i x**3/3 and b1 = -a1/2. It
182
+ # absorbs nothing, scatters 6/x**2 (|a1|**2 + |b1|**2) = 10/3 x**4, and the
183
+ # interference of the two dipoles throws light backwards, so g = -2/5.
184
+ conductor = np.real(m) == 0
185
+ qsca = np.where(conductor, 10 / 3 * x**4, qsca)[()]
186
+ qext = np.where(conductor, 10 / 3 * x**4, qext)[()]
187
+ qback = np.where(conductor, 9 * x**4, qback)[()]
188
+ # a dielectric's electric dipole alone scatters symmetrically fore and aft
189
+ g = np.where(conductor, -0.4, 0 * qsca)[()]
41
190
  return qext, qsca, qback, g
42
191
 
43
192
 
@@ -84,7 +233,8 @@ def S1_S2(m, x, mu, norm="albedo"):
84
233
  """
85
234
  Calculate the scattering amplitude functions for small spheres.
86
235
 
87
- Based on equation 5.4 in Bohren and Huffman
236
+ Based on equation 5.4 in Bohren and Huffman. A perfect conductor,
237
+ m.real == 0, adds the magnetic dipole b1 = -a1/2 to its electric one.
88
238
 
89
239
  The amplitude functions are normalized so that when integrated
90
240
  over all 4*pi solid angles, the integral will be qext*pi*x**2.
@@ -99,15 +249,29 @@ def S1_S2(m, x, mu, norm="albedo"):
99
249
 
100
250
  Returns:
101
251
  S1, S2: the scattering amplitudes at each angle mu [sr**(-0.5)]
252
+
253
+ Raises:
254
+ ValueError: if m or x is not finite, x is negative, or the real part of
255
+ m is negative, exactly as for the full Mie functions, or if m or x
256
+ is an array, since this describes one sphere
102
257
  """
258
+ _check_m_x(m, x)
259
+ _check_one_sphere(m, x)
103
260
  if np.imag(m) > 0: # ensure imaginary part of refractive index is negative
104
261
  m = np.conj(m)
105
262
 
106
- a1 = (2 * x**3) / 3 * (m**2 - 1) / (m**2 + 2) * 1j
107
- a1 += (2 * x**5) / 5 * (m**2 - 2) * (m**2 - 1) / (m**2 + 2) ** 2 * 1j
108
-
109
- S1 = (3 / 2) * a1 * np.ones_like(mu)
110
- S2 = (3 / 2) * a1 * mu
263
+ if np.real(m) == 0:
264
+ # perfect conductor: an electric dipole and a magnetic one, b1 = -a1/2
265
+ a1 = 2j * x**3 / 3
266
+ b1 = -a1 / 2
267
+ else:
268
+ a1 = (2 * x**3) / 3 * (m**2 - 1) / (m**2 + 2) * 1j
269
+ a1 += (2 * x**5) / 5 * (m**2 - 2) * (m**2 - 1) / (m**2 + 2) ** 2 * 1j
270
+ b1 = 0
271
+
272
+ # the n = 1 terms of the Mie series, where pi_1 = 1 and tau_1 = mu
273
+ S1 = (3 / 2) * (a1 * np.ones_like(mu) + b1 * mu)
274
+ S2 = (3 / 2) * (a1 * mu + b1 * np.ones_like(mu))
111
275
 
112
276
  normalization = normalization_factor(m, x, norm)
113
277
 
@@ -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
 
@@ -0,0 +1,18 @@
1
+ miepython/__init__.py,sha256=oWYwelBWxyV7Rc9DQSVKvy-Mx4Ck-k2PjfyShl-5pW0,5888
2
+ miepython/_backend.py,sha256=l_faAa6zanygRZKA9vMVcfcutHPFFkpqZzHqwjU8QcE,1619
3
+ miepython/bessel.py,sha256=U094wY_5RFqrVjXZgMbacmt_yqpP8GBtRZ24-MNzBsU,3647
4
+ miepython/core.py,sha256=zv0hkVBBpcr9GA2xwNAxWHancnh7o5X1mp2xZUoz2Js,24974
5
+ miepython/field.py,sha256=zvTf2g0qca0WRfJ9r2SL5YDGObfTYlOpcXUY9BoOQjA,26583
6
+ miepython/mie_jit.py,sha256=kYy9UCfuEgPSL0XY7P8Qx4IqBr9BbffusF6oeZdTnvQ,21685
7
+ miepython/mie_nojit.py,sha256=EGWqpvdnWOOV72x2wPy8TOSYXLguKXKQn-LzRXYpkrs,20315
8
+ miepython/monte_carlo.py,sha256=1pRELa63NcZOkhSe2AviObPztPaKe7JRqMBQn3rp9Ls,8422
9
+ miepython/rayleigh.py,sha256=6PMTNRs_0u3n9-7bBgJ9-QTB-pmGoPs29iOxj3Eub78,16939
10
+ miepython/util.py,sha256=3jxGd0jmPFYolllQXISLDmz0jE4C1AyFD1OyD65dUGo,3648
11
+ miepython/vsh.py,sha256=bPmEBCxbjCcNgisw3qQR-Zso46-aGPrnv9VBu7jhyTM,18114
12
+ miepython/data/Johnson.txt,sha256=PcdEhJ3ZKUnt--86ukEvUt-w-814uOyWXtE-G3qWTNE,1337
13
+ miepython/data/segelstein81_index.txt,sha256=aJLqcPIkw-0WDY37GRcTefLV0WGhyMt6IbOKY8JxGww,36719
14
+ miepython-3.3.1.dist-info/licenses/LICENSE.txt,sha256=g9-BWWRLvMDMQ1Zflxliku7Sleom1_SbXHQHOLGgqqk,1055
15
+ miepython-3.3.1.dist-info/METADATA,sha256=AlX1gk-tBQ2Z6km4sptmW2cGUx51DDAw4nEE0Gz30YE,7789
16
+ miepython-3.3.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
17
+ miepython-3.3.1.dist-info/top_level.txt,sha256=bfkk6YwrKm_aIzInR5y03YbywpwCzUjJEHaFkEiSKxI,10
18
+ miepython-3.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,18 +0,0 @@
1
- miepython/__init__.py,sha256=DCQbT9uj3NC33GNii2P242pbCKFffe4tg7_qszNKpKE,5888
2
- miepython/_backend.py,sha256=l_faAa6zanygRZKA9vMVcfcutHPFFkpqZzHqwjU8QcE,1619
3
- miepython/bessel.py,sha256=U094wY_5RFqrVjXZgMbacmt_yqpP8GBtRZ24-MNzBsU,3647
4
- miepython/core.py,sha256=SxppxzCwor0qFNMTPReBM6SZTDhuhuWyrnFEdm8fiQE,22519
5
- miepython/field.py,sha256=3CFj0Tg764fT-BABSGjmhOGiBiUUsqDuVheXToGb7Wg,26494
6
- miepython/mie_jit.py,sha256=kYy9UCfuEgPSL0XY7P8Qx4IqBr9BbffusF6oeZdTnvQ,21685
7
- miepython/mie_nojit.py,sha256=EGWqpvdnWOOV72x2wPy8TOSYXLguKXKQn-LzRXYpkrs,20315
8
- miepython/monte_carlo.py,sha256=ZEccQCkqlbqFOXmVmVzgWx41nBJ-Yuj45-cxYDLZjDY,6015
9
- miepython/rayleigh.py,sha256=cWSmP-N5QXF2u7WPwew_QiI4gyJx4FAZNxSiKFvpCMo,8875
10
- miepython/util.py,sha256=3jxGd0jmPFYolllQXISLDmz0jE4C1AyFD1OyD65dUGo,3648
11
- miepython/vsh.py,sha256=bPmEBCxbjCcNgisw3qQR-Zso46-aGPrnv9VBu7jhyTM,18114
12
- miepython/data/Johnson.txt,sha256=PcdEhJ3ZKUnt--86ukEvUt-w-814uOyWXtE-G3qWTNE,1337
13
- miepython/data/segelstein81_index.txt,sha256=aJLqcPIkw-0WDY37GRcTefLV0WGhyMt6IbOKY8JxGww,36719
14
- miepython-3.3.0.dist-info/licenses/LICENSE.txt,sha256=g9-BWWRLvMDMQ1Zflxliku7Sleom1_SbXHQHOLGgqqk,1055
15
- miepython-3.3.0.dist-info/METADATA,sha256=e6yTtC4kocqZOZOCC7xN6XD3Ao00S2CKphQORmvesKs,7831
16
- miepython-3.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
17
- miepython-3.3.0.dist-info/top_level.txt,sha256=bfkk6YwrKm_aIzInR5y03YbywpwCzUjJEHaFkEiSKxI,10
18
- miepython-3.3.0.dist-info/RECORD,,