diff --git a/benchmark/benchmarks.jl b/benchmark/benchmarks.jl index c15b428..7f4820a 100644 --- a/benchmark/benchmarks.jl +++ b/benchmark/benchmarks.jl @@ -13,6 +13,7 @@ # ebcm – EBCM blocks, fixed-order, and auto-converging solves # iitm – Invariant Imbedding T-Matrix (axisymmetric + N-fold) # postprocessing – far-field observables from a precomputed T-matrix +# near_field – external field reconstruction (VSWF) from a T-matrix # linearization – analytical Jacobian (baseline for the inv→lu work) # precision – Float64 vs Double64 on the same EBCM block @@ -127,6 +128,22 @@ let g = SUITE["postprocessing"] = BenchmarkGroup(["farfield"]) Nγ = 1) seconds=30 end +# -------------------------------------------------------------------------- +# Near-field — external field reconstruction from a precomputed T-matrix. +# `scattering_coefficients` is the per-incidence cost (the (p,q)=𝐓(a,b) apply); +# `scattered_field` is the per-point cost that dominates a dense field grid, so +# it is benchmarked with (p,q) precomputed (reused across points in practice). +# -------------------------------------------------------------------------- +let g = SUITE["near_field"] = BenchmarkGroup(["nearfield"]) + pt = [4.0, 0.0, 3.0] # outside the spheroid's circumscribing sphere (r > 2) + p, q = scattering_coefficients(T_REF, 0.0, 0.0, 1.0 + 0im, 0.0im) + g["scattering_coefficients"] = @benchmarkable scattering_coefficients($T_REF, 0.0, 0.0, + 1.0 + 0im, 0.0im) + g["scattered_field_point"] = @benchmarkable scattered_field($p, $q, $λ, $pt) + g["total_field_point"] = @benchmarkable total_field($T_REF, $λ, 0.0, 0.0, 1.0 + 0im, + 0.0im, $pt) +end + # -------------------------------------------------------------------------- # Linearization — analytical EBCM Jacobian. This is the direct baseline for # the planned `inv(𝐐)` → `lu`/factorization-reuse optimization. diff --git a/docs/make.jl b/docs/make.jl index 56833a6..343b765 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -13,7 +13,7 @@ const NB_DIR = normpath(joinpath(@__DIR__, "..", "examples")) const NB_OUT = joinpath(@__DIR__, "src", "examples") const NOTEBOOKS = ["shapes_gallery.jl", "solver_landscape.jl", "angular_scattering.jl", "orientation_averaging.jl", "spectral_sensitivity.jl", - "rain_radar.jl"] + "rain_radar.jl", "near_field.jl"] function notebook_title(nb) in_markdown = false @@ -33,10 +33,8 @@ function notebook_title(nb) end const NB_PAGE_PATHS = ["examples/" * replace(nb, ".jl" => ".md") for nb in NOTEBOOKS] -const NB_PAGES = [ - notebook_title(nb) => path - for (nb, path) in zip(NOTEBOOKS, NB_PAGE_PATHS) -] +const NB_PAGES = [notebook_title(nb) => path + for (nb, path) in zip(NOTEBOOKS, NB_PAGE_PATHS)] function build_examples() mkpath(NB_OUT) diff --git a/docs/src/api.md b/docs/src/api.md index b29cf5d..296464e 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -51,6 +51,18 @@ Pages = ["common/index.jl", "common/AbstractTransitionMatrix.jl", Filter = t -> !startswith(string(Base.nameof(t)), "_") ``` +## Near-field reconstruction + +Vector spherical wave functions and the external electromagnetic field +(incident, scattered, total) reconstructed from any transition matrix (see the +[Near-field maps from a T-matrix](examples/near_field.md) example). + +```@autodocs +Modules = [TransitionMatrices] +Pages = ["common/vswf.jl", "common/near_field.jl", "EBCM/near_field.jl"] +Filter = t -> !startswith(string(Base.nameof(t)), "_") +``` + ## Linearization The differentiation framework and its backends (see diff --git a/docs/src/examples/index.md b/docs/src/examples/index.md index 2272f79..f014ca8 100644 --- a/docs/src/examples/index.md +++ b/docs/src/examples/index.md @@ -28,6 +28,10 @@ directory of the repository. radiative-transfer prototype: spheroidal IITM T-matrices, a rain-drop axis-ratio model, water/ice refractive-index fits, and a drop-size distribution feeding dual-pol radar moments and brightness-temperature integrals. +- [**Near-field maps from a T-matrix**](near_field.md) — reconstruct the external + electromagnetic field (incident + scattered) from any T-matrix; a + field-enhancement ``|E|/|E_0|`` map and axial cut around a Mie sphere, with the + Rayleigh-hypothesis region-of-validity note. ## Running them locally diff --git a/docs/src/examples/near_field.md b/docs/src/examples/near_field.md new file mode 100644 index 0000000..5b43312 --- /dev/null +++ b/docs/src/examples/near_field.md @@ -0,0 +1,124 @@ +```@raw html + + + + + +

Near-field maps from a T-matrix

TransitionMatrices.jl reconstructs the electromagnetic field around a particle — not just the far-field cross sections — from any computed T-matrix. Given an incident plane wave, the scattered field is expanded in radiating vector spherical wave functions (VSWFs) with coefficients \((p, q) = \mathbf{T}\,(a, b)\), where \((a, b)\) are the plane-wave expansion coefficients. The total external field is

$$\mathbf{E}_\text{tot}(\mathbf r) = \mathbf{E}_\text{inc}(\mathbf r) + \mathbf{E}_\text{sca}(\mathbf r), +\qquad +\mathbf{E}_\text{sca}(\mathbf r) = \sum_{n,m} p_{mn}\mathbf{M}_{mn}(k\mathbf r) + q_{mn}\mathbf{N}_{mn}(k\mathbf r).$$

This works for any T-matrix (Mie, EBCM, IITM, Sh-matrix). Here we map the field enhancement \(|\mathbf{E}_\text{tot}|/|\mathbf{E}_\text{inc}|\) around a dielectric sphere.

Region of validity

The radiating expansion converges only outside the sphere circumscribing the particle (the Rayleigh hypothesis). For a sphere that boundary is the surface, so the map is valid right down to it; for a non-spherical particle, evaluate only outside the circumscribing sphere.

+ +
begin
+    import Pkg
+    Pkg.activate(@__DIR__)                 # the examples/ environment
+    Pkg.develop(; path = dirname(@__DIR__)) # point TransitionMatrices at this repo
+    Pkg.instantiate()
+    using TransitionMatrices, Plots
+end
+ + + +

1. A scatterer and its incidence

A dielectric sphere of size parameter \(x = k a = 2.5\) and refractive index \(m_r = 1.5\), illuminated by an \(x\)-polarized plane wave propagating along \(+z\). With \(\lambda = 2\pi\) the size parameter equals the radius, so \(a = 2.5\).

The incidence direction is \((\vartheta_\text{inc}, \varphi_\text{inc}) = (0, 0)\); the polarization \((E_\vartheta, E_\varphi) = (1, 0)\) is \(\hat{\mathbf x}\) because \(\hat{\boldsymbol\vartheta}(0,0) = \hat{\mathbf x}\).

+ +
begin
+    λ = 2π
+    k = 2π / λ
+    a = 2.5
+    mᵣ = 1.5 + 0.0im
+    x = k * a
+    N = ceil(Int, x + 4 * cbrt(x) + 2)
+    𝐓 = TransitionMatrices.MieTransitionMatrix{ComplexF64, N}(x, mᵣ)
+
+    # Incident plane wave: +z propagation, x-polarized.
+    ϑ_inc, φ_inc = 0.0, 0.0
+    Eθ, Eφ = 1.0 + 0im, 0.0im
+
+    # The scattered coefficients (p, q) = 𝐓 (a, b) depend only on the incidence,
+    # so compute them once and reuse them at every field point.
+    p, q = scattering_coefficients(𝐓, ϑ_inc, φ_inc, Eθ, Eφ)
+    nothing
+end
+ + + +

2. Sample the total field on a grid

We evaluate \(|\mathbf{E}_\text{tot}|\) in the \(x\)\(z\) plane (the plane of incidence and polarization). Points inside the sphere are masked — the external expansion is not meant to represent the interior field (that is the internal field, a separate reconstruction).

+ +
begin
+    xs = range(-3a, 3a; length = 161)
+    zs = range(-3a, 3a; length = 161)
+    enhancement = fill(NaN, length(zs), length(xs))
+    for (i, zz) in enumerate(zs), (j, xx) in enumerate(xs)
+        r = hypot(xx, zz)
+        r ≤ a && continue                      # inside the sphere: masked
+        pos = [xx, 0.0, zz]
+        E = incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, pos) +
+            scattered_field(p, q, λ, pos)
+        enhancement[i, j] = sqrt(abs2(E[1]) + abs2(E[2]) + abs2(E[3]))
+    end
+    nothing
+end
+ + + +

3. The field-enhancement map

The incident wave travels upward (\(+z\)). The map shows the standing-wave interference of the incident and scattered fields, the forward-scattering concentration on the far (\(+z\)) side, and the shadow on the near side.

+ +
let
+    hm = heatmap(xs, zs, enhancement;
+        aspect_ratio = 1, c = :inferno, clims = (0, maximum(filter(isfinite, enhancement))),
+        xlabel = "x", ylabel = "z", colorbar_title = "|E| / |E₀|",
+        title = "dielectric sphere, x = $(round(x; digits = 2)), mᵣ = 1.5",
+        size = (640, 560))
+    # outline the sphere
+    θ = range(0, 2π; length = 200)
+    plot!(hm, a .* cos.(θ), a .* sin.(θ); label = "", lw = 1.5, lc = :cyan)
+end
+ + + +

4. A line cut along the optical axis

Along \(x = 0\) the field shows the shadow/illumination asymmetry and the forward enhancement directly.

+ +
let
+    zline = range(-3a, 3a; length = 400)
+    amp = map(zline) do zz
+        abs(zz) ≤ a && return NaN
+        pos = [0.0, 0.0, zz]
+        E = incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, pos) + scattered_field(p, q, λ, pos)
+        sqrt(abs2(E[1]) + abs2(E[2]) + abs2(E[3]))
+    end
+    plot(zline, amp; lw = 2, xlabel = "z (optical axis)", ylabel = "|E| / |E₀|",
+        label = "", title = "axial cut (x = y = 0)", size = (720, 320))
+    vspan!([-a, a]; alpha = 0.15, c = :gray, label = "sphere")
+end
+ + + +

Takeaway

scattering_coefficients once, then scattered_field / total_field at any point gives the full external field for any T-matrix — sphere, spheroid, cylinder, Chebyshev, or a general particle. Reusing the precomputed (p, q) keeps a dense field grid cheap.

For the field inside the particle, see the internal-field reconstruction (TODO, Tier 2).

