miepython 3.2.0__py3-none-any.whl → 3.3.0__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,5 +1,35 @@
1
1
  """
2
- Monte Carlo and Mie scattering.
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.
30
+
31
+ Passing ``rng`` keeps the draws reproducible without disturbing the process-wide
32
+ ``numpy.random``; omitting it falls back to that global generator.
3
33
  """
4
34
 
5
35
  import numpy as np
@@ -96,7 +126,7 @@ def mu_with_uniform_cdf(m, x, num):
96
126
  return [mu, cdf_values]
97
127
 
98
128
 
99
- def generate_mie_costheta(mu_cdf):
129
+ def generate_mie_costheta(mu_cdf, rng=None):
100
130
  """
101
131
  Generate a new scattering angle using a cdf.
102
132
 
@@ -107,18 +137,37 @@ def generate_mie_costheta(mu_cdf):
107
137
 
108
138
  Args:
109
139
  mu_cdf: a cumulative distribution function
140
+ rng: optional source of randomness, anything with a ``random()`` method
141
+ such as ``numpy.random.default_rng()``. Omitting it uses the process
142
+ wide ``numpy.random``, whose stream a caller cannot control without
143
+ reseeding the whole program, so pass a generator when reproducibility
144
+ matters.
110
145
 
111
146
  Returns:
112
147
  The cosine of the scattering angle
148
+
149
+ Examples:
150
+ Draw a reproducible angle without disturbing the global stream:
151
+
152
+ >>> import numpy as np
153
+ >>> import miepython.monte_carlo as mc
154
+ >>> mu, cdf = mc.mu_with_uniform_cdf(1.33, 1.7, 50)
155
+ >>> rng = np.random.default_rng(42)
156
+ >>> first = mc.generate_mie_costheta(mu, rng=rng)
157
+ >>> bool(-1.0 <= first <= 1.0)
158
+ True
159
+ >>> bool(first == mc.generate_mie_costheta(mu, rng=np.random.default_rng(42)))
160
+ True
113
161
  """
114
- # the following should be equivalent to these four lines
115
- # index = np.random.randint(0, high=len(mu_cdf))
162
+ if rng is None:
163
+ rng = np.random
164
+
165
+ # rng.random() draws from [0, 1), so int(r * num) never reaches num and
166
+ # index + 1 stays inside the table. No clamp is needed.
116
167
  num = len(mu_cdf) - 1
117
- index = int(np.random.random() * num)
118
- if index >= num:
119
- index = num - 1
168
+ index = int(rng.random() * num)
120
169
 
121
170
  x = mu_cdf[index]
122
- x += (mu_cdf[index + 1] - mu_cdf[index]) * np.random.random()
171
+ x += (mu_cdf[index + 1] - mu_cdf[index]) * rng.random()
123
172
 
124
173
  return x
miepython/rayleigh.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  import numpy as np
4
4
 
