Skip to content

Commit e573c80

Browse files
committed
feat(P3): Gauss-Legendre quadrature parameter, velocity-aware bounds, and P3 property refactors
Once every integrand kink is a quadrature subinterval boundary, the P3 size-distribution integrals are smooth, so a low-order Gauss-Legendre rule reaches the accuracy that previously needed a high-order Chebyshev-Gauss rule. Quadrature: - Default to Gauss-Legendre at every order (arbitrary n >= 1); at matched order it is ~20x more accurate than Chebyshev-Gauss on the smooth P3 integrands, verified against an adaptive QuadGK reference. - Pass the quadrature rule directly on the parameters (P3IceParams.quad); remove build_quadrature and the quadrature_order field/kwarg, so the rule is a single explicit object rather than an order-to-rule indirection. - Lower the default order to 12 (~1e-5 worst-case relative error on the domain-reduced integrals, the accuracy previously reached at order 16). - Make quad a required argument of integrate and the P3 process functions, removing the hidden ChebyshevGauss(100) default; the rule is threaded from the parameters. - Resolve the Chen small/large-ice velocity D_cutoff kink as a subinterval boundary (velocity_integral_bounds), so velocity-weighted integrals converge geometrically instead of plateauing at the kink. Ice self-collection: - Integrate the double integral over the upper triangle D_1 <= D_2, which folds the D_1 = D_2 diagonal and every regime breakpoint onto quadrature bounds and halves the integrand evaluations. Particle properties: - regime_value: one branchless selector for the five P3 mass/area regimes, replacing the duplicated regime conditionals in ice_area, ice_mass_coeffs, and ϕ_material_density. - ϕᵢ uses the per-regime material density (oblate spheroid model), with aᵢ^{3/2} written as aᵢ * sqrt(aᵢ), a hardware sqrt and a multiply in place of a runtime-exponent power call. - Select the ice terminal-velocity aspect-ratio correction by type (UseAspectRatio / NoAspectRatio) so the branch resolves at compile time. Robustness: - Floor the air density to non-negative in the Chen velocity coefficients; a slightly-negative sub-domain density otherwise throws in their fractional powers.
1 parent ddd64f0 commit e573c80

18 files changed

Lines changed: 381 additions & 245 deletions

docs/src/API.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ P3Scheme.ice_mass
189189
P3Scheme.ice_density
190190
P3Scheme.∂ice_mass_∂D
191191
P3Scheme.ice_area
192+
P3Scheme.ϕ_material_density
192193
P3Scheme.ϕᵢ
193194
```
194195

@@ -261,7 +262,6 @@ P3Scheme.integrate
261262
P3Scheme.subintervals
262263
P3Scheme.ChebyshevGauss
263264
Quadrature.GaussLegendre
264-
Quadrature.build_quadrature
265265
P3Scheme.integral_bounds
266266
```
267267

