External validation¶
The internal validation (Internal validation) shows that scatrs evaluates the Mie equations
accurately. This page checks that it evaluates the right equations, in the conventions
users expect, against results that do not depend on scatrs’s code: the published test cases of
another Mie code, and the limits of known physics. The page runs whenever the documentation is
built; its checks are asserts, so a failing check fails the build.
Wiscombe’s MIEV0 test cases¶
Wiscombe’s MIEV0 (Wiscombe 1979, 1980) is the most widely used Mie code, and its documentation publishes test cases that most Mie codes since have been checked against: \(Q_\text{ext}\), \(Q_\text{sca}\), and the amplitudes \(S_1\) and \(S_2\) at seven angles from 0° to 180°, to seven significant digits. The cases span small and large spheres, refractive indices below 1, weak and strong absorption, and a metal-like \(m = 10 + 10i\).
MIEV0 uses time dependence \(e^{+i\omega t}\): its refractive index is written \(n - ik\), and its amplitudes are the complex conjugates of scatrs’s (Conventions). The values are those of test cases 5 to 19, as tabulated in the test suite of sasktran2, which replaces a few backward-angle amplitudes at \(x = 10^4\) (cases 11, 16 and 19) with values from another code; those are left out here.
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from scatrs import mie
data = json.loads(Path("data", "miev0-test-cases.json").read_text())
mu = np.cos(np.radians(data["angles_deg"]))
def half_unit(printed):
"""Half a unit in the last printed digit of a number such as "2.101321E+00" or "0.093923"."""
mantissa, _, exponent = printed.upper().partition("E")
decimals = len(mantissa.partition(".")[2])
return 0.5 * 10.0 ** (int(exponent or 0) - decimals)
rows = []
for case in data["cases"]:
x, m = case["x"], complex(*case["m"])
q = mie.efficiencies(x, m)
s = mie.amplitudes(x, m, mu)
# Amplitude differences relative to the amplitudes' size: the larger of |S| and its
# root-mean-square value over all directions, x √Q_sca / 2.
scale_rms = x * np.sqrt(q.q_sca) / 2
worst_s = max(
abs(got - complex(*want).conjugate()) / max(abs(complex(*want)), scale_rms)
for name, values in (("s1", s.s1), ("s2", s.s2))
for got, want in zip(values, case[name], strict=True)
if want is not None
)
# Q differences in units of the last printed digit.
worst_q = max(abs(getattr(q, k) - float(case[k])) / (2 * half_unit(case[k]))
for k in ("q_ext", "q_sca") if k in case)
rows.append((case["case"], x, m, q.q_ext, q.q_sca, worst_q, worst_s))
print(f"{'case':>4} {'x':>8} {'m':>12} {'Q_ext':>10} {'Q_sca':>10} "
f"{'Q: last digits':>15} {'S: relative':>12}")
for c, x, m, qe, qs, dq, ds in rows:
print(f"{c:4d} {x:8g} {f'{m.real:g}+{m.imag:g}i':>12} {qe:10.7f} {qs:10.7f} {dq:15.2f} {ds:12.1e}")
assert max(r[5] for r in rows) < 1, "a Q differs by a unit in its last printed digit"
assert max(r[6] for r in rows) < 1e-6, "an amplitude differs in its 7th digit"
case x m Q_ext Q_sca Q: last digits S: relative
5 0.099 0.75+0i 0.0000074 0.0000074 0.42 8.3e-07
6 0.101 0.75+0i 0.0000080 0.0000080 0.03 6.9e-07
7 10 0.75+0i 2.2322648 2.2322648 0.16 3.2e-07
8 1000 0.75+0i 1.9979082 1.9979082 0.18 9.2e-08
9 1 1.33+1e-05i 0.0939520 0.0939233 0.37 3.1e-07
10 100 1.33+1e-05i 2.1013207 2.0965935 0.49 6.9e-08
11 10000 1.33+1e-05i 2.0040889 1.7238572 0.22 7.7e-08
12 0.055 1.5+1i 0.1014910 0.0000113 0.42 9.8e-08
13 0.056 1.5+1i 0.1033467 0.0000122 0.16 8.2e-08
14 1 1.5+1i 2.3363210 0.6634538 0.38 1.2e-07
15 100 1.5+1i 2.0975018 1.2836970 0.24 1.1e-07
16 10000 1.5+1i 2.0043677 1.2365743 0.69 7.8e-08
17 1 10+10i 2.5329931 2.0494050 0.08 7.7e-08
18 100 10+10i 2.0711243 1.8367854 0.40 7.5e-08
19 10000 10+10i 2.0059143 1.7953930 0.33 8.2e-08
Every published \(Q\) is reproduced to within one unit in its last printed digit (the column “Q: last digits”), and every amplitude to within \(10^{-6}\) of the amplitudes’ size. All but two cases agree to within half a unit in the seventh digit, the rounding of the published values. The two exceptions, cases 5 and 6 at \(x \approx 0.1\), differ by less than \(10^{-6}\); there the declared accuracy of scatrs’s amplitudes is better than \(10^{-12}\) (Internal validation), so the differences come from the published values.
cases = [r[0] for r in rows]
fig, ax = plt.subplots(figsize=(8, 3.4), layout="constrained")
ax.bar(np.arange(len(rows)), [r[6] for r in rows], color="C0")
ax.axhline(5e-7, color="0.4", ls="--", lw=0.8)
ax.text(len(rows) - 0.5, 6e-7, "half a unit in the 7th digit", ha="right", color="0.3")
ax.set_xticks(np.arange(len(rows)), [f"{c}\n{r[1]:g}" for c, r in zip(cases, rows, strict=True)],
fontsize=7)
ax.set(yscale="log", ylim=(1e-8, 3e-6), xlabel="MIEV0 test case and its size parameter",
ylabel="largest relative difference of $S_1$, $S_2$")
plt.show()
Rayleigh scattering¶
Spheres much smaller than the wavelength scatter as dipoles. Their scattering efficiency is \(Q_\text{sca} = \tfrac83 x^4 \lvert (m^2 - 1)/(m^2 + 2)\rvert^2\), with corrections of order \(x^2\), and their normalized phase matrix has the closed form \(F_{11} = \tfrac34(1 + \mu^2)\), \(F_{12} = -\tfrac34(1 - \mu^2)\), \(F_{33} = \tfrac32\mu\), \(F_{34} = 0\). Its Greek coefficients (Mishchenko, Travis & Lacis 2002) are \(\alpha_1 = (1, 0, \tfrac12)\), \(\alpha_2^2 = 3\), \(\alpha_4^1 = \tfrac32\) and \(\beta_1^2 = -\sqrt6/2\), all others zero. These fix the sign convention of every element: a sign error in any of them would show here.
x_small = 1e-4
for m in (1.33, 1.5 + 0.1j, 0.5 + 3j, 10 + 10j):
q = mie.efficiencies(x_small, m)
rayleigh = 8 / 3 * x_small**4 * abs((m * m - 1) / (m * m + 2)) ** 2
print(f"m = {m}: Q_sca / Rayleigh - 1 = {q.q_sca / rayleigh - 1:+.1e}")
assert abs(q.q_sca / rayleigh - 1) < 1e-6
mu_check = np.linspace(-1, 1, 11)
f = mie.phase_matrix(x_small, 1.5, mu_check)
for got, want in [
(f.f11, 0.75 * (1 + mu_check**2)),
(f.f12, -0.75 * (1 - mu_check**2)),
(f.f33, 1.5 * mu_check),
(f.f34, 0 * mu_check),
]:
assert np.allclose(got, want, rtol=0, atol=1e-7)
g = mie.greek(x_small, 1.5, 4)
expected = {
"alpha1": [1, 0, 0.5, 0, 0], "alpha2": [0, 0, 3, 0, 0], "alpha3": [0, 0, 0, 0, 0],
"alpha4": [0, 1.5, 0, 0, 0], "beta1": [0, 0, -np.sqrt(6) / 2, 0, 0], "beta2": [0, 0, 0, 0, 0],
}
for name, want in expected.items():
print(f"{name}: {np.round(getattr(g, name), 6) + 0.0}") # + 0.0: no negative zeros
assert np.allclose(getattr(g, name), want, rtol=0, atol=1e-7), name
m = 1.33: Q_sca / Rayleigh - 1 = -7.4e-10
m = (1.5+0.1j): Q_sca / Rayleigh - 1 = +7.4e-10
m = (0.5+3j): Q_sca / Rayleigh - 1 = +1.8e-08
m = (10+10j): Q_sca / Rayleigh - 1 = +1.2e-08
alpha1: [1. 0. 0.5 0. 0. ]
alpha2: [0. 0. 3. 0. 0.]
alpha3: [0. 0. 0. 0. 0.]
alpha4: [0. 1.5 0. 0. 0. ]
beta1: [ 0. 0. -1.224745 0. 0. ]
beta2: [0. 0. 0. 0. 0.]
As \(x\) grows, \(Q_\text{sca}\) leaves the Rayleigh law by a relative amount proportional to \(x^2\):
xs = np.geomspace(1e-4, 1, 200)
fig, ax = plt.subplots(figsize=(6, 3.6), layout="constrained")
for m in (1.33 + 0j, 1.5 + 0.1j, 0.5 + 3j):
q = mie.efficiencies(xs, m)
rayleigh = 8 / 3 * xs**4 * abs((m * m - 1) / (m * m + 2)) ** 2
ax.loglog(xs, abs(q.q_sca / rayleigh - 1), label=f"$m$ = {m.real:g} + {m.imag:g}i")
ax.loglog(xs, xs**2, ":", color="0.5", label="$x^2$")
ax.set(xlabel="size parameter $x$", ylabel="$|Q_\\mathrm{sca}/Q_\\mathrm{Rayleigh} - 1|$")
ax.legend()
plt.show()
Large spheres and the optical theorem¶
A sphere much larger than the wavelength removes twice its geometric cross section, half by absorption or reflection and refraction and half by diffraction (the extinction paradox), so \(Q_\text{ext} \to 2\). And for every sphere, the extinction equals the forward amplitude by the optical theorem, \(Q_\text{ext} = (4/x^2)\,\mathrm{Re}\,S(0°)\). scatrs computes \(Q_\text{ext}\) from the Mie coefficients directly, not through \(S(0°)\), so the two are independent results.
xs = np.geomspace(1, 2e4, 300)
fig, ax = plt.subplots(figsize=(6, 3.6), layout="constrained")
for m in (1.33 + 1e-3j, 1.5 + 0.1j, 0.5 + 3j):
q = mie.efficiencies(xs, m)
s = mie.amplitudes(xs, m, 1.0)
theorem = 4 / xs**2 * s.s1.real
assert np.allclose(theorem, q.q_ext, rtol=1e-10, atol=0)
ax.semilogx(xs, q.q_ext, label=f"$m$ = {m.real:g} + {m.imag:g}i")
assert abs(q.q_ext[-1] - 2) < 0.02
ax.axhline(2, color="0.5", ls="--", lw=0.8)
ax.set(xlabel="size parameter $x$", ylabel="$Q_\\mathrm{ext}$", ylim=(0, 4.5))
ax.legend()
plt.show()
A non-absorbing sphere conserves energy: \(Q_\text{abs}\) is exactly zero, and \(Q_\text{ext} = Q_\text{sca}\).
q = mie.efficiencies(np.geomspace(1e-3, 1e4, 50), 1.33)
assert np.all(q.q_abs == 0)
assert np.allclose(q.q_ext, q.q_sca, rtol=1e-13, atol=0)
Layered spheres¶
A layered sphere has its own checks from physics, which do not depend on any code.
An air shell changes nothing. A shell of the medium’s own index (\(m = 1\)) around a core is not there: the coefficients are the bare core’s. scatrs computes them from the shell’s functions at both radii, so the check exercises the whole recursion.
from scatrs import layered
for x_core, m_core in [(0.5, 1.5 + 0.01j), (12.0, 1.95 + 0.79j), (80.0, 1.33)]:
core = mie.coefficients(x_core, m_core)
n = int(core.n_terms)
coated = layered.coefficients([x_core, 1.6 * x_core], [m_core, 1.0], n_max=n,
allow_unvalidated=True)
err = max(abs(coated.a[:n] - core.a).max(), abs(coated.b[:n] - core.b).max())
scale = max(abs(core.a).max(), abs(core.b).max())
print(f"x_core = {x_core:g}, m = {m_core}: relative difference {err / scale:.1e}")
assert err <= 1e-12 * scale
x_core = 0.5, m = (1.5+0.01j): relative difference 1.7e-16
x_core = 12, m = (1.95+0.79j): relative difference 4.6e-15
x_core = 80, m = 1.33: relative difference 5.8e-15
Layers of one index are one sphere. Splitting a sphere into shells of its own index gives the homogeneous sphere of the outer radius, exactly:
q = layered.efficiencies([2.0, 5.0, 9.0], [1.5 + 0.01j] * 3)
q0 = mie.efficiencies(9.0, 1.5 + 0.01j)
assert q.q_ext == q0.q_ext and q.q_abs == q0.q_abs and q.g == q0.g
The Rayleigh limit. A coated sphere much smaller than the wavelength scatters as a dipole of the electrostatic polarizability of a coated sphere (Bohren & Huffman 1983, chapter 5): with \(\varepsilon = m^2\) of the core (1) and shell (2) and the core volume fraction \(f = (x_1/x_2)^3\), \(a_1 \to -\tfrac{2i}{3}x^3\,\tilde\alpha\) with
with corrections of order \(x^2\):
x_out = 1e-4
for m1, m2 in [(1.95 + 0.79j, 1.43 + 0.01j), (1.33, 10 + 10j), (0.2 + 3j, 1.45)]:
for f_r in (0.1, 0.5, 0.9):
e1, e2, f = m1 * m1, m2 * m2, f_r**3
ratio = ((e2 - 1) * (e1 + 2 * e2) + f * (2 * e2 + 1) * (e1 - e2)) / (
(e2 + 2) * (e1 + 2 * e2) + 2 * f * (e2 - 1) * (e1 - e2)
)
a1 = layered.coefficients([f_r * x_out, x_out], [m1, m2]).a[0]
assert abs(a1 / (-2j / 3 * x_out**3 * ratio) - 1) < 10 * x_out**2
print("the Rayleigh limit holds to order x² for every case")
the Rayleigh limit holds to order x² for every case
Energy. A particle of real indices absorbs nothing, and for absorbing ones the extinction equals the forward amplitude (the optical theorem), computed independently:
q = layered.efficiencies([3.0, 6.0, 9.0], [1.5, 2.5, 1.33])
assert q.q_abs == 0 and q.q_ext == q.q_sca
x, m = [20.0, 35.0], [1.95 + 0.79j, 1.43 + 1e-8j]
q, s = layered.efficiencies(x, m), layered.amplitudes(x, m, 1.0)
assert abs(4 / 35.0**2 * s.s1.real / q.q_ext - 1) < 1e-12
Comparison with scattnlay¶
scattnlay (Peña & Pal 2009) computes layered spheres
by the recursion scatrs builds on, in double precision. The script
benchmarks/compare_scattnlay.py measures both, and the textbook method (direct matching at
the interfaces in double precision, as Bohren & Huffman’s BHCOAT does), against the 362 Arb
reference cases inside scatrs’s validated domain. The results it wrote are in
benchmarks/results/2026-10-14-arm64-scattnlay.json. Apple M4 Pro, macOS; scatrs 0.2.0,
scattnlay 2.4, 2026-10-14.
output |
scatrs: largest error |
scattnlay: cases above \(10^{-10}\), above \(10^{-6}\), failed |
direct matching: the same |
|---|---|---|---|
\(a_n\), \(b_n\) |
\(2.9\cdot10^{-12}\) |
118, 50, 9 |
143, 94, 43 |
\(Q_\text{ext}\) |
\(4.1\cdot10^{-12}\) |
102, 63, 6 |
157, 111, 59 |
\(Q_\text{sca}\) |
\(4.1\cdot10^{-12}\) |
100, 49, 6 |
151, 108, 59 |
\(Q_\text{abs}\) |
\(6.3\cdot10^{-11}\) |
174, 71, 6 |
205, 110, 53 |
\(Q_\text{back}\) |
\(5.4\cdot10^{-11}\) |
110, 46, 6 |
188, 109, 59 |
\(g\) |
\(7.3\cdot10^{-12}\) |
108, 55, 9 |
173, 131, 59 |
\(S_1\), \(S_2\) |
\(9.3\cdot10^{-11}\) |
140, 50, 6 |
142, 50, 0 |
The errors are measured as in Internal validation (relative, \(Q_\text{back}\) relative to the larger of \(Q_\text{back}\) and \(Q_\text{sca}\), \(g\) to the larger of \(|g|\) and \(10^{-3}\), the amplitudes to the larger of \(|S|\) and its RMS size); a failure is a missing or non-finite result. Where scattnlay disagrees, Arb sides with scatrs. Its errors have identifiable causes:
Absorption. scattnlay computes \(Q_\text{abs} = Q_\text{ext} - Q_\text{sca}\), which loses the digits of a weak absorption: a relative error of 2.4 for a core of index \(1.5 + 10^{-8}i\) in a clear shell at \(x_L = 1\). scatrs computes \(Q_\text{abs}\) directly (Layered spheres).
Very small particles. Below \(x \approx 10^{-3}\), scattnlay reduces its number of terms (“nmax value was changed due to unexpected error”) or fails, and its numerators lose the cancelling terms that scatrs removes.
Near-unity indices and air shells: for a small core in an air shell, errors of \(4\cdot10^{-7}\) in the coefficients and \(3\cdot10^{-4}\) in \(g\).
On one thread, scatrs computed the efficiencies of a coated sphere (black carbon in sulfate, core radius half the outer) 4.3 to 8.4 times faster:
\(x_L\) |
scatrs (µs) |
with every derivative (µs) |
scattnlay (µs) |
|---|---|---|---|
1 |
1.7 |
9.0 |
14.2 |
10 |
2.7 |
18.4 |
21.1 |
100 |
10.9 |
83.7 |
58.3 |
1000 |
104 |
451 |
Spheroids¶
Limits¶
A sphere. An aspect ratio of 1 gives every output of
scatrs.mie, to \(10^{-9}\) or better (tests/test_tmatrix.pyandscatrs/src/tmatrix/mod.rs).The Rayleigh limit. At \(x = 0.01\) the orientation-averaged absorption and scattering of oblate and prolate spheroids (\(\varepsilon\) from 1/4 to 4, \(m = 1.5 + 0.1i\)) follow the electrostatic polarizabilities \(\alpha_j = V(m^2 - 1)/(1 + L_j(m^2 - 1))\) of their depolarization factors \(L_j\) (Bohren & Huffman 1983, §5.3) to \(10^{-3}\), the size of the first correction.
The long-wavelength expansion. Majić et al. (2019) derived the T-matrix elements of a spheroid in closed form to \(O(X^6)\), with \(X = kc\): the 15 elements of the dipoles, quadrupoles and octupoles of modes 0 to 2 that are not zero to that order, including the couplings between electric and magnetic multipoles. Their errors are \(O(X^7)\), so as the size halves, the relative error of \(|T|^2\) must fall 16-fold for the two dipolar elements and 4-fold for the others, and that of the averaged cross sections 16-fold. Between \(\tilde X\) = 0.08 and 0.04 (equal-volume size parameter), for prolate (\(c/a\) = 3) and oblate (\(c/a\) = 1/2 and 1/4) spheroids, absorbing or not, scatrs’s elements and cross sections approach them at exactly these rates (measured exponents 4.00 and 2.00, within 0.03;
scatrs/tests/tmatrix_long_wavelength.rs). This checks every block of the low-order T-matrix against an independent derivation, beyond the leading term the Rayleigh limit checks.
Comparison with Mishchenko’s T-matrix code¶
Mishchenko’s extended-precision code for randomly oriented particles (tmq.lp.f, Mishchenko &
Travis 1994; the “tmatd” of the literature) is the standard reference for spheroids. The script
benchmarks/compare_tmatd.py fetches it from its page at NASA GISS (it is not redistributed
here), builds it with gfortran after patches that change no computation (the input read from
standard input, 16 printed digits, gfortran’s names for its quad-precision intrinsics), and
compares it with scatrs at rtol = 1e-10 for 72 spheroids: aspect ratios 1/5, 1/3, 1/2, 2, 3
and 5, \(x\) = 1, 3, 6 and 10, and indices 1.33, \(1.53 + 0.008i\) and \(1.5 + 0.5i\). One particle is
computed as the code’s notes describe, as a size distribution \(10^{-7}\) wide. The results are in
benchmarks/results/2026-10-15-arm64-tmatd.json. Apple M4 Pro, macOS, both on one thread;
scatrs 0.3.0, GNU Fortran 14.2, 2026-10-15.
output |
at tmatd’s tolerance \(10^{-6}\): median, largest difference |
at \(10^{-9}\): median, largest |
|---|---|---|
\(Q_\text{ext}\) |
\(1.6\cdot10^{-9}\), \(6.4\cdot10^{-8}\) |
\(7.0\cdot10^{-13}\), \(7.5\cdot10^{-11}\) |
\(Q_\text{sca}\) |
\(2.1\cdot10^{-9}\), \(8.6\cdot10^{-8}\) |
\(9.3\cdot10^{-13}\), \(6.8\cdot10^{-11}\) |
\(g\) |
\(1.2\cdot10^{-8}\), \(4.1\cdot10^{-8}\) |
\(1.1\cdot10^{-8}\), \(3.7\cdot10^{-8}\) |
Greek coefficients |
\(3.6\cdot10^{-7}\), \(4.1\cdot10^{-6}\) |
\(4.0\cdot10^{-8}\), \(2.9\cdot10^{-7}\) |
The differences are relative (\(g\) absolute, the Greek coefficients relative to the largest \(\alpha_1\), normalized). They shrink with tmatd’s tolerance to the floor of its narrow size distribution, about \(10^{-8}\) in \(g\) and the Greek coefficients; scatrs’s own estimates are \(10^{-11}\) or below. tmatd expands \(F_{12}\) and \(F_{34}\) in the generalized spherical functions \(P^l_{02} = -d^l_{02}\), so its \(\beta_1\) and \(\beta_2\) have the opposite signs of scatrs’s, which uses the Wigner d-functions; the phase matrices agree.
tmatd stops (“convergence is not obtained”) for five spheroids: \(\varepsilon = 1/5\) at \(x = 10\) for every index, \(\varepsilon = 1/5\) at \(x = 6\) and \(\varepsilon = 5\) at \(x = 10\) for the absorbing index (at the tighter tolerance). There its number of orders grows to its limit of 140 without the cross sections settling: the loss of precision of the classical integrals, which scatrs removes (Spheroids). scatrs computes them with about 45 orders.
On one thread, the median time per spheroid, over the spheroids tmatd computes at both of its
tolerances, scatrs at rtol \(10^{-6}\) and \(10^{-8}\), tmatd at \(10^{-6}\) and \(10^{-9}\):
\(x\) |
scatrs, \(10^{-6}\) (s) |
scatrs, \(10^{-8}\) (s) |
tmatd, \(10^{-6}\) (s) |
tmatd, \(10^{-9}\) (s) |
|---|---|---|---|---|
1 |
0.005 |
0.006 |
0.028 |
0.046 |
3 |
0.017 |
0.023 |
0.086 |
0.19 |
6 |
0.052 |
0.073 |
0.31 |
0.48 |
10 |
0.60 |
1.0 |
1.1 |
1.8 |
scatrs at rtol \(10^{-8}\) is faster than tmatd at \(10^{-9}\) for every one of the 67 spheroids
tmatd computes: by 5 to 11 times at \(x = 1\), 4 to 10 times at \(x = 3\), 1.6 to 10 times at
\(x = 6\) and 1.2 to 4 times at \(x = 10\) (median 1.8). At \(x = 10\) the modes that need refinement
in double-double take most of its time; the closest cases are the aspect ratios 1/3, 3 and 5
with the weakly absorbing indices. The slowest spheroid, \(\varepsilon = 1/5\) with the absorbing
index, which tmatd cannot compute, takes 25 s. scatrs also runs its quadrature in parallel,
with results that do not depend on the number of threads.
We thank M. I. Mishchenko, L. D. Travis and the NASA Goddard Institute for Space Studies for making the code publicly available; its references are listed in References.
Published arbitrary-precision values¶
Somerville, Auguié & Le Ru (2013) tabulate \(Q_\text{sca}\) and \(Q_\text{ext}\) of three randomly oriented prolate spheroids computed with the T-matrix and its inversion in arbitrary precision, to 15 digits. The values do not depend on any double-precision code. The three spheroids are model 1 of the benchmark of Kuik, de Haan & Hovenier (1992), a large spheroid, and a silver nanorod at its plasmon resonance, outside scatrs’s validated domain. The paper’s efficiencies are cross sections over the orientation-averaged projected area, \(S/4\) for a convex particle, and its size parameter is \(x_{\max} = kc\); scatrs’s efficiencies are over the cross section of the sphere of equal volume, so the conversion below multiplies by the ratio of the two areas.
from scatrs import tmatrix
def published(h, x_max, m, q_sca, q_ext, **options):
"""scatrs against the paper for a prolate spheroid of c/a = h: relative differences."""
eps = 1 / h
x = x_max * eps ** (2 / 3)
e = np.sqrt(1 - eps**2)
surface = 2 * np.pi * eps**2 * (1 + 1 / (eps * e) * np.arcsin(e)) # with c = 1
ratio = surface / 4 / (np.pi * eps ** (4 / 3))
q = tmatrix.efficiencies(x, m, eps, **options)
return abs(q.q_sca / ratio / q_sca - 1), abs(q.q_ext / ratio / q_ext - 1), float(q.error)
k = 2 * np.pi * 1.33 / 2560 # the nanorod: a = 10 nm, c = 200 nm in a solvent of index 1.33
cases = {
"Kuik et al. model 1": (4, 10.079368, 1.55 + 0.01j, 3.21290554203154, 3.36721292620919, {}),
"silver nanorod": (20, 200 * k, np.sqrt(-318 + 48.5j) / 1.33, 2.91644692910476,
18.6590264036833, {"allow_unvalidated": True}),
}
for name, (h, x_max, m, q_sca, q_ext, options) in cases.items():
d_sca, d_ext, est = published(h, x_max, m, q_sca, q_ext, rtol=1e-12, **options)
print(f"{name:20} Q_sca {d_sca:.1e} Q_ext {d_ext:.1e} (estimate {est:.1e})")
assert max(d_sca, d_ext) < 1e-13
Kuik et al. model 1 Q_sca 1.5e-14 Q_ext 8.9e-16 (estimate 1.0e-14)
silver nanorod Q_sca 1.1e-15 Q_ext 2.7e-15 (estimate 1.8e-14)
spheroid |
\(c/a\) |
\(x_{\max}\) |
\(m\) |
scatrs: \(Q_\text{sca}\), \(Q_\text{ext}\) |
SMARTIES’s double precision |
|---|---|---|---|---|---|
Kuik et al. model 1 |
4 |
10.079368 |
\(1.55 + 0.01i\) |
\(1.5\cdot10^{-14}\), \(9\cdot10^{-16}\) |
\(6\cdot10^{-15}\), \(9\cdot10^{-15}\) |
large |
4 |
35 |
\(1.5 + 0.02i\) |
\(4.4\cdot10^{-14}\), \(2\cdot10^{-15}\) |
\(7.1\cdot10^{-5}\), \(2.6\cdot10^{-5}\) |
silver nanorod |
20 |
0.653 |
\(1.02 + 13.45i\) |
\(1\cdot10^{-15}\), \(3\cdot10^{-15}\) |
\(2.4\cdot10^{-12}\), \(1.2\cdot10^{-12}\) |
The differences are relative to the published values, at rtol \(10^{-12}\) (the large
spheroid at \(10^{-10}\)). The large spheroid takes about 20 s, so it is checked by the test
large_prolate_spheroid of scatrs/tests/spheroid_published.rs (ignored by default) rather
than on this page; the other two are checked by that file’s default tests as well. The last
column is the same paper’s double-precision code, SMARTIES’s algorithm, against the same
values. The paper’s albedo for the large spheroid, 0.718371249381958, is not the ratio of
its own \(Q_\text{sca}\) and \(Q_\text{ext}\), 0.7183712495692; scatrs’s albedo agrees with the
ratio to \(2\cdot10^{-14}\). The nanorod’s index and aspect ratio are far outside the validated
domain, but with the default settings it converges as closely; at an aspect ratio of 20 the
nodes are set by the shape rather than by \(N\) (Spheroids).
SMARTIES¶
SMARTIES (Somerville, Auguié & Le Ru 2016) computes spheroids in double precision with the algorithm of their 2013 paper, which avoids the cancellations of the classical integrals. Its code is not run here. Its paper reports the largest maximum size parameter \(x_{\max} = k\max(a, c)\) at which the orientation-averaged extinction still converged, for each index and aspect ratio. Its authors attribute the remaining limit to the matrix inversion in double precision, which extended precision would lift; scatrs’s mixed-precision refinement does that. Where the two overlap:
relative index |
SMARTIES, largest \(x_{\max}\) (aspect ratio 2, 4) |
scatrs, validated \(x_{\max} = 60/\lvert m\rvert\) |
|---|---|---|
1.311 |
50 and 45 (oblate), 50 and 45 (prolate) |
46 |
1.5 |
35 and 30 |
40 |
\(1.5 + 0.02i\) |
40 and 30 |
40 |
2.5 |
16 and 12 (oblate), 18 and 12 (prolate) |
24 |
scatrs’s validated domain ends where its cost, not its accuracy, grows: on its accuracy map the
results stay within their tolerance up to \(|m|\,x_{\max} = 70\) (Internal validation). SMARTIES reaches
aspect ratios up to 100 and metallic indices. They are outside scatrs’s validated domain:
scatrs computes them with allow_unvalidated, as it does the nanorod above, but declares no
accuracy constants there. The analytic expansion of its authors (above) checks the low-order
T-matrix.
Size distributions¶
For averages over size distributions, Comparison with sasktran2 compares scatrs with the Mie integration of the sasktran2 radiative transfer model, for accuracy and speed.