5
+ from .core import normalization_factor as core_normalization_factor
6
+
5
7
  __all__ = (
6
8
  "efficiencies_mx",
7
9
  "efficiencies",
@@ -62,7 +64,10 @@ def efficiencies(m, d, lambda0, n_env=1.0):
62
64
 
63
65
  def normalization_factor(m, x, norm_str):
64
66
  """
65
- Figure out scattering function normalization.
67
+ Figure out scattering function normalization for a small sphere.
68
+
69
+ The rules are the same as for the full Mie solution, so they live in
70
+ ``miepython.core``; only the efficiencies feeding them are the Rayleigh ones.
66
71
 
67
72
  Args:
68
73
  m: complex index of refraction of sphere
@@ -72,38 +77,7 @@ def normalization_factor(m, x, norm_str):
72
77
  Returns:
73
78
  scaling factor needed for scattering function
74
79
  """
75
- factor = None
76
- norm = norm_str.lower()
77
-
78
- if norm in ["bohren"]:
79
- factor = 1 / 2
80
-
81
- elif norm in ["wiscombe"]:
82
- factor = 1
83
-
84
- elif norm in ["qsca", "scattering_efficiency"]:
85
- factor = x * np.sqrt(np.pi)
86
-
87
- else:
88
- qext, qsca, _, _ = efficiencies_mx(m, x)
89
-
90
- if norm in ["a", "albedo"]:
91
- factor = x * np.sqrt(np.pi * qext)
92
-
93
- if norm in ["1", "one", "unity"]:
94
- factor = x * np.sqrt(qsca * np.pi)
95
-
96
- if norm in ["four_pi", "4pi"]:
97
- factor = x * np.sqrt(qsca / 4)
98
-
99
- if norm in ["qext", "extinction_efficiency"]:
100
- factor = x * np.sqrt(qsca * np.pi / qext)
101
-
102
- if factor is None:
103
- raise ValueError(
104
- "normalization must be one of 'albedo' (default), 'one', '4pi', 'qext', 'qsca', 'bohren', or 'wiscombe'"
105
- )
106
- return factor
80
+ return core_normalization_factor(m, x, norm_str, efficiency_source=efficiencies_mx)
107
81
 
108
82
 
109
83
  def S1_S2(m, x, mu, norm="albedo"):
miepython/util.py CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  import numpy as np
4
4
 
5
- _all_ = (
5
+ __all__ = (
6
6
  "cs_scalar",
7
7
  "cs_vector",
8
8
  "cs",
9
+ "phasor_str_scalar",
9
10
  "phasor_str",
10
11
  "cartesian_to_spherical",
11
12
  "spherical_to_cartesian",
@@ -14,10 +15,29 @@ _all_ = (
14
15
 
15
16
 
16
17
  def cartesian_to_spherical(x, y, z):
17
- """Convert Cartesian coordinates (x, y, z) to spherical (r, theta, phi)."""
18
- r = np.sqrt(x**2 + y**2 + z**2)
19
- theta = np.arccos(z / r) if r != 0 else 0 # Polar angle (0 to pi)
20
- phi = np.arctan2(y, x) # Azimuthal angle (-pi to pi)
18
+ """
19
+ Convert Cartesian coordinates (x, y, z) to spherical (r, theta, phi).
20
+
21
+ Scalars and arrays are both accepted and the three inputs are broadcast
22
+ against each other. At the origin theta is reported as 0, and z/r is clipped
23
+ so that rounding cannot push the arccos argument outside [-1, 1].
24
+
25
+ Args:
26
+ x: Cartesian x coordinate, scalar or array-like
27
+ y: Cartesian y coordinate, scalar or array-like
28
+ z: Cartesian z coordinate, scalar or array-like
29
+
30
+ Returns:
31
+ r, theta, phi: radius, polar angle from +z, azimuth from +x toward +y
32
+ """
33
+ xx, yy, zz = np.broadcast_arrays(np.asarray(x), np.asarray(y), np.asarray(z))
34
+ r = np.sqrt(xx**2 + yy**2 + zz**2)
35
+
36
+ with np.errstate(divide="ignore", invalid="ignore"):
37
+ cos_theta = np.where(r == 0, 1.0, np.clip(zz / r, -1.0, 1.0))
38
+
39
+ theta = np.arccos(cos_theta) # polar angle (0 to pi)
40
+ phi = np.arctan2(yy, xx) # azimuthal angle (-pi to pi)
21
41
  return r, theta, phi
22
42
 
23
43
 
miepython/vsh.py CHANGED
@@ -29,18 +29,27 @@ Bessel Function Selection:
29
29
 
30
30
  Functions:
31
31
 
32
- M_odd(n, k, d_sphere, r, theta, phi)
32
+ All four take the same arguments ``(n, lambda0, d_sphere, m_index, r, theta,
33
+ phi)``: the multipole order, the vacuum wavelength, the sphere diameter, the
34
+ refractive index of the medium the point lies in, and the spherical coordinates
35
+ of that point. ``lambda0``, ``d_sphere`` and ``r`` share one length unit.
36
+
37
+ M_odd(n, lambda0, d_sphere, m_index, r, theta, phi)
33
38
  Compute the nth odd magnetic vector spherical harmonic (m=1).
34
39
 
35
- M_even(n, k, d_sphere, r, theta, phi)
40
+ M_even(n, lambda0, d_sphere, m_index, r, theta, phi)
36
41
  Compute the nth even magnetic vector spherical harmonic (m=1).
37
42
 
38
- N_odd(n, k, d_sphere, r, theta, phi)
43
+ N_odd(n, lambda0, d_sphere, m_index, r, theta, phi)
39
44
  Compute the nth odd electric vector spherical harmonic (m=1).
40
45
 
41
- N_even(n, k, d_sphere, r, theta, phi)
46
+ N_even(n, lambda0, d_sphere, m_index, r, theta, phi)
42
47
  Compute the nth even electric vector spherical harmonic (m=1).
43
48
 
49
+ Each has an ``*_array`` counterpart -- ``M_odd_array``, ``M_even_array``,
50
+ ``N_odd_array``, ``N_even_array`` -- with the identical signature, taking
51
+ array-valued ``r``, ``theta`` and ``phi``.
52
+
44
53
  Additional Utility Functions:
45
54
 
46
55
  mie_pi(n, theta, deg=False)
@@ -54,8 +63,8 @@ Additional Utility Functions:
54
63
 
55
64
  import numpy as np
56
65
  from scipy.special import spherical_jn, lpmv, factorial2
57
- from miepython.bessel import spherical_h1, d_riccati_bessel_h1
58
- import miepython as mie
66
+ from .bessel import spherical_h1, d_riccati_bessel_h1
67
+ from ._backend import D_calc
59
68
 
60
69
  __all__ = (
61
70
  "mie_tau",
@@ -312,7 +321,7 @@ def N_base(n, m_index, kr, theta, inside):
312
321
  else:
313
322
  factor1 = spherical_jn(n, rho)
314
323
  # D_calc returns D_1..D_N; request one extra term and take D_n.
315
- Dn = mie.D_calc(m_index, kr, n + 1)[n - 1]
324
+ Dn = D_calc(m_index, kr, n + 1)[n - 1]
316
325
  factor2 = factor1 * Dn
317
326
  factor1 /= rho
318
327
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: miepython
3
- Version: 3.2.0
3
+ Version: 3.3.0
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
@@ -34,6 +34,7 @@ Requires-Dist: sphinx_automodapi; extra == "docs"
34
34
  Requires-Dist: sphinx_rtd_theme; extra == "docs"
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest; extra == "dev"
37
+ Requires-Dist: pytest-cov; extra == "dev"
37
38
  Requires-Dist: imageio; extra == "dev"
38
39
  Requires-Dist: requests; extra == "dev"
39
40
  Requires-Dist: ruff; extra == "dev"
@@ -72,7 +73,7 @@ Dynamic: license-file
72
73
  :alt: DOI
73
74
 
74
75
  .. |license| image:: https://img.shields.io/github/license/scottprahl/miepython?color=68CA66
75
- :target: https://github.com/scottprahl/miepython/blob/master/LICENSE.txt
76
+ :target: https://github.com/scottprahl/miepython/blob/main/LICENSE.txt
76
77
  :alt: License
77
78
 
78
79
  .. |test| image:: https://github.com/scottprahl/miepython/actions/workflows/test.yaml/badge.svg
@@ -208,9 +209,9 @@ JIT Enabled 0.15 s 27×
208
209
  Citation
209
210
  --------
210
211
 
211
- If you use ``laserbeamsize`` in academic, instructional, or applied technical work, please cite:
212
+ If you use ``miepython`` in academic, instructional, or applied technical work, please cite:
212
213
 
213
- Prahl, S. (2026). *miepython: Pure python calculation of Mie scattering* (Version 3.1.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.7949263
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
214
215
 
215
216
  BibTeX:
216
217
 
@@ -222,7 +223,7 @@ BibTeX:
222
223
  url = {https://github.com/scottprahl/miepython},
223
224
  doi = {10.5281/zenodo.7949263},
224
225
  year = {2026},
225
- version = {3.1.0},
226
+ version = {3.2.0},
226
227
  publisher = {Zenodo}
227
228
  }
228
229