Skip to content

Commit 04792a8

Browse files
committed
fix(P3): numerically stable get_ρ_d in Float32
The analytical solution for ρ_d has a denominator that is 0/0 as F_rim → 0. In Float32 the cancellation drives ρ_d, and therefore ρ_g, negative, which later raises a DomainError in get_D_gr. Rewrite get_ρ_d with the relative exponential functions exprel1 and exprel2 so the cancellation is removed; ρ_g then stays positive for every physical input, with no clipping. Add the derivation and an error-cancellation figure to the P3 documentation, and a Float32 stability test.
1 parent 0955247 commit 04792a8

5 files changed

Lines changed: 186 additions & 4 deletions

File tree

docs/src/P3Scheme.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,54 @@ We obtain the following expression for $ρ_d$
118118
\frac{(β_{va} - 2)(k - 1)}{(1 - F_{rim}) k - 1} - (1 - F_{rim})
119119
}
120120
```
121+
122+
!!! details "Click here to see a numerically stable form"
123+
Evaluated directly, this expression loses accuracy as ``F_{rim} → 0``. In that limit ``k → 1`` and
124+
``(1 - F_{rim}) k → 1``, so the ratio ``(k - 1) / ((1 - F_{rim}) k - 1)`` is ``0/0``. In `Float32` the
125+
resulting cancellation drives the computed ``ρ_d``, and therefore ``ρ_g``, negative. We rewrite the
126+
expression so that the cancellation is removed.
127+
128+
Let ``F_u = 1 - F_{rim}`` be the unrimed fraction, let ``L = \log F_u``, and let ``p = 1 / (3 - β_{va})``,
129+
so that ``k = F_u^{-p}`` and ``(1 - F_{rim}) k = F_u^{1 - p}``. Every power of the form ``F_u^a - 1`` is
130+
equal to ``e^{aL} - 1``. We write these through the relative exponential functions
131+
132+
```math
133+
φ(a) = \frac{e^{aL} - 1}{aL} = \mathrm{exprel}_1(aL), \qquad
134+
ψ(a) = \frac{e^{aL} - 1 - aL}{(aL)^2} = \mathrm{exprel}_2(aL),
135+
```
136+
137+
both of which stay finite and accurate as ``L → 0``. Using ``β_{va} - 2 = -(1 - p) / p``, the denominator
138+
of ``ρ_d`` becomes ``φ(-p) / φ(1 - p) - F_u``. Both the numerator ``ρ_{rim} F_{rim}`` and the denominator
139+
are proportional to ``L`` as ``F_{rim} → 0``, so we factor ``L`` out of each. With ``F_{rim} = -L\,φ(1)``
140+
and ``φ(-p) - φ(1 - p) = L\,[-p\,ψ(-p) - (1 - p)\,ψ(1 - p)]``, the denominator divided by ``L`` is
141+
142+
```math
143+
G = \big[-p\,ψ(-p) - (1 - p)\,ψ(1 - p)\big] - φ(1 - p)\,φ(1),
144+
```
145+
146+
which tends to ``-3/2`` as ``F_{rim} → 0``. The factor ``L`` cancels, and we are left with
147+
148+
```math
149+
ρ_d = -\frac{ρ_{rim}\,φ(1)\,φ(1 - p)}{G}.
150+
```
151+
152+
This form has no subtraction of nearly equal numbers, so it stays accurate in `Float32`, and ``ρ_g``
153+
stays positive for every physical input without any clipping. The functions ``\mathrm{exprel}_1`` and
154+
``\mathrm{exprel}_2`` (implemented by the internal `exprel` function) use a Taylor series at small
155+
arguments, where the closed forms would themselves cancel.
156+
157+
The figure below shows the relative error of ``ρ_g`` in `Float32`, against a reference computed in
158+
`BigFloat`, for both the direct evaluation and this rewrite. The direct form is order-one wrong at small
159+
rime fractions and only reaches `Float32` precision near ``F_{rim} = 0.35``, while the rewrite stays at
160+
the precision floor across the whole range.
161+
162+
```@example
163+
include("plots/P3RhoDStability.jl")
164+
165+
nothing # hide
166+
```
167+
![](P3RhoDStability.svg)
168+
121169
Given $ρ_d$, we can obtain $ρ_g$, $D_{gr}$, and $D_{cr}$ using the expressions above. Depending on the value of $ρ_{rim}$ and $F_{rim}$,
122170
these thresholds and densities obtain a range of values, as shown in the plot below.
123171

docs/src/plots/P3RhoDStability.jl

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import CairoMakie: Makie
2+
import CloudMicrophysics.Parameters as CMP
3+
import CloudMicrophysics.P3Scheme as P3
4+
5+
# Direct evaluation of the analytical solution for ρ_d, which loses accuracy as F_rim → 0.
6+
function ρ_d_direct(β_va, F_rim, ρ_rim)
7+
p = 1 / (3 - β_va)
8+
Fᵤ = 1 - F_rim
9+
k = Fᵤ^(-p)
10+
den = (β_va - 2) * (k - 1) / (Fᵤ * k - 1) - Fᵤ
11+
return ρ_rim * F_rim / den
12+
end
13+
14+
mass = CMP.ParametersP3(Float32).mass
15+
β_va = mass.β_va
16+
ρ_rim = 400
17+
18+
ρ_g(ρ_d, F_rim) = F_rim * ρ_rim + (1 - F_rim) * ρ_d
19+
F_rims = [10.0^e for e in -7:0.05:-0.005]
20+
ρ_g_ref(F_rim) = Float64(ρ_g(ρ_d_direct(big(β_va), big(F_rim), big(ρ_rim)), big(F_rim)))
21+
rel_err(ρ_g_f32, F_rim) = abs(Float64(ρ_g_f32) - ρ_g_ref(F_rim)) / abs(ρ_g_ref(F_rim))
22+
23+
err_stable = [rel_err(ρ_g(P3.get_ρ_d(mass, Float32(F), Float32(ρ_rim)), Float32(F)), F) for F in F_rims]
24+
err_direct = [rel_err(ρ_g(ρ_d_direct(β_va, Float32(F), Float32(ρ_rim)), Float32(F)), F) for F in F_rims]
25+
26+
fig = Makie.Figure(size = (760, 460))
27+
ax = Makie.Axis(
28+
fig[1, 1];
29+
xscale = log10,
30+
yscale = log10,
31+
xlabel = "F_rim",
32+
ylabel = "relative error of ρ_g (Float32)",
33+
title = "Direct evaluation vs. numerically stable rewrite",
34+
)
35+
Makie.lines!(ax, F_rims, max.(err_direct, 1e-16), label = "direct evaluation", linewidth = 2)
36+
Makie.lines!(ax, F_rims, max.(err_stable, 1e-16), label = "stable rewrite", linewidth = 2)
37+
Makie.hlines!(ax, [eps(Float32)], color = :gray, linestyle = :dash, label = "eps(Float32)")
38+
Makie.axislegend(ax; position = :rt)
39+
Makie.save("P3RhoDStability.svg", fig)

src/P3_particle_properties.jl

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,44 @@ Return `true` if the particle is unrimed, i.e. `F_rim = 0`.
111111
"""
112112
isunrimed(state::P3State) = iszero(state.F_rim)
113113

114+
exprel_coeffs(n, k) = 1 // factorial(n + k) # exprelₖ series coefficient = 1/(n+k)!
115+
116+
@inline exprel1(x) = expm1(x) / x # exprel₁ = (exp(x)-1)/x
117+
@inline _exprel2(x) = (expm1(x) - x) / (x * x)
118+
@inline _exprel2_small(x) = evalpoly(x, ntuple(np1 -> exprel_coeffs(np1 - 1, 2), Val(8)))
119+
@inline function exprel2(x) # exprel₂ = (exp(x)-1-x)/x²
120+
abs(x) < 1 // 5 && return _exprel2_small(x)
121+
return _exprel2(x)
122+
end
123+
124+
"""
125+
exprel(x, ::Val{k})
126+
127+
Compute the relative exponential `exprelₖ(x) = Σₙ xⁿ/(n+k)!`.
128+
129+
# Arguments
130+
- `x`: real argument.
131+
- `k`: order of the function, passed as `Val(k)`.
132+
133+
# Details
134+
135+
`exprelₖ` is one of the `φ`-functions `φₖ(x)` that appear in exponential integrators.
136+
It is implemented for `k = 1` (`(eˣ-1)/x`) and `k = 2` (`(eˣ-1-x)/x²`); other values of `k`
137+
throw an `ArgumentError`. For `k = 2` at small `|x|`, a Taylor series is used to avoid
138+
catastrophic loss of precision near `x = 0`.
139+
140+
The value `k` is passed as `Val(k)`, so the order resolves at compile time.
141+
142+
# References
143+
- [Exponential integrators](https://en.wikipedia.org/wiki/Exponential_integrator)
144+
- [Niesen & Wright (2009), A Krylov subspace algorithm for evaluating the φ-functions appearing in exponential integrators](https://arxiv.org/abs/0907.4631)
145+
"""
146+
@inline function exprel(x, ::Val{k}) where {k}
147+
k == 1 && return exprel1(x)
148+
k == 2 && return exprel2(x)
149+
throw(ArgumentError("exprel is only implemented for k = 1 and k = 2"))
150+
end
151+
114152
"""
115153
get_ρ_d(mass::MassPowerLaw, F_rim, ρ_rim)
116154
get_ρ_d(state::P3State)
@@ -119,19 +157,42 @@ Exact solution for the density of the unrimed portion of the particle as
119157
function of the rime mass fraction `F_rim`, mass power law parameters `mass`,
120158
and rime density `ρ_rim`.
121159
160+
For the derivation of the numerically stable form used here, see the P3 scheme
161+
documentation ([Assumed particle size relationships](@ref)).
162+
122163
# Arguments
123164
- `mass`: [`CMP.MassPowerLaw`](@ref) parameters
124165
- `F_rim`: rime mass fraction
125166
- `ρ_rim`: rime density
126167
127168
# Returns
128169
- `ρ_d`: density of the unrimed portion of the particle [kg/m³]
170+
171+
# Examples
172+
173+
```jldoctest
174+
julia> import CloudMicrophysics.Parameters as CMP,
175+
ClimaParams as CP,
176+
CloudMicrophysics.P3Scheme as P3
177+
178+
julia> FT = Float64;
179+
180+
julia> mass = CMP.MassPowerLaw(CP.create_toml_dict(FT));
181+
182+
julia> F_rim, ρ_rim = FT(0.5), FT(916.7);
183+
184+
julia> ρ_d = P3.get_ρ_d(mass, F_rim, ρ_rim)
185+
488.9120789986414
186+
```
129187
"""
130188
function get_ρ_d((; β_va)::CMP.MassPowerLaw, F_rim, ρ_rim)
131-
k = (1 - F_rim)^(-1 / (3 - β_va))
132-
num = ρ_rim * F_rim
133-
den = (β_va - 2) * (k - 1) / ((1 - F_rim) * k - 1) - (1 - F_rim)
134-
return num / den
189+
p = 1 / (3 - β_va)
190+
logFᵤ = log1p(-F_rim) # = log(1 - F_rim)
191+
φ₁ = exprel(logFᵤ, Val(1))
192+
φ₁₋ₚ = exprel((1 - p) * logFᵤ, Val(1))
193+
H = -p * exprel(-p * logFᵤ, Val(2)) - (1 - p) * exprel((1 - p) * logFᵤ, Val(2))
194+
G = H - φ₁₋ₚ * φ₁
195+
return -(ρ_rim * φ₁ * φ₁₋ₚ) / G
135196
end
136197
get_ρ_d((; params, F_rim, ρ_rim)::P3State) = get_ρ_d(params.mass, F_rim, ρ_rim)
137198

test/p3_rho_d_stability.jl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Test: @testset, @test
2+
import CloudMicrophysics.P3Scheme as P3
3+
import CloudMicrophysics.Parameters as CMP
4+
import ClimaParams as CP
5+
import ForwardDiff as FD
6+
7+
# Direct evaluation of the analytical solution for ρ_d. Evaluated in BigFloat it is the
8+
# high-precision reference; a Float64 reference is not accurate enough, because the direct
9+
# expression itself loses about two percent at F_rim = 1e-7.
10+
function get_ρ_d_direct(::Type{FT}, F_rim, ρ_rim, β_va) where {FT}
11+
k = (one(FT) - F_rim)^(-one(FT) / (FT(3) - β_va))
12+
den = (β_va - FT(2)) * (k - one(FT)) / ((one(FT) - F_rim) * k - one(FT)) - (one(FT) - F_rim)
13+
return ρ_rim * F_rim / den
14+
end
15+
16+
@testset "P3 get_ρ_d Float32 stability (small rime fraction)" begin
17+
mass = CMP.ParametersP3(Float32).mass
18+
βva = mass.β_va
19+
ρ_g_ref(F_rim, ρ_rim) =
20+
P3.get_ρ_g(F_rim, ρ_rim, get_ρ_d_direct(BigFloat, F_rim, ρ_rim, BigFloat(βva)))
21+
for F_rim in (1.0f-7, 1.0f-6, 1.0f-5, 1.0f-4, 1.0f-3, 1.0f-2, 1.0f-1, 4.0f-1, 9.0f-1),
22+
ρ_rim in (1.0f0, 5.0f1, 4.0f2, 9.16f2)
23+
24+
ρ_d = P3.get_ρ_d(mass, F_rim, ρ_rim)
25+
ρ_g = P3.get_ρ_g(F_rim, ρ_rim, ρ_d)
26+
@test ρ_g > 0 # The direct implementation produces negative numbers for some inputs.
27+
@test isfinite(ρ_d)
28+
@test ρ_g Float32(ρ_g_ref(BigFloat(F_rim), BigFloat(ρ_rim))) rtol = 1.0f-5
29+
end
30+
# The value and its derivative with respect to F_rim stay finite under ForwardDiff in Float32.
31+
g(x) = (ρ_d = P3.get_ρ_d(mass, x, 4.0f2); P3.get_ρ_g(x, 4.0f2, ρ_d))
32+
@test isfinite(FD.derivative(g, 1.0f-4))
33+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ TT.@testset "All tests" begin
1818
include("nucleation_unit_tests.jl")
1919
include("precipitation_susceptibility_tests.jl")
2020
include("p3_tests.jl")
21+
include("p3_rho_d_stability.jl")
2122
include("p3_shape_solver_warmstart_tests.jl")
2223
include("aqua.jl")
2324
include("performance_tests.jl")

0 commit comments

Comments
 (0)