+ + +``` + diff --git a/docs/src/usage.md b/docs/src/usage.md index 90e90b2..5a6f6f7 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -240,6 +240,91 @@ angles ``\Theta``: 𝐅 = scattering_matrix(𝐓, 2π, θs) # one matrix per angle ``` +## Near-field reconstruction + +Beyond the far field, the **electromagnetic field around the particle** can be +reconstructed from any T-matrix. For an incident plane wave the scattered field +is expanded in radiating vector spherical wave functions with coefficients +``(p, q) = \mathbf{T}\,(a, b)``, where ``(a, b)`` are the plane-wave coefficients; +the total external field is ``\mathbf{E}_\text{inc} + \mathbf{E}_\text{sca}``. + +The incident plane wave propagates along ``\hat{\mathbf n} = (\vartheta_\text{inc}, +\varphi_\text{inc})`` with a Jones polarization `(Eθ, Eφ)` in the spherical basis +``(\hat{\boldsymbol\vartheta}, \hat{\boldsymbol\varphi})`` at that direction. Field +points are Cartesian (any `AbstractVector` of length 3); the polar axis is `+z`. + +```julia +𝐓 = transition_matrix(spheroid, 2π) +λ = 2π + +# +z propagation, x-polarized (θ̂ at ϑ=0 is x̂): +ϑ_inc, φ_inc, Eθ, Eφ = 0.0, 0.0, 1.0 + 0im, 0.0im +r⃗ = [4.0, 0.0, 3.0] # a point outside the particle + +E_inc = incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) # analytic plane wave +E_sca = scattered_field(𝐓, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) +E_tot = total_field(𝐓, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) # = E_inc + E_sca +``` + +When evaluating a **dense grid** of field points, compute the scattered +coefficients once and reuse them: + +```julia +p, q = scattering_coefficients(𝐓, ϑ_inc, φ_inc, Eθ, Eφ) +field = [scattered_field(p, q, λ, [x, 0.0, z]) for z in zs, x in xs] +``` + +The underlying VSWFs are available directly as [`vswf`](@ref) / [`vswf_cartesian`](@ref). + +!!! warning "Region of validity" + The radiating (scattered) expansion converges only **outside the smallest + sphere circumscribing the particle** (the Rayleigh hypothesis). For a sphere + that boundary is the surface; for a non-spherical particle, evaluate only + outside its circumscribing sphere. + +For the field **inside** a homogeneous sphere, [`internal_field`](@ref) +reconstructs it from the analytic Mie internal coefficients (at the internal +wavenumber ``k_\text{int} = m_r k``); it is continuous, tangentially, with the +external [`total_field`](@ref) across the surface: + +```julia +x, mᵣ = 3.0, 1.5 + 0.05im # size parameter and index +r⃗ = [0.3, 0.2, 0.5] # a point inside the sphere + +E_in = internal_field(x, mᵣ, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + +# reuse the coefficients across a dense interior grid: +c, d = internal_coefficients(x, mᵣ, ϑ_inc, φ_inc, Eθ, Eφ) +E_in = internal_field(c, d, mᵣ, λ, r⃗) +``` + +For a general **axisymmetric** particle the internal field is reconstructed from +the EBCM matrices (``\mathbf{c} = \tfrac{1}{2}\mathbf{Q}^{-1}\mathbf{a}`` per +azimuthal block), via the shape-based methods: + +```julia +spheroid = Spheroid(1.0, 2.0, 1.4 + 0.02im) +E_in = internal_field(spheroid, λ, nmax, Ng, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) +# or, reused across a grid: +c, d = internal_coefficients(spheroid, λ, nmax, Ng, ϑ_inc, φ_inc, Eθ, Eφ) +E_in = internal_field(c, d, spheroid.m, λ, r⃗) +``` + +!!! note "Region of validity and conditioning" + The interior expansion is mathematically guaranteed within the inscribed sphere + (radius `rmin(shape)`); empirically it stays stable and physical throughout the + interior (it does **not** diverge past the inscribed sphere). The binding limit + is instead EBCM ``\mathbf{Q}``-matrix conditioning at high aspect ratio with + high `nmax` — since ``\mathbf{c} = \tfrac12\mathbf{Q}^{-1}\mathbf{a}`` inherits + ``\mathbf{Q}^{-1}``, the same Float64 cancellation that limits the T-matrix + corrupts the coefficients everywhere — so keep `nmax`/aspect where the T-matrix + is reliable. The convention is validated against Mie on the degenerate sphere + (`Spheroid(R, R, mᵣ)`); absolute accuracy beyond the inscribed sphere for + non-spherical shapes is not independently verified. + +See the [Near-field maps from a T-matrix](examples/near_field.md) example for a +field-enhancement map. + ## Orientation averaging For randomly oriented particles you usually want orientation-averaged diff --git a/examples/near_field.jl b/examples/near_field.jl new file mode 100644 index 0000000..f52ad3d --- /dev/null +++ b/examples/near_field.jl @@ -0,0 +1,181 @@ +### A Pluto.jl notebook ### +# v1.0.1 + +using Markdown +using InteractiveUtils + +# ╔═╡ 7a1f0001-0000-4000-8000-000000000001 +begin + import Pkg + Pkg.activate(@__DIR__) # the examples/ environment + Pkg.develop(; path = dirname(@__DIR__)) # point TransitionMatrices at this repo + Pkg.instantiate() + using TransitionMatrices, Plots, LinearAlgebra +end + +# ╔═╡ 7a1f0002-0000-4000-8000-000000000002 +md""" +# Near-field maps from a T-matrix + +`TransitionMatrices.jl` reconstructs the electromagnetic field *around* a particle +— not just the far-field cross sections — from any computed T-matrix. Given an +incident plane wave, the scattered field is expanded in radiating vector spherical +wave functions (VSWFs) with coefficients ``(p, q) = \mathbf{T}\,(a, b)``, where +``(a, b)`` are the plane-wave expansion coefficients. The total external field is + +```math +\mathbf{E}_\text{tot}(\mathbf r) = \mathbf{E}_\text{inc}(\mathbf r) + \mathbf{E}_\text{sca}(\mathbf r), +\qquad +\mathbf{E}_\text{sca}(\mathbf r) = \sum_{n,m} p_{mn}\mathbf{M}_{mn}(k\mathbf r) + q_{mn}\mathbf{N}_{mn}(k\mathbf r). +``` + +This works for **any** T-matrix (Mie, EBCM, IITM, Sh-matrix). Here we map the field +enhancement ``|\mathbf{E}_\text{tot}|/|\mathbf{E}_\text{inc}|`` around a dielectric +sphere. + +!!! note "Region of validity" + The radiating expansion converges only **outside the sphere circumscribing the + particle** (the Rayleigh hypothesis). For a sphere that boundary *is* the + surface, so the map is valid right down to it; for a non-spherical particle, + evaluate only outside the circumscribing sphere. +""" + +# ╔═╡ 7a1f0003-0000-4000-8000-000000000003 +md""" +## 1. A scatterer and its incidence + +A dielectric sphere of size parameter ``x = k a = 2.5`` and refractive index +``m_r = 1.5``, illuminated by an ``x``-polarized plane wave propagating along +``+z``. With ``\lambda = 2\pi`` the size parameter equals the radius, so ``a = 2.5``. + +The incidence direction is ``(\vartheta_\text{inc}, \varphi_\text{inc}) = (0, 0)``; +the polarization ``(E_\vartheta, E_\varphi) = (1, 0)`` is ``\hat{\mathbf x}`` because +``\hat{\boldsymbol\vartheta}(0,0) = \hat{\mathbf x}``. +""" + +# ╔═╡ 7a1f0004-0000-4000-8000-000000000004 +begin + λ = 2π + k = 2π / λ + a = 2.5 + mᵣ = 1.5 + 0.0im + x = k * a + N = ceil(Int, x + 4 * cbrt(x) + 2) + 𝐓 = TransitionMatrices.MieTransitionMatrix{ComplexF64, N}(x, mᵣ) + + # Incident plane wave: +z propagation, x-polarized. + ϑ_inc, φ_inc = 0.0, 0.0 + Eθ, Eφ = 1.0 + 0im, 0.0im + + # The scattered coefficients (p, q) = 𝐓 (a, b) depend only on the incidence, + # so compute them once and reuse them at every field point. + p, q = scattering_coefficients(𝐓, ϑ_inc, φ_inc, Eθ, Eφ) + nothing +end + +# ╔═╡ 7a1f0005-0000-4000-8000-000000000005 +md""" +## 2. Sample the total field on a grid + +We evaluate ``|\mathbf{E}_\text{tot}|`` in the ``x``–``z`` plane (the plane of +incidence and polarization). Points inside the sphere are masked — the external +expansion is not meant to represent the interior field (that is the *internal* +field, a separate reconstruction). +""" + +# ╔═╡ 7a1f0006-0000-4000-8000-000000000006 +begin + xs = range(-3a, 3a; length = 161) + zs = range(-3a, 3a; length = 161) + enhancement = fill(NaN, length(zs), length(xs)) + for (i, zz) in enumerate(zs), (j, xx) in enumerate(xs) + + r = hypot(xx, zz) + r ≤ a && continue # inside the sphere: masked + pos = [xx, 0.0, zz] + E_inc = incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, pos) + E = E_inc + scattered_field(p, q, λ, pos) + enhancement[i, j] = norm(E) / norm(E_inc) + end + nothing +end + +# ╔═╡ 7a1f0007-0000-4000-8000-000000000007 +md""" +## 3. The field-enhancement map + +The incident wave travels upward (``+z``). The map shows the standing-wave +interference of the incident and scattered fields, the forward-scattering +concentration on the far (``+z``) side, and the shadow on the near side. +""" + +# ╔═╡ 7a1f0008-0000-4000-8000-000000000008 +let + hm = heatmap(xs, zs, enhancement; + aspect_ratio = 1, c = :inferno, clims = (0, maximum(filter(isfinite, enhancement))), + xlabel = "x", ylabel = "z", colorbar_title = "|E| / |E₀|", + title = "dielectric sphere, x = $(round(x; digits = 2)), mᵣ = 1.5", + size = (640, 560)) + # outline the sphere + θ = range(0, 2π; length = 200) + plot!(hm, a .* cos.(θ), a .* sin.(θ); label = "", lw = 1.5, lc = :cyan) +end + +# ╔═╡ 7a1f0009-0000-4000-8000-000000000009 +md""" +## 4. A line cut along the optical axis + +Along ``x = 0`` the field shows the shadow/illumination asymmetry and the +forward enhancement directly. +""" + +# ╔═╡ 7a1f000a-0000-4000-8000-00000000000a +let + zline = range(-3a, 3a; length = 400) + amp = map(zline) do zz + abs(zz) ≤ a && return NaN + pos = [0.0, 0.0, zz] + E_inc = incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, pos) + E = E_inc + scattered_field(p, q, λ, pos) + norm(E) / norm(E_inc) + end + plot(zline, amp; lw = 2, xlabel = "z (optical axis)", ylabel = "|E| / |E₀|", + label = "", title = "axial cut (x = y = 0)", size = (720, 320)) + vspan!([-a, a]; alpha = 0.15, c = :gray, label = "sphere") +end + +# ╔═╡ 7a1f000b-0000-4000-8000-00000000000b +md""" +## Takeaway + +`scattering_coefficients` once, then `scattered_field` / `total_field` at any point +gives the full external field for any T-matrix — sphere, spheroid, cylinder, +Chebyshev, or a general particle. Reusing the precomputed `(p, q)` keeps a dense +field grid cheap. + +For the field **inside** the particle, use `internal_field`, which reconstructs +the regular internal solution from `internal_coefficients`. Those coefficients +are the VSWF expansion amplitudes for the field inside the particle; compute them +once and reuse them just like `(p, q)`: + +```julia +c, d = internal_coefficients(x, mᵣ, ϑ_inc, φ_inc, Eθ, Eφ; nmax = N) +E_inside = internal_field(c, d, mᵣ, λ, [0.2a, 0.0, 0.0]) +``` + +The Tier-2 implementations live in `src/common/near_field.jl` for homogeneous +spheres and `src/EBCM/near_field.jl` for axisymmetric particles. +""" + +# ╔═╡ Cell order: +# ╟─7a1f0002-0000-4000-8000-000000000002 +# ╠═7a1f0001-0000-4000-8000-000000000001 +# ╟─7a1f0003-0000-4000-8000-000000000003 +# ╠═7a1f0004-0000-4000-8000-000000000004 +# ╟─7a1f0005-0000-4000-8000-000000000005 +# ╠═7a1f0006-0000-4000-8000-000000000006 +# ╟─7a1f0007-0000-4000-8000-000000000007 +# ╠═7a1f0008-0000-4000-8000-000000000008 +# ╟─7a1f0009-0000-4000-8000-000000000009 +# ╠═7a1f000a-0000-4000-8000-00000000000a +# ╟─7a1f000b-0000-4000-8000-00000000000b diff --git a/examples/orientation_averaging.jl b/examples/orientation_averaging.jl index 2261ae2..c59b86e 100644 --- a/examples/orientation_averaging.jl +++ b/examples/orientation_averaging.jl @@ -58,7 +58,7 @@ begin (; method = "analytic (Mishchenko)", Qsca = calc_Csca(Tanalytic, λ), Qext = calc_Cext(Tanalytic, λ), g = asymmetry_parameter(Tanalytic, λ)), (; method = "numerical (20³ grid)", Qsca = calc_Csca(Tnumeric, λ), - Qext = calc_Cext(Tnumeric, λ), g = asymmetry_parameter(Tnumeric, λ)), + Qext = calc_Cext(Tnumeric, λ), g = asymmetry_parameter(Tnumeric, λ)) ] end @@ -74,7 +74,8 @@ the analytic closed form gives it directly, at a fraction of the cost. begin Qref = calc_Csca(Tanalytic, λ) Ns = 4:2:18 - errs = [abs(calc_Csca(orientation_average(T, uniform; Nα = 2, Nβ = N, Nγ = 2), λ) - Qref) / Qref + errs = [abs(calc_Csca(orientation_average(T, uniform; Nα = 2, Nβ = N, Nγ = 2), λ) - + Qref) / Qref for N in Ns] nothing end diff --git a/examples/radiative_transfer/generate_fake_wrf.jl b/examples/radiative_transfer/generate_fake_wrf.jl index 357cb65..dd08cdd 100644 --- a/examples/radiative_transfer/generate_fake_wrf.jl +++ b/examples/radiative_transfer/generate_fake_wrf.jl @@ -77,6 +77,7 @@ function synthetic_fields(nx, ny, nz, nt) for t in 1:nt for j in 1:ny, i in 1:nx + xlat[i, j, t] = 34.0f0 + 0.04f0 * Float32(j - 1) xlon[i, j, t] = -98.0f0 + 0.04f0 * Float32(i - 1) end diff --git a/examples/radiative_transfer/wrf_dualpol_radar.jl b/examples/radiative_transfer/wrf_dualpol_radar.jl index 83729ba..6635ed7 100644 --- a/examples/radiative_transfer/wrf_dualpol_radar.jl +++ b/examples/radiative_transfer/wrf_dualpol_radar.jl @@ -95,8 +95,8 @@ function water_refractive_index(freq_hz, T_celsius) λ_m = C0_M_PER_S / freq_hz ε0 = 8.854e-12 εs = 78.54 * (1 - 4.579e-3 * (T_celsius - 25) + - 1.19e-5 * (T_celsius - 25)^2 - - 2.8e-8 * (T_celsius - 25)^3) + 1.19e-5 * (T_celsius - 25)^2 - + 2.8e-8 * (T_celsius - 25)^3) ε∞ = 5.27137 + 2.16474e-2 * T_celsius - 1.31198e-3 * T_celsius^2 α = -16.8129 / (T_celsius + 273) + 6.09265e-2 λs = 3.3836e-6 * exp(2513.98 / (T_celsius + 273)) @@ -114,9 +114,9 @@ end function rain_axis_ratio(D_mm) D_mm < 0.7 && return 1.0 D_mm < 1.5 && return 1.173 - 0.5265D_mm + 0.4698D_mm^2 - - 0.1317D_mm^3 - 8.5e-3D_mm^4 + 0.1317D_mm^3 - 8.5e-3D_mm^4 1.065 - 6.25e-2D_mm - 3.99e-3D_mm^2 + - 7.66e-4D_mm^3 - 4.095e-5D_mm^4 + 7.66e-4D_mm^3 - 4.095e-5D_mm^4 end function scattering_table(Ds, freq_hz; temperature_celsius = 0.0, @@ -269,11 +269,11 @@ function radar_moments(table, freq_hz, N0, Λ) Zhh = hh * λ^4 / (π^5 * Kw2) Zvv = vv * λ^4 / (π^5 * Kw2) cov = sum(conj(row.Shh_back) * row.Svv_back * weights[i] - for (i, row) in pairs(table)) + for (i, row) in pairs(table)) ρhv = abs(cov) / sqrt(hh * vv) kdp = 180 / π * 1e-3 * λ * sum(real(row.Shh_forward - row.Svv_forward) * weights[i] - for (i, row) in pairs(table)) + for (i, row) in pairs(table)) (; ZH = 10log10(Zhh), ZV = 10log10(Zvv), diff --git a/examples/rain_radar.jl b/examples/rain_radar.jl index e145990..cbdc1ae 100644 --- a/examples/rain_radar.jl +++ b/examples/rain_radar.jl @@ -48,8 +48,8 @@ begin λ_m = c0_m_per_s() / freq_hz ε0 = 8.854e-12 εs = 78.54 * (1 - 4.579e-3 * (T_celsius - 25) + - 1.19e-5 * (T_celsius - 25)^2 - - 2.8e-8 * (T_celsius - 25)^3) + 1.19e-5 * (T_celsius - 25)^2 - + 2.8e-8 * (T_celsius - 25)^3) ε∞ = 5.27137 + 2.16474e-2 * T_celsius - 1.31198e-3 * T_celsius^2 α = -16.8129 / (T_celsius + 273) + 6.09265e-2 λs = 3.3836e-6 * exp(2513.98 / (T_celsius + 273)) @@ -85,16 +85,15 @@ begin function rain_axis_ratio(D_mm) D_mm < 0.7 && return 1.0 D_mm < 1.5 && return 1.173 - 0.5265D_mm + 0.4698D_mm^2 - - 0.1317D_mm^3 - 8.5e-3D_mm^4 + 0.1317D_mm^3 - 8.5e-3D_mm^4 1.065 - 6.25e-2D_mm - 3.99e-3D_mm^2 + - 7.66e-4D_mm^3 - 4.095e-5D_mm^4 + 7.66e-4D_mm^3 - 4.095e-5D_mm^4 end end # ╔═╡ 07e1ca0a-3bf8-45a9-ae4d-8c51971c6f57 begin - round_complex(z; digits = 4) = - complex(round(real(z); digits), round(imag(z); digits)) + round_complex(z; digits = 4) = complex(round(real(z); digits), round(imag(z); digits)) material_summary = [ (; material = "water", freq_GHz = 9.375, T = 0.0, @@ -149,12 +148,10 @@ begin solver = IITM(6, 10, 16) table = scattering_table(Ds, freq_hz; temperature_celsius, solver) - table_summary = [ - (; D = row.D_mm, - axis_ratio = round(row.axis_ratio; digits = 3), - Cext = round(row.Cext_mm2; digits = 4)) - for row in table - ] + table_summary = [(; D = row.D_mm, + axis_ratio = round(row.axis_ratio; digits = 3), + Cext = round(row.Cext_mm2; digits = 4)) + for row in table] end # ╔═╡ aaef0a83-7657-4c40-a875-bc7f18a7b42e @@ -172,8 +169,9 @@ algorithm. # ╔═╡ d78d1d83-508b-4521-ae44-9068d15f4d14 begin - drop_distribution(D_mm, rain_rate_mm_h) = + function drop_distribution(D_mm, rain_rate_mm_h) rain_rate_mm_h == 0 ? 0.0 : 8.0e3 * exp(-4.1 * rain_rate_mm_h^(-0.21) * D_mm) + end function dualpol_moments(table, freq_hz, rain_rate_mm_h; temperature_celsius = 0.0) @@ -181,21 +179,19 @@ begin ΔD = spacing(table) m = water_refractive_index(freq_hz, temperature_celsius) Kw2 = abs2((m^2 - 1) / (m^2 + 2)) - weights = [ - drop_distribution(row.D_mm, rain_rate_mm_h) * ΔD - for row in table - ] + weights = [drop_distribution(row.D_mm, rain_rate_mm_h) * ΔD + for row in table] hh = sum(abs2(row.Shh_back) * weights[i] for (i, row) in pairs(table)) vv = sum(abs2(row.Svv_back) * weights[i] for (i, row) in pairs(table)) Zhh = hh * λ^4 / (π^5 * Kw2) Zvv = vv * λ^4 / (π^5 * Kw2) cov = sum(conj(row.Shh_back) * row.Svv_back * weights[i] - for (i, row) in pairs(table)) + for (i, row) in pairs(table)) ρhv = abs(cov) / sqrt(hh * vv) kdp = 180 / π * 1e-3 * λ * sum(real(row.Shh_forward - row.Svv_forward) * weights[i] - for (i, row) in pairs(table)) + for (i, row) in pairs(table)) (; R = rain_rate_mm_h, ZH = round(10log10(Zhh); digits = 2), @@ -217,14 +213,10 @@ end # ╔═╡ 383e1078-e216-4810-b83d-cd7a6590749b begin rain_rates = [1.0, 10.0, 50.0] - radar = [ - dualpol_moments(table, freq_hz, R; temperature_celsius) - for R in rain_rates - ] - tb = [ - (; R, TB = round(brightness_temperature(table, R); digits = 4)) - for R in rain_rates - ] + radar = [dualpol_moments(table, freq_hz, R; temperature_celsius) + for R in rain_rates] + tb = [(; R, TB = round(brightness_temperature(table, R); digits = 4)) + for R in rain_rates] (; radar, tb) end @@ -246,9 +238,11 @@ begin for f in frequencies_hz local_table = scattering_table(Ds, f; temperature_celsius, solver) for R in rain_rates - push!(rows, (; freq_GHz = round(f / 1e9; digits = 3), R, - TB = round(brightness_temperature(local_table, R; - path_km, Tatm_K); digits = 4))) + push!(rows, + (; freq_GHz = round(f / 1e9; digits = 3), R, + TB = round( + brightness_temperature(local_table, R; + path_km, Tatm_K); digits = 4))) end end rows diff --git a/examples/shapes_gallery.jl b/examples/shapes_gallery.jl index f0924ce..9455054 100644 --- a/examples/shapes_gallery.jl +++ b/examples/shapes_gallery.jl @@ -40,10 +40,10 @@ begin λ = 2π m = 1.5 + 0.02im shapes = ( - spheroid = TransitionMatrices.Spheroid{Float64, ComplexF64}(2.0, 1.0, m), - cylinder = TransitionMatrices.Cylinder{Float64, ComplexF64}(1.0, 2.0, m), + spheroid = TransitionMatrices.Spheroid{Float64, ComplexF64}(2.0, 1.0, m), + cylinder = TransitionMatrices.Cylinder{Float64, ComplexF64}(1.0, 2.0, m), chebyshev = TransitionMatrices.Chebyshev{Float64, ComplexF64}(1.0, 0.1, 4, m), - prism6 = TransitionMatrices.Prism(6, 1.0, 2.0, m), + prism6 = TransitionMatrices.Prism(6, 1.0, 2.0, m) ) end @@ -57,8 +57,10 @@ IITM solve. Pluto renders the resulting vector of named tuples as a table. # ╔═╡ 2173874a-6082-11f1-8f4b-f3cee77b5fae begin - Tmatrix(s) = s isa TransitionMatrices.Prism ? + function Tmatrix(s) + s isa TransitionMatrices.Prism ? transition_matrix(s, λ, IITM(8, 12, 24, 24)) : calc_T(s, λ) + end summary = map(collect(pairs(shapes))) do (name, s) T = Tmatrix(s) diff --git a/examples/solver_landscape.jl b/examples/solver_landscape.jl index cfc8266..cb2933d 100644 --- a/examples/solver_landscape.jl +++ b/examples/solver_landscape.jl @@ -39,12 +39,16 @@ Three routes to the same cross section: # ╔═╡ 246053c2-6082-11f1-9425-cd50e8e63bdd begin λ = 2π - prolate = TransitionMatrices.Spheroid{Float64, ComplexF64}(2.5198421, 10.079368, 1.55 + 0.01im) + prolate = TransitionMatrices.Spheroid{Float64, ComplexF64}(2.5198421, 10.079368, 1.55 + + 0.01im) routes = [ - (; route = "EBCM fixed (n=24)", Csca = calc_Csca(transition_matrix(prolate, λ, EBCM(24, 384)), λ)), - (; route = "Iterative(EBCM; stable)", Csca = calc_Csca(transition_matrix(prolate, λ, Iterative(EBCM; stable = true)), λ)), - (; route = "IITM", Csca = calc_Csca(transition_matrix(prolate, λ, IITM(24, 30, 60)), λ)), + (; route = "EBCM fixed (n=24)", + Csca = calc_Csca(transition_matrix(prolate, λ, EBCM(24, 384)), λ)), + (; route = "Iterative(EBCM; stable)", + Csca = calc_Csca(transition_matrix(prolate, λ, Iterative(EBCM; stable = true)), λ)), + (; route = "IITM", + Csca = calc_Csca(transition_matrix(prolate, λ, IITM(24, 30, 60)), λ)) ] end @@ -60,8 +64,10 @@ classic EBCM vs the `stable = true` EBCM. Watch the classic curve blow up. begin ref = calc_Csca(transition_matrix(prolate, λ, EBCM(40, 600; stable = true)), λ) ns = 16:2:40 - classic_err = [abs(calc_Csca(transition_matrix(prolate, λ, EBCM(n, 16n)), λ) - ref) / ref for n in ns] - stable_err = [abs(calc_Csca(transition_matrix(prolate, λ, EBCM(n, 16n; stable = true)), λ) - ref) / ref for n in ns] + classic_err = [abs(calc_Csca(transition_matrix(prolate, λ, EBCM(n, 16n)), λ) - ref) / + ref for n in ns] + stable_err = [abs(calc_Csca(transition_matrix(prolate, λ, EBCM(n, 16n; stable = true)), λ) - + ref) / ref for n in ns] nothing end diff --git a/examples/spectral_sensitivity.jl b/examples/spectral_sensitivity.jl index 2bd8d28..ab494ad 100644 --- a/examples/spectral_sensitivity.jl +++ b/examples/spectral_sensitivity.jl @@ -116,7 +116,8 @@ begin f_sh(λ) = ForwardDiff.derivative(l -> Csca(l, mᵣ), λ) f_classic(λ) = ForwardDiff.derivative(l -> Csca_classic(l, mᵣ), λ) - f_sh(λs[1]); f_classic(λs[1]) # warm up + f_sh(λs[1]); + f_classic(λs[1]) # warm up t_prepare = @belapsed prepare_sh($spheroid, $nmax, $Ng) samples=1 evals=1 t_sh = @belapsed [f_sh(λ) for λ in $λs] samples=1 evals=1 diff --git a/src/EBCM/index.jl b/src/EBCM/index.jl index 2fc6449..ed3e1f3 100644 --- a/src/EBCM/index.jl +++ b/src/EBCM/index.jl @@ -3,3 +3,4 @@ include("stabilization.jl") include("shmatrix.jl") include("routines.jl") include("linearization.jl") +include("near_field.jl") diff --git a/src/EBCM/near_field.jl b/src/EBCM/near_field.jl new file mode 100644 index 0000000..579e811 --- /dev/null +++ b/src/EBCM/near_field.jl @@ -0,0 +1,147 @@ +# ── Internal field (Tier 2): general axisymmetric particle (EBCM) ───────────── +# +# Extends `internal_coefficients` / `internal_field` (defined for the homogeneous +# sphere in `common/near_field.jl`) to a general axisymmetric particle. The +# interior coefficients come from the EBCM matrices rather than an analytic +# formula. With the package's 𝐐 = 𝐏 + i𝐔 (so 𝐓 = -𝐏 𝐐⁻¹) the Waterman relation +# gives the internal coefficients as 𝐐⁻¹ 𝐚 per azimuthal block; an overall factor +# ½ — universal across size and refractive index, fixed by matching the analytic +# Mie internal field on a sphere to machine precision — converts to this package's +# VSWF normalization: +# +# 𝐜⁽ᵐ⁾ = ½ 𝐐⁽ᵐ⁾⁻¹ 𝐚⁽ᵐ⁾ (degree +m). +# +# The −m block follows the same ±m symmetry the T-matrix uses (see +# `AxisymmetricTransitionMatrix`): the M↔N cross blocks flip sign, so +# 𝐜⁽⁻ᵐ⁾ = 𝐃 (½ 𝐐⁽ᵐ⁾⁻¹) 𝐃 𝐚⁽⁻ᵐ⁾ with 𝐃 = diag(+𝐈_M, -𝐈_N). Each block stacks the +# M (p=1) coefficients above the N (p=2) coefficients over n = max(1,m):nmax. +# +# VALIDITY. The regular-VSWF interior expansion is mathematically guaranteed to +# converge to the true field at least within the inscribed sphere (radius +# `rmin(shape)`). Empirically (tested) it does NOT diverge outside it: the +# reconstruction stays numerically stable and physical throughout the interior — +# tips included, well beyond the inscribed sphere — as long as the underlying EBCM +# build is well conditioned. The real failure mode is EBCM 𝐐-matrix +# ill-conditioning at high aspect ratio with high nmax (the same Float64 +# cancellation the F⁺ `stable` path fixes for the T-matrix): since 𝐜 = ½ 𝐐⁻¹ 𝐚 +# inherits 𝐐⁻¹, a poorly-conditioned 𝐐 corrupts the coefficients EVERYWHERE +# (including the core), not just near the surface. Keep aspect×nmax in the regime +# where the T-matrix itself is reliable. +# +# Absolute accuracy beyond the inscribed sphere is not independently validated for +# non-spherical shapes (there is no self-contained surface check — tangential +# boundary continuity would need both interior and exterior expansions to converge +# at the surface, which the Rayleigh hypothesis forbids — and no built-in reference +# method). The convention (the ½ factor and the ±m symmetry) is pinned to machine +# precision by the degenerate-sphere (a = c) comparison against the analytic Mie +# internal field. + +@doc raw""" +``` +internal_coefficients(shape::AbstractAxisymmetricShape, λ, nmax, Ng, ϑ_inc, φ_inc, Eθ, Eφ) -> (c, d) +``` + +Internal-field expansion coefficients `(cₘₙ, dₘₙ)` for a general axisymmetric +particle, obtained from the EBCM matrices: per azimuthal block ``\mathbf{c} = +\tfrac{1}{2}\mathbf{Q}^{-1}\mathbf{a}`` with ``\mathbf{Q} = \mathbf{P} + +\mathrm{i}\mathbf{U}``, under an incident plane wave (`(ϑ_inc, φ_inc)` propagation, +Jones polarization `(Eθ, Eφ)`). Returns `OffsetArray`s indexed `[m, n]`; reuse them +across field points with [`internal_field`](@ref). + +The interior expansion is guaranteed within the inscribed sphere (radius +`rmin(shape)`) and is empirically stable throughout the interior; the practical +limit is EBCM conditioning at high aspect ratio with high `nmax` (keep +`nmax`/aspect where the T-matrix itself is reliable). See [`internal_field`](@ref). +""" +function internal_coefficients(shape::AbstractAxisymmetricShape{T, CT}, λ, nmax::Integer, + Ng::Integer, ϑ_inc, φ_inc, Eθ, Eφ) where {T, CT} + Tr = real(CT) + a, b = _plane_wave_coefficients(nmax, Tr(ϑ_inc), Tr(φ_inc), CT(Eθ), CT(Eφ)) + c = OffsetArray(zeros(CT, 2nmax + 1, nmax), (-nmax):nmax, 1:nmax) + d = OffsetArray(zeros(CT, 2nmax + 1, nmax), (-nmax):nmax, 1:nmax) + for mₐ in 0:nmax + 𝐏, + 𝐔 = mₐ == 0 ? ebcm_matrices_m₀(shape, λ, nmax, Ng) : + ebcm_matrices_m(mₐ, shape, λ, nmax, Ng) + 𝐐 = 𝐏 .+ im .* 𝐔 + ns = max(1, mₐ):nmax + nn = length(ns) + avec = CT[[a[mₐ, n] for n in ns]; [b[mₐ, n] for n in ns]] + cvec = (𝐐 \ avec) ./ 2 + for (i, n) in enumerate(ns) + c[mₐ, n] = cvec[i] + d[mₐ, n] = cvec[nn + i] + end + if mₐ > 0 + 𝐃 = CT[ones(CT, nn); -ones(CT, nn)] + amvec = CT[[a[-mₐ, n] for n in ns]; [b[-mₐ, n] for n in ns]] + cmvec = (𝐃 .* (𝐐 \ (𝐃 .* amvec))) ./ 2 + for (i, n) in enumerate(ns) + c[-mₐ, n] = cmvec[i] + d[-mₐ, n] = cmvec[nn + i] + end + end + end + return c, d +end + +@doc raw""" +``` +internal_field(shape::AbstractAxisymmetricShape, λ, nmax, Ng, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) -> SVector{3} +``` + +Internal electric field inside a general axisymmetric particle at the Cartesian +point `r⃗`, via the EBCM internal coefficients (see [`internal_coefficients`](@ref)). +The relative refractive index is taken from `shape`, and `r⃗` should lie inside the +particle. Convergence is guaranteed within the inscribed sphere (radius +`rmin(shape)`) and is empirically stable throughout the interior; the binding +constraint is EBCM conditioning at high aspect × `nmax`, not the inscribed-sphere +radius. +""" +function internal_field(shape::AbstractAxisymmetricShape, λ, nmax::Integer, Ng::Integer, + ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + c, d = internal_coefficients(shape, λ, nmax, Ng, ϑ_inc, φ_inc, Eθ, Eφ) + return internal_field(c, d, shape.m, λ, r⃗) +end + +@testitem "EBCM internal field matches Mie on a sphere (degenerate spheroid)" begin + using TransitionMatrices: Spheroid, internal_field + + # A sphere is a spheroid with a = c. Its EBCM internal field must reproduce the + # analytic Mie internal field — this locks the EBCM internal-coefficient + # convention (the ½ factor and the ±m symmetry) to machine precision. + λ = 2π + k0 = 2π / λ + @testset "R=$R, mᵣ=$mᵣ" for (R, mᵣ) in ((2.0, 1.5 + 0.05im), (1.5, 1.33 + 0.0im)) + x = k0 * R + nmax = ceil(Int, x + 4cbrt(x) + 2) + Ng = 200 + sph = Spheroid{Float64, ComplexF64}(R, R, mᵣ) + worst = 0.0 + for ϑ in (0.4, 1.1, 2.0), φ in (0.0, 1.3, 4.0), rr in (0.3R, 0.6R, 0.85R) + sϑ, cϑ, sφ, cφ = sin(ϑ), cos(ϑ), sin(φ), cos(φ) + pos = [rr * sϑ * cφ, rr * sϑ * sφ, rr * cϑ] + Eebcm = internal_field(sph, λ, nmax, Ng, 0.0, 0.0, 1.0 + 0im, 0.0im, pos) + Emie = internal_field(x, mᵣ, λ, 0.0, 0.0, 1.0 + 0im, 0.0im, pos; nmax) + worst = max(worst, maximum(abs, Eebcm - Emie) / maximum(abs, Emie)) + end + @test worst < 1e-8 + end +end + +@testitem "EBCM internal field reconstructs inside a spheroid (runs, finite)" begin + using TransitionMatrices: Spheroid, internal_coefficients, internal_field + + # Non-spherical: no self-contained reference, but the coefficients/field must be + # finite and the two-step and one-shot forms must agree, inside the inscribed + # sphere (radius = min semi-axis). + λ = 2π + sph = Spheroid{Float64, ComplexF64}(1.0, 2.0, 1.4 + 0.02im) # prolate, rmin = 1 + nmax, Ng = 10, 200 + c, d = internal_coefficients(sph, λ, nmax, Ng, 0.3, 0.5, 1.0 + 0im, 0.0im) + pos = [0.4, 0.2, 0.5] # |pos| ≈ 0.67 < inscribed radius 1 + E1 = internal_field(c, d, sph.m, λ, pos) + E2 = internal_field(sph, λ, nmax, Ng, 0.3, 0.5, 1.0 + 0im, 0.0im, pos) + @test all(isfinite, abs.(E1)) + @test maximum(abs, E1 - E2) < 1e-12 +end diff --git a/src/TransitionMatrices.jl b/src/TransitionMatrices.jl index 6af0e58..11fc0c7 100644 --- a/src/TransitionMatrices.jl +++ b/src/TransitionMatrices.jl @@ -40,6 +40,10 @@ export AbstractLinearizationBackend, MieLinearization, EBCMLinearization, IITMLi UnsupportedLinearization, variables, rebuild, derivative, supports_linearization, linearize_transition_matrix, linearize_observable +# Near-field reconstruction +export vswf, vswf_cartesian, scattering_coefficients, incident_field, scattered_field, + total_field, internal_coefficients, internal_field + # Utility functions export OrderDegreeIterator, rotate, amplitude_matrix, phase_matrix, scattering_matrix, expansion_coefficients, diff --git a/src/common/index.jl b/src/common/index.jl index ba5adc2..33e9529 100644 --- a/src/common/index.jl +++ b/src/common/index.jl @@ -1,4 +1,6 @@ include("AbstractTransitionMatrix.jl") include("AxisymmetricTransitionMatrix.jl") include("RandomOrientationTransitionMatrix.jl") +include("vswf.jl") +include("near_field.jl") include("utils.jl") diff --git a/src/common/near_field.jl b/src/common/near_field.jl new file mode 100644 index 0000000..683450d --- /dev/null +++ b/src/common/near_field.jl @@ -0,0 +1,477 @@ +# ── Near-field reconstruction (Tier 1: external field) ──────────────────────── +# +# Given a T-matrix `𝐓` and an incident plane wave, reconstruct the electric field +# at points OUTSIDE the circumscribing sphere of the particle: the incident field +# (analytic plane wave), the scattered field, and their sum (the total field). +# +# The incident plane wave propagates along the direction `n̂ = (ϑ_inc, φ_inc)` and +# carries a polarization given as a Jones vector `(Eθ, Eφ)` in the spherical basis +# `(𝛝̂, 𝛗̂)` evaluated at `n̂`: +# +# 𝐄_inc(𝐫) = (Eθ 𝛝̂ + Eφ 𝛗̂) exp(i k n̂·𝐫). +# +# It is expanded in regular VSWFs (`vswf(:regular, …)`) +# 𝐄_inc(𝐫) = Σ_{n,m} [ aₘₙ RgMₘₙ(k𝐫) + bₘₙ RgNₘₙ(k𝐫) ], +# with coefficients (locked numerically against the analytic plane wave to machine +# precision, Mishchenko convention; γₘₙ as in `vswf`): +# aₘₙ = 4π iⁿ γₘₙ e^{-imφ_inc} ( −i πₘₙ(ϑ_inc) Eθ − τₘₙ(ϑ_inc) Eφ ), +# bₘₙ = 4π iⁿ⁻¹ γₘₙ e^{-imφ_inc} ( τₘₙ(ϑ_inc) Eθ − i πₘₙ(ϑ_inc) Eφ ). +# +# The scattered field uses the radiating VSWFs and the scattered coefficients +# (p,q) = 𝐓 (a,b): +# 𝐄_sca(𝐫) = Σ_{n,m} [ pₘₙ Mₘₙ(k𝐫) + qₘₙ Nₘₙ(k𝐫) ], +# pₘₙ = Σ_{n′m′} 𝐓₁₁ aₘ′ₙ′ + 𝐓₁₂ bₘ′ₙ′, qₘₙ = Σ_{n′m′} 𝐓₂₁ aₘ′ₙ′ + 𝐓₂₂ bₘ′ₙ′. +# +# Validity: the radiating expansion converges only OUTSIDE the smallest sphere +# circumscribing the particle (the Rayleigh hypothesis). Inside that sphere the +# scattered series may diverge; callers are responsible for evaluating at r larger +# than the circumscribing radius. + +@inline _dot3(a, b) = a[1] * b[1] + a[2] * b[2] + a[3] * b[3] + +@inline function _cart_to_sph(r⃗) + x, y, z = r⃗[1], r⃗[2], r⃗[3] + r = sqrt(x^2 + y^2 + z^2) + ϑ = acos(clamp(z / r, -one(r), one(r))) + φ = atan(y, x) + return r, ϑ, φ +end + +# Plane-wave expansion coefficients aₘₙ, bₘₙ (degree m, order n), as OffsetArrays +# indexed `[m, n]`, m ∈ -N:N, n ∈ 1:N. +function _plane_wave_coefficients(N::Integer, ϑ::T, φ::T, Eθ::Complex{T}, + Eφ::Complex{T}) where {T} + a = OffsetArray(zeros(Complex{T}, 2N + 1, N), (-N):N, 1:N) + b = OffsetArray(zeros(Complex{T}, 2N + 1, N), (-N):N, 1:N) + for m in (-N):N + dvec, τvec = wigner_d_recursion(T, 0, m, N, ϑ; deriv = true) + for n in max(1, abs(m)):N + P = dvec[n] + τ = τvec[n] + π_ = pi_func(T, m, n, ϑ; d = P) + γ = (-1)^(m & 1) * sqrt(T(2n + 1) / (4 * T(π) * n * (n + 1))) + pref = 4 * T(π) * cis(-m * φ) * γ + a[m, n] = pref * im^(n & 3) * (-im * π_ * Eθ - τ * Eφ) + b[m, n] = pref * im^((n - 1) & 3) * (τ * Eθ - im * π_ * Eφ) + end + end + return a, b +end + +@doc raw""" +``` +scattering_coefficients(𝐓, ϑ_inc, φ_inc, Eθ, Eφ) -> (p, q) +``` + +Scattered-field expansion coefficients `(pₘₙ, qₘₙ)` for the T-matrix `𝐓` under an +incident plane wave propagating along `(ϑ_inc, φ_inc)` with Jones polarization +`(Eθ, Eφ)` in the spherical basis at the incidence direction. Returns two +`OffsetArray`s indexed `[m, n]` (m ∈ -N:N, n ∈ 1:N), where `(p, q) = 𝐓 (a, b)` and +`(a, b)` are the incident plane-wave coefficients. Reuse these across many field +points (see [`scattered_field`](@ref)). +""" +function scattering_coefficients(𝐓::AbstractTransitionMatrix{CT, N}, ϑ_inc, φ_inc, + Eθ, Eφ) where {CT, N} + T = real(CT) + a, b = _plane_wave_coefficients(N, T(ϑ_inc), T(φ_inc), CT(Eθ), CT(Eφ)) + p = OffsetArray(zeros(CT, 2N + 1, N), (-N):N, 1:N) + q = OffsetArray(zeros(CT, 2N + 1, N), (-N):N, 1:N) + for n in 1:N, m in (-n):n + + s1 = zero(CT) + s2 = zero(CT) + for n′ in 1:N, m′ in (-n′):n′ + + aₘ′ₙ′ = a[m′, n′] + bₘ′ₙ′ = b[m′, n′] + s1 += 𝐓[m, n, m′, n′, 1, 1] * aₘ′ₙ′ + 𝐓[m, n, m′, n′, 1, 2] * bₘ′ₙ′ + s2 += 𝐓[m, n, m′, n′, 2, 1] * aₘ′ₙ′ + 𝐓[m, n, m′, n′, 2, 2] * bₘ′ₙ′ + end + p[m, n] = s1 + q[m, n] = s2 + end + return p, q +end + +# Sum Σ c₁ₘₙ Fₘₙ + c₂ₘₙ Gₘₙ at one point, where (F,G)=(RgM,RgN) for kind=:regular +# or (M,N) for kind=:outgoing. Radial factors computed once; angular per m. The +# wavenumber `k` may be COMPLEX (the internal field uses k_int = mᵣ·k); the +# geometry (r, ϑ, φ) and the angular functions stay real, only the radial +# argument x = k·r and the resulting field become complex. +function _field_from_coeffs(c₁, c₂, kind::Symbol, k::Number, r⃗, N::Integer) + T = float(real(promote_type(typeof(k), eltype(r⃗)))) + r⃗T = SVector{3, T}(r⃗[1], r⃗[2], r⃗[3]) + r = sqrt(r⃗T[1]^2 + r⃗T[2]^2 + r⃗T[3]^2) + x = k * r + if _is_regular_origin(kind, x, T) + CT = complex(T) + E = zero(SVector{3, CT}) + N ≥ 1 || return E + for m in -1:1 + _, 𝐆 = _regular_vswf_origin_limit(T, m, 1) + E += c₂[m, 1] * 𝐆 + end + return E + end + _, ϑ, φ = _cart_to_sph(r⃗T) + zₙ, xzₙ′ = _vswf_radial(kind, N, x) + r̂, ϑ̂, φ̂ = _sph_unit_vectors(ϑ, φ) + + CT = complex(T) + E = zero(SVector{3, CT}) + for m in (-N):N + dvec, τvec = wigner_d_recursion(T, 0, m, N, ϑ; deriv = true) + eⁱᵐᵠ = cis(m * φ) + for n in max(1, abs(m)):N + P = dvec[n] + τ = τvec[n] + π_ = pi_func(T, m, n, ϑ; d = P) + γ = (-1)^(m & 1) * sqrt(T(2n + 1) / (4 * T(π) * n * (n + 1))) + pref = γ * eⁱᵐᵠ + zn = zₙ[n] + dzn = xzₙ′[n] + znx = zn / x + 𝐅 = pref * zn * (im * π_ .* ϑ̂ .- τ .* φ̂) + 𝐆 = pref * + (n * (n + 1) * znx * P .* r̂ .+ dzn .* (τ .* ϑ̂ .+ im * π_ .* φ̂)) + E += c₁[m, n] * 𝐅 + c₂[m, n] * 𝐆 + end + end + return E +end + +@doc raw""" +``` +incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) -> SVector{3} +``` + +The incident plane wave ``(E_\theta\hat{\boldsymbol\vartheta} + E_\varphi\hat{\boldsymbol\varphi})\exp(\mathrm{i}k\,\hat{\mathbf n}\cdot\mathbf r)`` +evaluated at the Cartesian point `r⃗`, propagating along `n̂ = (ϑ_inc, φ_inc)` with +`k = 2π/λ`. Polarization `(Eθ, Eφ)` is given in the spherical basis at `n̂`. +""" +function incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + T = float(promote_type(typeof(λ), typeof(ϑ_inc), typeof(φ_inc), eltype(r⃗))) + k = 2 * T(π) / T(λ) + sϑ, cϑ = sincos(T(ϑ_inc)) + sφ, cφ = sincos(T(φ_inc)) + n̂ = SVector(sϑ * cφ, sϑ * sφ, cϑ) + ϑ̂ = SVector(cϑ * cφ, cϑ * sφ, -sϑ) + φ̂ = SVector(-sφ, cφ, zero(T)) + return (Complex{T}(Eθ) .* ϑ̂ .+ Complex{T}(Eφ) .* φ̂) .* cis(k * _dot3(n̂, r⃗)) +end + +@doc raw""" +``` +scattered_field(p, q, λ, r⃗) -> SVector{3} +scattered_field(𝐓, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) -> SVector{3} +``` + +Scattered electric field at the Cartesian point `r⃗`, reconstructed from the +radiating VSWFs. The first form reuses precomputed coefficients `(p, q)` from +[`scattering_coefficients`](@ref) (efficient for evaluating many points); the +second computes them on the fly for the T-matrix `𝐓` and incidence. + +The point `r⃗` must lie OUTSIDE the sphere circumscribing the particle (the +radiating series diverges inside it). +""" +function scattered_field(p::OffsetArray, q::OffsetArray, λ, r⃗) + N = size(p, 2) + Tr = real(eltype(p)) + k = 2 * Tr(π) / λ + return _field_from_coeffs(p, q, :outgoing, k, r⃗, N) +end + +function scattered_field(𝐓::AbstractTransitionMatrix, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + p, q = scattering_coefficients(𝐓, ϑ_inc, φ_inc, Eθ, Eφ) + return scattered_field(p, q, λ, r⃗) +end + +@doc raw""" +``` +total_field(𝐓, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) -> SVector{3} +``` + +Total external field `𝐄_inc(r⃗) + 𝐄_sca(r⃗)` at the Cartesian point `r⃗` for the +T-matrix `𝐓` under the incident plane wave (see [`incident_field`](@ref) and +[`scattered_field`](@ref)). +""" +function total_field(𝐓::AbstractTransitionMatrix, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + return incident_field(λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) + + scattered_field(𝐓, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗) +end + +# ── Internal field (Tier 2): homogeneous sphere (Mie) ───────────────────────── +# +# The field INSIDE the particle is expanded in regular VSWFs at the internal +# wavenumber k_int = mᵣ·k: +# 𝐄_int(𝐫) = Σ_{n,m} c_{mn} RgM_{mn}(k_int 𝐫) + d_{mn} RgN_{mn}(k_int 𝐫). +# Unlike the scattered field, the internal coefficients are NOT determined by the +# T-matrix alone (which maps incident → scattered); they need the method's +# interior solution. For a homogeneous sphere they are the analytic Mie internal +# coefficients (Bohren & Huffman 1983, Eqs. 4.52–4.53), per order n: +# cₙ = mᵣ(ψₙ(x)ξₙ′(x) − ξₙ(x)ψₙ′(x)) / (ψₙ(mᵣx)ξₙ′(x) − mᵣ ξₙ(x)ψₙ′(mᵣx)) (M / magnetic) +# dₙ = mᵣ(ψₙ(x)ξₙ′(x) − ξₙ(x)ψₙ′(x)) / (mᵣ ψₙ(mᵣx)ξₙ′(x) − ξₙ(x)ψₙ′(mᵣx)) (N / electric) +# with ξₙ = ψₙ + iχₙ the package's outgoing Riccati–Bessel. The internal degree-m +# coefficients then ride on the incident ones, cₘₙ = cₙ aₘₙ, dₘₙ = dₙ bₘₙ. +# Validated by Maxwell boundary continuity (tangential 𝐄 and 𝐇 continuous across +# the surface) against the already-validated external field. EBCM/IITM interior +# fields (needing the retained 𝐐 matrix) are a separate, deferred extension. +function _mie_internal_coefficients(x::Real, mᵣ::Number, nmax::Integer) + T = float(real(typeof(float(x)))) + CT = complex(T) + xT = T(x) + m = CT(mᵣ) + ψx, ψx′ = ricattibesselj(nmax, estimate_ricattibesselj_extra_terms(nmax, xT), xT) + χx, χx′ = ricattibessely(nmax, xT) + mx = m * xT + ψm, ψm′ = ricattibesselj(nmax, estimate_ricattibesselj_extra_terms(nmax, mx), mx) + c = Vector{CT}(undef, nmax) + d = Vector{CT}(undef, nmax) + for n in 1:nmax + ξ = ψx[n] + im * χx[n] + ξ′ = ψx′[n] + im * χx′[n] + Dₐ = m * ψm[n] * ξ′ - ξ * ψm′[n] + D_b = ψm[n] * ξ′ - m * ξ * ψm′[n] + num = m * (ψx[n] * ξ′ - ξ * ψx′[n]) + c[n] = num / D_b + d[n] = num / Dₐ + end + return c, d +end + +@doc raw""" +``` +internal_coefficients(x, mᵣ, ϑ_inc, φ_inc, Eθ, Eφ; nmax) -> (c, d) +``` + +Internal-field expansion coefficients `(cₘₙ, dₘₙ)` for a **homogeneous sphere** of +size parameter `x = k a` and relative refractive index `mᵣ`, under an incident +plane wave (`(ϑ_inc, φ_inc)` propagation, Jones polarization `(Eθ, Eφ)` in the +spherical basis at the incidence direction). Returns two `OffsetArray`s indexed +`[m, n]`; reuse them across many field points with [`internal_field`](@ref). +""" +function internal_coefficients(x::Real, mᵣ::Number, ϑ_inc, φ_inc, Eθ, Eφ; + nmax::Integer = ceil(Int, x + 4cbrt(x) + 2)) + T = float(real(promote_type(typeof(float(x)), typeof(float(ϑ_inc))))) + CT = complex(T) + cₙ, dₙ = _mie_internal_coefficients(x, mᵣ, nmax) + a, b = _plane_wave_coefficients(nmax, T(ϑ_inc), T(φ_inc), CT(Eθ), CT(Eφ)) + c = OffsetArray(zeros(CT, 2nmax + 1, nmax), (-nmax):nmax, 1:nmax) + d = OffsetArray(zeros(CT, 2nmax + 1, nmax), (-nmax):nmax, 1:nmax) + for n in 1:nmax, m in (-n):n + + c[m, n] = cₙ[n] * a[m, n] + d[m, n] = dₙ[n] * b[m, n] + end + return c, d +end + +@doc raw""" +``` +internal_field(c, d, mᵣ, λ, r⃗) -> SVector{3} +internal_field(x, mᵣ, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗; nmax) -> SVector{3} +``` + +Internal electric field (inside a homogeneous sphere) at the Cartesian point `r⃗`, +reconstructed from regular VSWFs at the internal wavenumber ``k_\text{int} = m_r k``. +The first form reuses precomputed coefficients `(c, d)` from +[`internal_coefficients`](@ref) (efficient for a dense grid); the second computes +them on the fly for size parameter `x = k a` and refractive index `mᵣ`. + +`r⃗` must lie INSIDE the sphere (the regular expansion represents the interior +field). The external field is [`total_field`](@ref); the two are continuous +(tangentially) across the surface. +""" +function internal_field(c::OffsetArray, d::OffsetArray, mᵣ, λ, r⃗) + N = size(c, 2) + Tr = real(eltype(c)) + k = 2 * Tr(π) / λ + return _field_from_coeffs(c, d, :regular, mᵣ * k, r⃗, N) +end + +function internal_field(x::Real, mᵣ, λ, ϑ_inc, φ_inc, Eθ, Eφ, r⃗; + nmax::Integer = ceil(Int, x + 4cbrt(x) + 2)) + c, d = internal_coefficients(x, mᵣ, ϑ_inc, φ_inc, Eθ, Eφ; nmax) + return internal_field(c, d, mᵣ, λ, r⃗) +end + +# The general axisymmetric (EBCM) internal field — extending `internal_coefficients` +# / `internal_field` to non-spherical shapes — lives in `src/EBCM/near_field.jl`, +# included after the EBCM matrices and the shape types it depends on. + +@testitem "Internal field is tangentially continuous with the external field" begin + using TransitionMatrices: MieTransitionMatrix, _plane_wave_coefficients, + _field_from_coeffs, _mie_internal_coefficients, + internal_field, total_field + + # Maxwell boundary conditions on a sphere: tangential 𝐄 and 𝐇 are continuous + # across the surface. Comparing the (validated) external field to the internal + # reconstruction at r = a is a complete physical check of the internal coeffs. + tangential(E, + ϑ̂, + φ̂) = (ϑ̂[1] * E[1] + ϑ̂[2] * E[2] + ϑ̂[3] * E[3], + φ̂[1] * E[1] + φ̂[2] * E[2] + φ̂[3] * E[3]) + + λ = 2π + k0 = 2π / λ + x, mᵣ = 3.0, 1.5 + 0.05im + a = x / k0 + N = ceil(Int, x + 4cbrt(x) + 2) + mie = MieTransitionMatrix{ComplexF64, N}(x, mᵣ) + Eθ, Eφ = 1.0 + 0im, 0.0im + + a_inc, b_inc = _plane_wave_coefficients(N, 0.0, 0.0, ComplexF64(Eθ), ComplexF64(Eφ)) + p = scattering_coefficients(mie, 0.0, 0.0, Eθ, Eφ)[1] + q = scattering_coefficients(mie, 0.0, 0.0, Eθ, Eφ)[2] + cₙ, dₙ = _mie_internal_coefficients(x, mᵣ, N) + c = similar(a_inc) + d = similar(b_inc) + for n in 1:N, m in (-n):n + + c[m, n] = cₙ[n] * a_inc[m, n] + d[m, n] = dₙ[n] * b_inc[m, n] + end + k_int = mᵣ * k0 + + angles = [(ϑ, φ) for ϑ in (0.3, 0.9, 1.5, 2.2, 2.8) for φ in (0.0, 1.9, 3.5)] + discont = map(angles) do (ϑ, φ) + sϑ, cϑ, sφ, cφ = sin(ϑ), cos(ϑ), sin(φ), cos(φ) + r̂ = [sϑ * cφ, sϑ * sφ, cϑ] + ϑ̂ = [cϑ * cφ, cϑ * sφ, -sϑ] + φ̂ = [-sφ, cφ, 0.0] + pos = a .* r̂ + Eext = _field_from_coeffs(a_inc, b_inc, :regular, k0, pos, N) + + _field_from_coeffs(p, q, :outgoing, k0, pos, N) + Eint = _field_from_coeffs(c, d, :regular, k_int, pos, N) + # H̃ = k·(swap M↔N): _field_from_coeffs(c₂, c₁) gives Σ c₁ N + c₂ M ∝ ∇×E. + Hext = k0 * (_field_from_coeffs(b_inc, a_inc, :regular, k0, pos, N) + + _field_from_coeffs(q, p, :outgoing, k0, pos, N)) + Hint = k_int * _field_from_coeffs(d, c, :regular, k_int, pos, N) + Eϑe, Eφe = tangential(Eext, ϑ̂, φ̂) + Eϑi, Eφi = tangential(Eint, ϑ̂, φ̂) + Hϑe, Hφe = tangential(Hext, ϑ̂, φ̂) + Hϑi, Hφi = tangential(Hint, ϑ̂, φ̂) + relE = (abs(Eϑe - Eϑi) + abs(Eφe - Eφi)) / max(abs(Eϑe), abs(Eφe)) + relH = (abs(Hϑe - Hϑi) + abs(Hφe - Hφi)) / max(abs(Hϑe), abs(Hφe)) + (relE, relH) + end + @test maximum(first, discont) < 1e-8 + @test maximum(last, discont) < 1e-8 + + # The one-shot convenience form agrees with the manual reconstruction. + pos = a .* [sin(0.9) * cos(1.9), sin(0.9) * sin(1.9), cos(0.9)] ./ 2 # inside + E1 = internal_field(x, mᵣ, λ, 0.0, 0.0, Eθ, Eφ, pos; nmax = N) + E2 = _field_from_coeffs(c, d, :regular, k_int, pos, N) + @test maximum(abs, E1 - E2) < 1e-12 +end + +@testitem "Internal field converges to the Rayleigh (small-sphere) limit" begin + using TransitionMatrices: internal_field + + # As x → 0 the internal field becomes uniform and equal to the electrostatic + # result 3/(mᵣ²+2)·E₀ (E₀ = x̂ for +z incidence, x-polarization). This is an + # independent absolute-magnitude anchor (boundary continuity alone fixes the + # internal field only relative to the external one). The leading dynamic + # correction is O(x), so halving x roughly halves the deviation. + λ = 2π + E0 = [1.0 + 0im, 0.0im, 0.0im] + devs = Float64[] + for x in (0.02, 0.005), mᵣ in (1.5 + 0.0im, 2.0 + 0.1im) + + a = x / (2π / λ) + pred = 3 / (mᵣ^2 + 2) + pts = (0.3a .* [1.0, 0, 0], 0.3a .* [0, 1.0, 0], 0.2a .* [0, 0, 1.0]) + w = maximum(pts) do p + E = internal_field(x, mᵣ, λ, 0.0, 0.0, 1.0 + 0im, 0.0im, Vector{Float64}(p)) + maximum(abs, E .- pred .* E0) / abs(pred) + end + push!(devs, w) + @test w < 0.01 # within 1% of the static limit already at x ≤ 0.02 + end + # And it converges (smaller x ⇒ smaller deviation) for each (mᵣ). + @test devs[3] < devs[1] # mᵣ=1.5: x=0.005 closer than x=0.02 + @test devs[4] < devs[2] # mᵣ=2.0+0.1im likewise +end + +@testitem "Incident plane-wave expansion reconstructs the analytic plane wave" begin + using TransitionMatrices: _plane_wave_coefficients, _field_from_coeffs, incident_field + + # Σ aₘₙ RgMₘₙ + bₘₙ RgNₘₙ must equal the analytic plane wave inside the + # circumscribing sphere — this locks the incident coefficients and the regular + # VSWF normalization simultaneously. + λ = 2π + k = 2π / λ + N = 20 + pts = ([0.2, 0.1, 0.15], [-0.18, 0.25, -0.2], [0.05, -0.3, 0.22]) + for (Eθ, Eφ) in ((1.0 + 0im, 0.0im), (0.0im, 1.0 + 0im), (0.6 + 0.2im, 0.3 - 0.4im)), + (ϑi, φi) in ((0.7, 0.4), (2.1, -1.3)) + + a, b = _plane_wave_coefficients(N, ϑi, φi, ComplexF64(Eθ), ComplexF64(Eφ)) + for p in pts + Erec = _field_from_coeffs(a, b, :regular, k, p, N) + Epw = incident_field(λ, ϑi, φi, Eθ, Eφ, p) + @test maximum(abs, Erec - Epw) < 1e-10 + end + end +end + +@testitem "Regular field reconstruction is finite at the origin" begin + using TransitionMatrices: _plane_wave_coefficients, _field_from_coeffs, incident_field, + internal_field + + λ = 2π + k = 2π / λ + N = 20 + pos = [0.0, 0.0, 0.0] + for (Eθ, Eφ) in ((1.0 + 0im, 0.0im), (0.0im, 1.0 + 0im), + (0.6 + 0.2im, 0.3 - 0.4im)), + (ϑi, φi) in ((0.7, 0.4), (2.1, -1.3), (0.0, 0.0)) + + a, b = _plane_wave_coefficients(N, ϑi, φi, ComplexF64(Eθ), ComplexF64(Eφ)) + Erec = _field_from_coeffs(a, b, :regular, k, pos, N) + Epw = incident_field(λ, ϑi, φi, Eθ, Eφ, pos) + @test all(isfinite, abs.(Erec)) + @test maximum(abs, Erec - Epw) < 1e-10 + end + + Eint = internal_field(2.5, 1.5 + 0.05im, λ, 0.0, 0.0, 1.0 + 0im, 0.0im, pos) + @test all(isfinite, abs.(Eint)) +end + +@testitem "Scattered field reproduces amplitude_matrix in the far zone" begin + using TransitionMatrices: MieTransitionMatrix, scattering_coefficients, + scattered_field, amplitude_matrix + + dot3(a, b) = a[1] * b[1] + a[2] * b[2] + a[3] * b[3] + function bvecs(ϑ, φ) + sϑ, cϑ = sincos(ϑ) + sφ, cφ = sincos(φ) + [sϑ * cφ, sϑ * sφ, cϑ], [cϑ * cφ, cϑ * sφ, -sϑ], [-sφ, cφ, 0.0] + end + + λ = 2π + k = 2π / λ + x = 3.0 + N = ceil(Int, x + 4cbrt(x) + 2) + 𝐓 = MieTransitionMatrix{ComplexF64, N}(x, 1.5 + 0.01im) + Eθ, Eφ = 0.5 + 0.3im, -0.2 + 0.4im + ϑi, φi = 0.6, 0.5 + p, q = scattering_coefficients(𝐓, ϑi, φi, Eθ, Eφ) + + # The reconstructed far field converges to (e^{ikR}/R)·S·E₀ as O(1/R); checking + # the ratio at two radii a decade apart confirms both the value and the rate, + # which locks the outgoing-VSWF normalization and the (p,q)=𝐓(a,b) application. + for (ϑs, φs) in ((0.4, 0.3), (1.5, 2.0), (2.6, -0.7)) + n̂s, θ̂s, φ̂s = bvecs(ϑs, φs) + S = amplitude_matrix(𝐓, ϑi, φi, ϑs, φs; λ) + predθ(R) = cis(k * R) / R * (S[1, 1] * Eθ + S[1, 2] * Eφ) + predφ(R) = cis(k * R) / R * (S[2, 1] * Eθ + S[2, 2] * Eφ) + e = Float64[] + for R in (2.0e4, 2.0e5) + E = scattered_field(p, q, λ, R .* n̂s) + push!(e, abs(dot3(θ̂s, E) / predθ(R) - 1) + abs(dot3(φ̂s, E) / predφ(R) - 1)) + end + @test e[1] < 2e-3 # close already at R = 2e4 + @test e[2] < e[1] / 5 # and converging as O(1/R) + end +end diff --git a/src/common/vswf.jl b/src/common/vswf.jl new file mode 100644 index 0000000..5541137 --- /dev/null +++ b/src/common/vswf.jl @@ -0,0 +1,235 @@ +# ── Vector spherical wave functions (VSWFs) for near-field reconstruction ───── +# +# The incident, scattered and internal fields of a T-matrix problem are expanded +# in vector spherical wave functions. We use the convention of Mishchenko, Travis +# & Lacis (2002), Eqs. (5.16)–(5.17), which is the convention the rest of this +# package (`amplitude_matrix`, the T-matrix itself) already follows: +# +# 𝐌ₘₙ(k𝐫) = γₘₙ e^{imφ} zₙ(kr) [ i πₘₙ(ϑ) 𝛝̂ − τₘₙ(ϑ) 𝛗̂ ] +# 𝐍ₘₙ(k𝐫) = γₘₙ e^{imφ} { n(n+1)/(kr) zₙ(kr) Pₘₙ(ϑ) 𝐫̂ +# + [kr·zₙ(kr)]′/(kr) [ τₘₙ(ϑ) 𝛝̂ + i πₘₙ(ϑ) 𝛗̂ ] } +# +# with +# γₘₙ = (−1)^m √[(2n+1) / (4π n(n+1))], +# Pₘₙ(ϑ) = d^n_{0m}(ϑ) (Wigner d, = normalized associated Legendre), +# πₘₙ(ϑ) = m d^n_{0m}(ϑ)/sinϑ, +# τₘₙ(ϑ) = d d^n_{0m}(ϑ)/dϑ, +# and zₙ the spherical Bessel function jₙ (`kind = :regular`, used for the regular +# functions RgM/RgN that expand the incident and internal fields) or the spherical +# Hankel function hₙ⁽¹⁾ (`kind = :outgoing`, the radiating M/N that expand the +# scattered field, valid outside the circumscribing sphere). +# +# The radial factors are obtained from the package's Riccati–Bessel routines +# (`ricattibesselj`, `ricattibessely`): +# jₙ(x) = ψₙ(x)/x, yₙ(x) = χₙ(x)/x, hₙ⁽¹⁾(x) = jₙ(x) + i yₙ(x), +# [x·zₙ(x)]′/x = ψ′ₙ(x)/x (regular) resp. (ψ′ₙ + i χ′ₙ)/x (outgoing). +# +# 𝐌 and 𝐍 share the same γₘₙ, so they satisfy ∇×𝐌ₘₙ = k 𝐍ₘₙ and ∇×𝐍ₘₙ = k 𝐌ₘₙ +# regardless of the absolute value of γₘₙ; this curl identity is what the unit +# tests check. The absolute normalization γₘₙ is fixed to the Mishchenko value so +# that, combined with the plane-wave incident coefficients, the reconstructed +# field reproduces `amplitude_matrix` in the far zone (validated against Mie). + +@inline function _sph_unit_vectors(ϑ::T, φ::T) where {T} + sϑ, cϑ = sincos(ϑ) + sφ, cφ = sincos(φ) + r̂ = SVector(sϑ * cφ, sϑ * sφ, cϑ) + ϑ̂ = SVector(cϑ * cφ, cϑ * sφ, -sϑ) + φ̂ = SVector(-sφ, cφ, zero(T)) + return r̂, ϑ̂, φ̂ +end + +@inline _is_regular_origin(kind::Symbol, x, + ::Type{T}) where {T} = kind === :regular && (iszero(x) || abs(x) < eps(T)) + +function _regular_vswf_origin_limit(::Type{T}, m::Integer, n::Integer) where {T} + CT = Complex{T} + zero3 = zero(SVector{3, CT}) + n == 1 || return zero3, zero3 + + α = inv(sqrt(T(12) * T(π))) + β = inv(sqrt(T(6) * T(π))) + 𝐍 = if m == -1 + SVector{3, CT}(α, -im * α, zero(CT)) + elseif m == 0 + SVector{3, CT}(zero(CT), zero(CT), β) + elseif m == 1 + SVector{3, CT}(-α, -im * α, zero(CT)) + else + zero3 + end + return zero3, 𝐍 +end + +function _regular_vswf_radial_origin(::Type{T}, nₘₐₓ::Integer, x::Number) where {T} + XT = x isa Real ? T : Complex{T} + zₙ = zeros(XT, nₘₐₓ) + xzₙ′ = zeros(XT, nₘₐₓ) + nₘₐₓ ≥ 1 && (xzₙ′[1] = XT(T(2) / T(3))) + return zₙ, xzₙ′ +end + +# Radial factors zₙ(x) and [x·zₙ(x)]′/x for n = 1:nₘₐₓ, plus zₙ(x)/x (the factor +# multiplying the 𝐫̂ component of 𝐍). +function _vswf_radial(kind::Symbol, nₘₐₓ::Integer, x::Number) + T = float(real(typeof(x))) + _is_regular_origin(kind, x, T) && return _regular_vswf_radial_origin(T, nₘₐₓ, x) + + if kind === :regular + ψ, ψ′ = ricattibesselj(nₘₐₓ, estimate_ricattibesselj_extra_terms(nₘₐₓ, x), x) + zₙ = ψ ./ x + xzₙ′ = ψ′ ./ x + elseif kind === :outgoing + ψ, ψ′ = ricattibesselj(nₘₐₓ, estimate_ricattibesselj_extra_terms(nₘₐₓ, x), x) + χ, χ′ = ricattibessely(nₘₐₓ, x) + zₙ = (ψ .+ im .* χ) ./ x + xzₙ′ = (ψ′ .+ im .* χ′) ./ x + else + throw(ArgumentError("`kind` must be :regular or :outgoing, got $kind")) + end + return zₙ, xzₙ′ +end + +@doc raw""" +``` +vswf(kind::Symbol, m::Integer, n::Integer, k, r, ϑ, φ) +``` + +Evaluate the vector spherical wave functions ``\mathbf{M}_{mn}`` and +``\mathbf{N}_{mn}`` of degree `m` and order `n` at the point `(r, ϑ, φ)` for size +parameter `k` (so the radial argument is `x = k·r`), returning the pair `(𝐌, 𝐍)` +as `SVector{3}` of **Cartesian** field components. + +`kind` selects the radial dependence: + +- `:regular` — spherical Bessel ``j_n`` (the regular `RgM`/`RgN`, finite at the + origin; used for incident and internal fields). +- `:outgoing` — spherical Hankel ``h_n^{(1)}`` (the radiating `M`/`N` used for the + scattered field; valid outside the circumscribing sphere). + +The convention is Mishchenko, Travis & Lacis (2002), Eqs. (5.16)–(5.17), +consistent with [`amplitude_matrix`](@ref). `𝐌` and `𝐍` satisfy +``\nabla\times\mathbf{M}_{mn} = k\,\mathbf{N}_{mn}`` and +``\nabla\times\mathbf{N}_{mn} = k\,\mathbf{M}_{mn}``. +""" +function vswf(kind::Symbol, m::Integer, n::Integer, k::Real, r::Real, ϑ::Real, φ::Real) + abs(m) ≤ n || throw(ArgumentError("require |m| ≤ n, got m=$m, n=$n")) + T = float(promote_type(typeof(k), typeof(r), typeof(ϑ), typeof(φ))) + ϑ, φ = T(ϑ), T(φ) + x = T(k) * T(r) + _is_regular_origin(kind, x, T) && return _regular_vswf_origin_limit(T, m, n) + + zₙ, xzₙ′ = _vswf_radial(kind, n, x) + zn = zₙ[n] + dzn = xzₙ′[n] + zn_over_x = zn / x + + # Angular factors Pₘₙ = d^n_{0m}, τₘₙ = d(d^n_{0m})/dϑ, πₘₙ = m d^n_{0m}/sinϑ. + dvec, τvec = wigner_d_recursion(T, 0, m, n, ϑ; deriv = true) + P = dvec[n] + τ = τvec[n] + π_ = pi_func(T, m, n, ϑ; d = P) + + γ = (-1)^(m & 1) * sqrt(T(2n + 1) / (4 * T(π) * n * (n + 1))) + eⁱᵐᵠ = cis(m * φ) + r̂, ϑ̂, φ̂ = _sph_unit_vectors(ϑ, φ) + + pref = γ * eⁱᵐᵠ + 𝐌 = pref * zn * (im * π_ .* ϑ̂ .- τ .* φ̂) + 𝐍 = pref * + (n * (n + 1) * zn_over_x * P .* r̂ .+ dzn .* (τ .* ϑ̂ .+ im * π_ .* φ̂)) + + return 𝐌, 𝐍 +end + +""" +``` +vswf_cartesian(kind::Symbol, m, n, k, xyz::AbstractVector) +``` + +Convenience wrapper of [`vswf`](@ref) taking a Cartesian position `xyz = (x, y, z)` +and returning the `(𝐌, 𝐍)` Cartesian field vectors. The polar axis is `+z`. +""" +function vswf_cartesian(kind::Symbol, m::Integer, n::Integer, k::Real, + xyz::AbstractVector) + x, y, z = xyz[1], xyz[2], xyz[3] + T = float(promote_type(typeof(k), typeof(x), typeof(y), typeof(z))) + xT, yT, zT = T(x), T(y), T(z) + r = sqrt(xT^2 + yT^2 + zT^2) + _is_regular_origin(kind, T(k) * r, T) && return _regular_vswf_origin_limit(T, m, n) + ϑ = acos(clamp(zT / r, -one(T), one(T))) + φ = atan(yT, xT) + return vswf(kind, m, n, k, r, ϑ, φ) +end + +@testitem "VSWF satisfy the curl identities ∇×M = kN and ∇×N = kM" begin + using TransitionMatrices: vswf_cartesian + + # Finite-difference Cartesian curl of a vector field F(𝐫). + function fd_curl(F, p, h) + ex = [h, 0.0, 0.0] + ey = [0.0, h, 0.0] + ez = [0.0, 0.0, h] + ∂x = (F(p + ex) - F(p - ex)) / 2h + ∂y = (F(p + ey) - F(p - ey)) / 2h + ∂z = (F(p + ez) - F(p - ez)) / 2h + return [∂y[3] - ∂z[2], ∂z[1] - ∂x[3], ∂x[2] - ∂y[1]] + end + + k = 1.3 + h = 1e-5 + # A handful of generic off-axis points (avoid the poles / origin). + points = ([0.7, 0.5, 0.9], [-0.6, 0.8, 0.4], [1.1, -0.3, -0.7]) + + @testset "kind = $kind" for kind in (:regular, :outgoing) + for p in points + for n in 1:4, m in (-n):n + + M(q) = vswf_cartesian(kind, m, n, k, q)[1] + N(q) = vswf_cartesian(kind, m, n, k, q)[2] + curlM = fd_curl(M, p, h) + curlN = fd_curl(N, p, h) + kN = k * vswf_cartesian(kind, m, n, k, p)[2] + kM = k * vswf_cartesian(kind, m, n, k, p)[1] + @test maximum(abs, curlM - kN) / maximum(abs, kN) < 1e-6 + @test maximum(abs, curlN - kM) / maximum(abs, kM) < 1e-6 + end + end + end +end + +@testitem "Regular VSWFs have finite analytic origin limits" begin + using TransitionMatrices: _vswf_radial, vswf, vswf_cartesian + + α = 1 / sqrt(12π) + β = 1 / sqrt(6π) + zero3 = [0.0, 0.0, 0.0] + + zₙ, xzₙ′ = _vswf_radial(:regular, 4, 0.0) + @test all(isfinite, zₙ) + @test all(isfinite, xzₙ′) + @test maximum(abs, zₙ) == 0 + @test xzₙ′[1] == 2 / 3 + @test maximum(abs, xzₙ′[2:end]) == 0 + + expected_N(m, + n) = n == 1 ? + ( + m == -1 ? α .* [1.0, -im, 0.0] : + m == 0 ? β .* [0.0, 0.0, 1.0] : + m == 1 ? -α .* [1.0, im, 0.0] : + zeros(ComplexF64, 3)) : zeros(ComplexF64, 3) + + for n in 1:4, m in (-n):n + + M, N = vswf_cartesian(:regular, m, n, 1.0, zero3) + @test all(isfinite, abs.(M)) + @test all(isfinite, abs.(N)) + @test maximum(abs, M) < 1e-14 + @test maximum(abs, N - expected_N(m, n)) < 1e-14 + + M_sph, N_sph = vswf(:regular, m, n, 1.0, 0.0, 0.4, 0.2) + @test maximum(abs, M_sph - M) < 1e-14 + @test maximum(abs, N_sph - N) < 1e-14 + end +end