docs/src/P3Scheme.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,42 @@ An alternative parameterization for $μ$ is a constant value:
354354
We use the Chen et al. [Chen2022](@cite) velocity parameterization,
355355
see [here](https://clima.github.io/CloudMicrophysics.jl/dev/TerminalVelocity.html#Chen-et.-al.-2022) for details.
356356

357+
### Aspect ratio
358+
359+
We model the ice particle as an oblate spheroid of maximum dimension $D$, projected
360+
area $a_i = $ `ice_area(state, D)`, and mass $m_i = $ `ice_mass(state, D)`. Writing
361+
the equatorial diameter as $a = 2\sqrt{a_i/π}$ and the polar diameter as $c = ϕ\,a$,
362+
and equating the spheroid mass $ρ\,(π/6)\,a^2 c$ to $m_i$ gives the oblate aspect
363+
ratio ($κ = 1/3$)
364+
365+
```math
366+
ϕ = \frac{3 \sqrt{π}\, m_i}{4\, ρ\, a_i^{3/2}},
367+
```
368+
369+
where $ρ$ is the particle's material density [`ϕ_material_density`](@ref) — the
370+
density of the solid the particle is made of ($ρ_i$, or $ρ_g$ for graupel) — not the
371+
size-dependent effective density $m_i / V_{sphere}(D)$ [`ice_density`](@ref). Using
372+
the effective density would cancel the particle mass and pin $ϕ ≡ 1$, disabling the
373+
$\sqrt[3]{ϕ}$ fall-speed correction in [`ice_particle_terminal_velocity`](@ref).
374+
375+
Within each mass regime the material density is constant, so $ϕ$ tracks $m_i / a_i^{3/2}$.
376+
In the spherical regimes ($D < D_{th}$, and graupel $D_{gr} ≤ D < D_{cr}$) the mass and
377+
area are exactly spherical, so $ϕ = 1$. In the dense-nonspherical regime $ϕ < 1$ (oblate)
378+
and decreases with $D$, slowing large unrimed ice.
379+
380+
!!! note "Residual $ϕ > 1$ band just above $D_{th}$"
381+
$ϕ$ can slightly exceed 1 (peak $≈ 1.2$) in a narrow size band immediately above
382+
$D_{th}$. This is not a sign error: it is caused by the area discontinuity at
383+
$D_{th}$, where the projected area drops from the spherical law $(π/4) D^2$ to the
384+
nonspherical law $γ D^σ$ while the mass stays continuous. The smaller area inflates
385+
$m_i / a_i^{3/2}$ just past the threshold. $\sqrt[3]{ϕ}$ remains finite and bounded
386+
there ($\sqrt[3]{1.2} ≈ 1.06$), so no clamp is applied. Reconciling the mass and area
387+
laws at $D_{th}$ (so the area is continuous too) is a separate area power-law item,
388+
left untouched here.
389+
357390
The figure below shows the implied aspect ratio for different particle size regimes.
358-
Note that $ϕ = 1$ corresponds to spherical particles (small spherical ice ($D < D_{th}$) and graupel ($D_{gr} < D < D_{cr}$)).
391+
Note that $ϕ = 1$ corresponds to spherical particles (small spherical ice ($D < D_{th}$) and graupel ($D_{gr} < D < D_{cr}$)),
392+
and that $ϕ$ exceeds 1 in a narrow band just above $D_{th}$ (the area discontinuity noted above; a follow-on item).
359393
```@example
360394
include("plots/P3AspectRatioPlot.jl")
361395

docs/src/plots/P3AspectRatioPlot.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ axis_theme = Theme(
1010
xminorticksvisible = true,
1111
xminorticks = IntervalsBetween(5),
1212
xticks = [0.01, 0.1, 1, 10],
13-
limits = ((0.01, 10.0), (0, 1.05)),
13+
limits = ((0.01, 10.0), (0, 1.3)),
1414
xgridvisible = false,
1515
ygridvisible = false,
1616
xlabel = "D (mm)",

src/Common.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ See [Chen2022](@cite) for more details.
289289
"""
290290
@inline function Chen2022_vel_coeffs(coeffs::CMP.Chen2022VelTypeRain, ρₐ)
291291
(; ρ0, a, a3_pow, b, b_ρ, c) = coeffs
292+
ρₐ = max(ρₐ, zero(ρₐ))
292293
# Table B1
293294
q = exp(ρ0 * ρₐ)
294295
ai = (a[1] * q, a[2] * q, a[3] * q * ρₐ^a3_pow)
@@ -303,6 +304,7 @@ end
303304
@inline function Chen2022_vel_coeffs(coeffs::CMP.Chen2022VelTypeSmallIce, ρₐ, ρᵢ)
304305
FT = eltype(coeffs)
305306
(; A, B, C, E, F, G) = coeffs
307+
ρₐ = max(ρₐ, zero(ρₐ))
306308
# Table B3 - cache sqrt for reuse
307309
log_ρᵢ = log(ρᵢ)
308310
sqrt_ρᵢ = sqrt(ρᵢ)
@@ -325,6 +327,7 @@ end
325327
@inline function Chen2022_vel_coeffs(coeffs::CMP.Chen2022VelTypeLargeIce, ρₐ, ρᵢ)
326328
FT = eltype(coeffs)
327329
(; A, B, C, E, F, G, H) = coeffs
330+
ρₐ = max(ρₐ, zero(ρₐ))
328331
# Table B5 - cache sqrt for reuse
329332
log_ρᵢ = log(ρᵢ)
330333
sqrt_ρᵢ = sqrt(ρᵢ)

src/P3_integral_properties.jl

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ function integral_bounds(state::P3State{FT}, logλ; p, moment_order = 0) where {
4242
return segment_boundaries(state, D_min, D_max)
4343
end
4444

45+
"""
46+
velocity_integral_bounds(state::P3State, logλ, D_cutoff; p, moment_order = 0)
47+
48+
Compute the integration bounds for a velocity-weighted P3 integral: the
49+
mass-regime [`integral_bounds`](@ref) with the Chen 2022 small/large-ice velocity
50+
breakpoint `D_cutoff` clamped into `[D_min, D_max]` and re-sorted, so the `v(D)`
51+
kink lands on a subinterval boundary. Returns a fixed 6-tuple.
52+
"""
53+
function velocity_integral_bounds(state::P3State{FT}, logλ, D_cutoff; p, moment_order = 0) where {FT}
54+
bnds = integral_bounds(state, logλ; p, moment_order)
55+
D_c = clamp(FT(D_cutoff), first(bnds), last(bnds))
56+
return Tuple(SA.sort(SA.SVector(bnds..., D_c)))
57+
end
58+
4559
"""
4660
D_m(state::P3State, logλ)
4761

src/P3_particle_properties.jl

Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,33 @@ function weighted_average(f_a, a, b)
304304
return f_a * a + (1 - f_a) * b
305305
end
306306

307+
"""
308+
regime_value(state::P3State, D; small, unrimed, dense_rimed, graupel, partially_rimed)
309+
310+
Select the value for the P3 mass/area regime that the maximum dimension `D` falls in:
311+
- `small`: small spherical ice (`D < D_th`),
312+
- `unrimed`: large unrimed ice (`F_rim = 0` ∧ `D_th ≤ D`),
313+
- `dense_rimed`: dense rimed ice (`D_th ≤ D < D_gr`),
314+
- `graupel`: graupel (rimed) (`D_gr ≤ D < D_cr`),
315+
- `partially_rimed`: partially rimed ice (`D_cr ≤ D`).
316+
317+
All five values are evaluated and must share a type.
318+
"""
319+
@inline function regime_value(
320+
state::P3State, D;
321+
small, unrimed, dense_rimed, graupel, partially_rimed,
322+
)
323+
(; F_rim, D_th, D_gr, D_cr) = state
324+
#! format: off
325+
return ifelse(D < D_th, small, # small spherical ice
326+
ifelse(iszero(F_rim), unrimed, # large nonspherical unrimed ice
327+
ifelse(D < D_gr, dense_rimed, # dense nonspherical rimed ice
328+
ifelse(D < D_cr, graupel, # graupel (rimed)
329+
partially_rimed, # partially rimed ice
330+
))))
331+
#! format: on
332+
end
333+
307334
"""
308335
ice_mass_coeffs(state::P3State, D)
309336
@@ -318,21 +345,16 @@ Return the coefficients for the ice mass power law at diameter `D`.
318345
"""
319346
function ice_mass_coeffs(state::P3State, D)
320347
FT = promote_type(eltype(state), eltype(D))
321-
(; params, F_rim, ρ_g, D_th, D_gr, D_cr) = state
348+
(; params, F_rim, ρ_g) = state
322349
(; ρ_i) = params
323350
(; α_va, β_va) = params.mass
324-
a, b = if D < D_th # small spherical ice
325-
(ρ_i * π / 6, 3)
326-
elseif iszero(F_rim) # large nonspherical unrimed ice
327-
(α_va, β_va)
328-
elseif D_th D < D_gr # dense nonspherical rimed ice
329-
(α_va, β_va)
330-
elseif D_gr D < D_cr # graupel (rimed)
331-
(ρ_g * π / 6, 3)
332-
else # D_cr ≤ D # partially rimed ice
333-
(α_va / (1 - F_rim), β_va)
334-
end
335-
return FT(a), FT(b)
351+
m_sphere(ρ) = (FT* π / 6), FT(3)) # spherical mass ∝ ρ (π/6) D³
352+
m_power(a) = (FT(a), FT(β_va)) # power-law mass ∝ a D^β_va
353+
return regime_value(state, D;
354+
small = m_sphere(ρ_i), unrimed = m_power(α_va),
355+
dense_rimed = m_power(α_va), graupel = m_sphere(ρ_g),
356+
partially_rimed = m_power(α_va / (1 - F_rim)),
357+
)
336358
end
337359

338360
"""
@@ -361,7 +383,6 @@ Return the density of a particle with diameter `D`
361383
# Notes:
362384
The density of nonspherical particles is assumed to be the particle mass divided
363385
by the volume of a sphere with the same D [MorrisonMilbrandt2015](@cite).
364-
Needed for aspect ratio calculation, so we assume zero liquid fraction.
365386
"""
366387
ice_density(state::P3State, D) = ice_mass(state, D) / CO.volume_sphere_D(D)
367388

@@ -395,46 +416,63 @@ Return the cross-sectional area of a particle based on where it falls in the
395416
- `D`: maximum particle dimension [m]
396417
"""
397418
function ice_area(state::P3State, D)
398-
(; params, F_rim, D_th, D_gr, D_cr) = state
419+
(; params, F_rim) = state
399420
(; γ, σ) = params.area
400-
spherical_area(D) = D^2 * π / 4
401-
nonspherical_area(D) = γ * D^σ
402-
return if D < D_th # small spherical ice
403-
spherical_area(D)
404-
elseif iszero(F_rim) # large nonspherical unrimed ice
405-
nonspherical_area(D)
406-
elseif D_th D < D_gr # dense nonspherical rimed ice
407-
nonspherical_area(D)
408-
elseif D_gr D < D_cr # graupel (rimed)
409-
spherical_area(D)
410-
else # D_cr ≤ D # partially rimed ice
411-
weighted_average(F_rim, spherical_area(D), nonspherical_area(D))
412-
end
421+
spherical = D^2 * π / 4
422+
nonspherical = γ * D^σ
423+
return regime_value(state, D;
424+
small = spherical, unrimed = nonspherical, dense_rimed = nonspherical,
425+
graupel = spherical,
426+
partially_rimed = weighted_average(F_rim, spherical, nonspherical),
427+
)
428+
end
429+
430+
"""
431+
ϕ_material_density(state::P3State, D)
432+
433+
Return the material density of the ice particle's solid at diameter `D`, used in
434+
the aspect-ratio closure [`ϕᵢ`](@ref). This is `ρ_i` in every mass regime except
435+
graupel (`D_gr ≤ D < D_cr`), where it is the graupel density `ρ_g`.
436+
437+
This is the density of the actual solid material, not the size-dependent effective
438+
density `mᵢ / Vsphere(D)` returned by [`ice_density`](@ref); see the
439+
[P3 documentation](@ref "Aspect ratio") for the distinction.
440+
"""
441+
function ϕ_material_density(state::P3State, D)
442+
(; params, ρ_g) = state
443+
(; ρ_i) = params
444+
return regime_value(state, D;
445+
small = ρ_i, unrimed = ρ_i, dense_rimed = ρ_i, graupel = ρ_g, partially_rimed = ρ_i,
446+
)
413447
end
414448

415449
"""
416450
ϕᵢ(state::P3State, D)
417451
418-
Returns the aspect ratio (ϕ) for an ice particle with diameter `D`
452+
Return the oblate aspect ratio `ϕ = 3√π mᵢ / (4 ρ aᵢ^{3/2})` (`κ = 1/3`) for an
453+
ice particle of maximum dimension `D`, with mass `mᵢ = ice_mass(state, D)`,
454+
projected area `aᵢ = ice_area(state, D)`, and material density
455+
`ρ = ϕ_material_density(state, D)`. Assumes zero liquid fraction.
419456
420457
# Arguments
421458
- `state`: The [`P3State`](@ref)
422459
- `D`: maximum dimension of ice particle [m]
423460
424-
# Notes
425-
The density of nonspherical particles is assumed to be equal to the particle mass
426-
divided by the volume of a spherical particle with the same D_max [MorrisonMilbrandt2015](@cite).
427-
Assuming zero liquid fraction and oblate shape.
461+
See also [`ϕ_material_density`](@ref) and the
462+
[aspect-ratio section of the P3 documentation](@ref "Aspect ratio") for the
463+
spheroid derivation and the residual `ϕ > 1` band above `D_th`.
428464
"""
429465
function ϕᵢ(state::P3State, D)
430466
FT = eltype(D)
431467
mᵢ = ice_mass(state, D)
432468
aᵢ = ice_area(state, D)
433-
ρᵢ = mᵢ / CO.volume_sphere_D(D)
469+
ρ = ϕ_material_density(state, D)
434470

435-
# TODO - prolate or oblate?
436-
ϕ_ob = min(1, 3 * sqrt(FT(π)) * mᵢ / (4 * ρᵢ * aᵢ^FT(1.5))) # κ = 1/3
437-
#ϕ_pr = max(1, 16 * ρᵢ^2 * aᵢ^3 / (9 * FT(π) * mᵢ^2)) # κ = -1/6
471+
# Oblate aspect ratio (κ = 1/3); no clamp (see P3 documentation).
472+
# `aᵢ^{3/2}` written as `aᵢ * sqrt(aᵢ)`: a hardware sqrt and a multiply
473+
# instead of a runtime-exponent power call, evaluated per quadrature node.
474+
ϕ_ob = 3 * sqrt(FT(π)) * mᵢ / (4 * ρ * aᵢ * sqrt(aᵢ))
475+
#ϕ_pr = 16 * ρ^2 * aᵢ^3 / (9 * FT(π) * mᵢ^2) # prolate, κ = -1/6
438476

439477
return ifelse(D == 0, zero(ϕ_ob), ϕ_ob)
440478
end

src/P3_processes.jl

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ end
5757
- `logλ`: the log of the slope parameter [log(1/m)]
5858
5959
# Keyword arguments
60-
- `quad`: quadrature rule, default is `ChebyshevGauss(100)`
60+
- `quad`: quadrature rule (a `Quadrature.QuadratureRule`)
6161
6262
Returns the melting rate of ice (QIMLT in Morrison and Mildbrandt (2015)).
6363
"""
6464
function ice_melt(
6565
velocity_params, aps::CMP.AirProperties, tps::TDI.PS,
6666
Tₐ, ρₐ, state::P3State, logλ;
67-
quad = ChebyshevGauss(100),
67+
quad,
6868
)
6969
# Note: process not dependent on `F_liq`
7070
# (we want ice core shape params)
@@ -292,7 +292,7 @@ Returns a function `liquid_integrals(Dᵢ)` that computes the liquid particle in
292292
- `liq_bounds`: integration bounds for liquid particles
293293
294294
# Keyword arguments
295-
- `quad`: quadrature rule, default is `ChebyshevGauss(100)`
295+
- `quad`: quadrature rule (a `Quadrature.QuadratureRule`)
296296
297297
# Notes
298298
The function `liquid_integrals(Dᵢ)` returns a tuple `(∂ₜN_col, ∂ₜM_col, ∂ₜB_col)`
@@ -301,7 +301,7 @@ The function `liquid_integrals(Dᵢ)` returns a tuple `(∂ₜN_col, ∂ₜM_col
301301
- `∂ₜM_col`: mass collision rate [kg/s]
302302
- `∂ₜB_col`: rime volume collision rate [m³/s]
303303
"""
304-
function get_liquid_integrals(n, ∂ₜV, m_liq, ρ′_rim, liq_bounds; quad = ChebyshevGauss(100))
304+
function get_liquid_integrals(n, ∂ₜV, m_liq, ρ′_rim, liq_bounds; quad)
305305
function liquid_integrals(Dᵢ)
306306
integrand =
307307
D -> SA.SVector(
@@ -440,12 +440,12 @@ Computes the bulk collision rate integrands between ice and liquid particles.
440440
- `ice_bounds`: integration bounds for ice particles, from [`integral_bounds`](@ref)
441441
442442
# Keyword arguments
443-
- `quad`: quadrature rule, default is `ChebyshevGauss(100)`
443+
- `quad`: quadrature rule (a `Quadrature.QuadratureRule`)
444444
445445
# Returns
446446
A tuple of 8 integrands, see [`∫liquid_ice_collisions`](@ref) for details.
447447
"""
448-
function ∫liquid_ice_collisions(n_i, ∂ₜM_max, cloud_integrals, rain_integrals, ice_bounds; quad = ChebyshevGauss(100))
448+
function ∫liquid_ice_collisions(n_i, ∂ₜM_max, cloud_integrals, rain_integrals, ice_bounds; quad)
449449
function liquid_ice_collisions_integrands(Dᵢ)
450450
# Inner integrals over liquid particle diameters
451451
∂ₜN_c_col, ∂ₜM_c_col, ∂ₜB_c_col = cloud_integrals(Dᵢ)
@@ -593,7 +593,7 @@ A `NamedTuple` of `(; ∂ₜq_c, ∂ₜq_r, ∂ₜN_c, ∂ₜN_r, ∂ₜL_rim,
593593
function bulk_liquid_ice_collision_sources(
594594
state, logλ,
595595
psd_c, psd_r, L_c, N_c, L_r, N_r,
596-
aps, tps, vel, ρₐ, T; quad = ChebyshevGauss(100),
596+
aps, tps, vel, ρₐ, T; quad,
597597
)
598598
FT = promote_type(eltype(state), UT.promote_typeof(L_c, N_c, L_r, N_r, ρₐ, T))
599599
(; τ_wet, ρ_i) = state.params
@@ -687,28 +687,34 @@ while leaving mass, rime mass, and rime volume unchanged.
687687
- `ρₐ`: air density [kg/m³]
688688
689689
# Keyword arguments
690-
- `quad`: quadrature rule, default is `ChebyshevGauss(100)`
690+
- `quad`: quadrature rule (a `Quadrature.QuadratureRule`)
691691
692692
# Returns
693693
A `NamedTuple` of `(; dNdt)`, where:
694694
1. `dNdt`: ice number concentration tendency due to self-collection [1/m³/s] (always positive or zero, represents a loss rate)
695695
"""
696-
function ice_self_collection(state, logλ, vel, ρₐ; quad = ChebyshevGauss(100))
696+
function ice_self_collection(state, logλ, vel, ρₐ; quad)
697697
n_i = DT.size_distribution(state, logλ)
698698
∂ₜV = volumetric_ice_ice_collision_rate_integrand(vel, ρₐ, state)
699699

700700
p = eps(one(ρₐ))
701-
ice_bounds = integral_bounds(state, logλ; p)
701+
ice_bounds = velocity_integral_bounds(state, logλ, vel.small_ice.cutoff; p)
702+
D_min, D_max = ice_bounds[1], ice_bounds[end]
702703

703704
function inner_integral(D_1)
705+
# Inner integral over D_2 ∈ [D_1, D_max] (the upper triangle). Its
706+
# subinterval boundaries are the P3 regime breakpoints restricted to that
707+
# window: clamping each breakpoint up to D_1 drops those below it to
708+
# zero-width (no-op) subintervals.
704709
integrand = D_2 -> ∂ₜV(D_1, D_2) * n_i(D_2)
705-
rate_at_D1 = integrate(integrand, ice_bounds, quad)
706-
return rate_at_D1 * n_i(D_1)
710+
D_lo = clamp(D_1, D_min, D_max)
711+
inner_bounds = map(D -> max(D, D_lo), ice_bounds)
712+
return integrate(integrand, inner_bounds, quad) * n_i(D_1)
707713
end
708714

709-
total_rate = integrate(inner_integral, ice_bounds, quad)
710-
711-
# The 0.5 factor accounts for double-counting in self-collection
712-
dNdt = (1 // 2) * total_rate
715+
# The collision integrand is symmetric under `D_1 ↔ D_2`, so the triangle
716+
# integral times two equals the full-square integral; that factor of two
717+
# cancels the `1/2` self-collection double-counting factor.
718+
dNdt = integrate(inner_integral, ice_bounds, quad)
713719
return (; dNdt)
714720
end

0 commit comments

Comments
 (0)