Skip to content

Commit 6badb1a

Browse files
committed
feat(P3): add more P3 ice tendencies
new tendencies for bulk 2M scheme: - ice self-collection - ice sublimation / vapor deposition - empirical ice nucleation (Frostenberg mean) numerics: - add numeric epsilon for rime volume - squash a few bugs - improve docs
1 parent d9669a2 commit 6badb1a

File tree

11 files changed

+385
-110
lines changed

11 files changed

+385
-110
lines changed

src/BulkMicrophysicsTendencies.jl

Lines changed: 111 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ import ..Microphysics1M as CM1
3232
import ..Microphysics2M as CM2
3333
import ..MicrophysicsNonEq as CMNonEq
3434
import ..P3Scheme as CMP3
35+
import ..HetIceNucleation as CM_HetIce
3536
import ...ThermodynamicsInterface as TDI
37+
import ..Common as CO
3638

3739
export MicrophysicsScheme,
3840
Microphysics0Moment,
@@ -801,9 +803,14 @@ to be non-Nothing, eliminating runtime type checks and dynamic dispatch.
801803
"""
802804
@inline function bulk_microphysics_tendencies(
803805
::Microphysics2Moment, mp::CMP.Microphysics2MParams{WR, ICE}, tps,
804-
ρ, T, q_tot, q_lcl, n_lcl, q_rai, n_rai,
805-
q_ice = zero(ρ), n_ice = zero(ρ), q_rim = zero(ρ), b_rim = zero(ρ), logλ = zero(ρ),
806+
ρ, T, q_tot,
807+
q_lcl, n_lcl, q_rai, n_rai,
808+
q_ice, n_ice, q_rim, b_rim, logλ,
806809
) where {WR, ICE <: CMP.P3IceParams}
810+
FT = eltype(ρ)
811+
ϵₘ = UT.ϵ_numerics_2M_M(FT)
812+
ϵₙ = UT.ϵ_numerics_2M_N(FT)
813+
ϵB = UT.ϵ_numerics_P3_B(FT)
807814
# Clamp negative inputs to zero (robustness against numerical errors)
808815
ρ = UT.clamp_to_nonneg(ρ)
809816
q_tot = UT.clamp_to_nonneg(q_tot)
@@ -816,100 +823,133 @@ to be non-Nothing, eliminating runtime type checks and dynamic dispatch.
816823
q_rim = UT.clamp_to_nonneg(q_rim)
817824
b_rim = UT.clamp_to_nonneg(b_rim)
818825

819-
# Unpack warm rain parameters (always present)
826+
# Convert to volumetric quantities for P3 functions
827+
L_lcl = q_lcl * ρ # [kg lcl / m³ air]
828+
L_rai = q_rai * ρ # [kg rai / m³ air]
829+
N_lcl = n_lcl * ρ # [1 / m³ air]
830+
N_rai = n_rai * ρ # [1 / m³ air]
831+
L_ice = q_ice * ρ # [kg ice / m³ air]
832+
N_ice = n_ice * ρ # [1 / m³ air]
833+
L_rim = q_rim * ρ # [kg rim / m³ air]
834+
B_rim = b_rim * ρ # [m³ rim / m³ air]
835+
# Compute rime fraction and density
836+
F_rim = ifelse(q_ice > ϵₘ, q_rim / q_ice, zero(L_rim))
837+
ρ_rim = ifelse(b_rim > ϵB, q_rim / b_rim, zero(L_rim)) # [kg rim / m³ rim]
838+
state = CMP3.P3State(mp.ice.scheme, L_ice, N_ice, F_rim, ρ_rim)
839+
840+
# Unpack warm rain parameters
820841
aps = mp.warm_rain.air_properties
842+
subdep = mp.warm_rain.subdep
821843

822844
# Initialize ice-related tendencies
823845
dq_ice_dt = zero(ρ)
824-
# TODO: When ice number concentration becomes prognostic, add:
825-
# dn_ice_dt = zero(ρ) # Ice number tendency (changes due to melting, aggregation)
846+
dn_ice_dt = zero(ρ)
826847
dq_rim_dt = zero(ρ)
827848
db_rim_dt = zero(ρ)
828849

829-
# --- Core Warm Rain Processes (shared helper) ---
850+
# --- Core Warm Rain Processes ---
830851
warm = warm_rain_tendencies_2m(mp.warm_rain, tps, T, q_tot, q_lcl, q_rai, q_ice, ρ, n_lcl, n_rai)
831852
dq_lcl_dt = warm.dq_lcl_dt
832853
dn_lcl_dt = warm.dn_lcl_dt
833854
dq_rai_dt = warm.dq_rai_dt
834855
dn_rai_dt = warm.dn_rai_dt
835856

836-
# Convert to number densities for remaining functions
837-
N_lcl = ρ * n_lcl
838-
N_rai = ρ * n_rai
839-
840857
# --- P3 Ice Processes ---
841858
p3 = mp.ice.scheme
842859
vel = mp.ice.terminal_velocity
843860
pdf_c = mp.ice.cloud_pdf
844861
pdf_r = mp.ice.rain_pdf
862+
ice_nucleation = mp.ice.ice_nucleation
863+
845864

846865
# Only compute ice processes if there is ice mass/number present
847-
if (q_ice > zero(q_ice) || n_ice > zero(n_ice))
848-
# Convert to volumetric quantities for P3 functions
849-
L_ice = q_ice * ρ # [kg/m³]
850-
N_ice = n_ice * ρ # [1/m³]
851-
L_lcl = q_lcl * ρ # [kg/m³]
852-
N_lcl = n_lcl * ρ # [1/m³]
853-
L_rai = q_rai * ρ # [kg/m³]
854-
N_rai = n_rai * ρ # [1/m³]
855-
856-
# Compute rime fraction and density
857-
F_rim = ifelse(q_ice > zero(q_ice), q_rim / q_ice, zero(q_rim))
858-
ρ_rim = ifelse(b_rim > zero(b_rim), q_rim * ρ / (b_rim * ρ), ρ) # [kg/m³]
859-
860-
# Liquid-ice collision sources (core P3 process)
861-
# Only compute if there is ice present
862-
if L_ice > zero(L_ice) && N_ice > zero(N_ice)
863-
coll = CMP3.bulk_liquid_ice_collision_sources(
864-
p3,
865-
logλ,
866-
L_ice,
867-
N_ice,
868-
F_rim,
869-
ρ_rim,
870-
pdf_c,
871-
pdf_r,
872-
L_lcl,
873-
N_lcl,
874-
L_rai,
875-
N_rai,
876-
aps,
877-
tps,
878-
vel,
879-
ρ,
880-
T,
881-
)
882-
883-
# Add collision tendencies
884-
dq_lcl_dt += coll.∂ₜq_c
885-
dq_rai_dt += coll.∂ₜq_r
886-
dn_lcl_dt += coll.∂ₜN_c / ρ
887-
dn_rai_dt += coll.∂ₜN_r / ρ
888-
dq_ice_dt += coll.∂ₜL_ice / ρ
889-
# TODO: When P3 collision sources return ∂ₜN_ice (aggregation, etc.), add:
890-
# dn_ice_dt += coll.∂ₜN_ice / ρ
891-
dq_rim_dt += coll.∂ₜL_rim / ρ
892-
db_rim_dt += coll.∂ₜB_rim / ρ
893-
end
866+
if q_ice > ϵₘ && n_ice > ϵₙ
867+
868+
# --- Liquid-ice collisions ---
869+
coll = CMP3.bulk_liquid_ice_collision_sources(
870+
state, logλ, pdf_c, pdf_r, L_lcl, N_lcl, L_rai, N_rai, aps, tps, vel, ρ, T,
871+
)
872+
dq_lcl_dt += coll.∂ₜq_c
873+
dq_rai_dt += coll.∂ₜq_r
874+
dn_lcl_dt += coll.∂ₜN_c / ρ
875+
dn_rai_dt += coll.∂ₜN_r / ρ
876+
dq_ice_dt += coll.∂ₜL_ice / ρ
877+
dq_rim_dt += coll.∂ₜL_rim / ρ
878+
db_rim_dt += coll.∂ₜB_rim / ρ
879+
880+
# --- Ice self-collection (aggregation) ---
881+
S_ice_agg = CMP3.ice_self_collection(state, logλ, aps, tps, vel, ρ, T)
882+
dn_ice_dt -= S_ice_agg.dNdt / ρ
894883

895884
# Ice melting (above freezing temperature)
896885
T_freeze = TDI.TD.Parameters.T_freeze(tps)
897-
if T > T_freeze && L_ice > zero(L_ice)
898-
state = CMP3.P3State(p3, L_ice, N_ice, F_rim, ρ_rim)
899-
melt = CMP3.ice_melt(vel, aps, tps, T, ρ, state, logλ)
900-
901-
# Melting converts ice to rain
902-
dq_ice_dt -= melt.dLdt / ρ
903-
dq_rai_dt += melt.dLdt / ρ
904-
# TODO: When ice number concentration is tracked, add:
905-
# dn_ice_dt -= melt.dNdt / ρ # Ice particles consumed by melting
906-
dn_rai_dt += melt.dNdt / ρ # Melted ice becomes rain drops
907-
end
886+
melt = ifelse(T > T_freeze,
887+
CMP3.ice_melt(vel, aps, tps, T, ρ, state, logλ),
888+
(; dNdt = zero(ρ), dLdt = zero(ρ))
889+
)
890+
# Melting converts ice to rain
891+
dq_rai_dt += melt.dLdt / ρ
892+
dn_rai_dt += melt.dNdt / ρ # Melted ice becomes rain drops
893+
dq_ice_dt -= melt.dLdt / ρ
894+
dn_ice_dt -= melt.dNdt / ρ # Ice particles consumed by melting
908895
end
909896

910-
# TODO: When ice number concentration is tracked, add dn_ice_dt to return tuple:
911-
# return (; dq_lcl_dt, dn_lcl_dt, dq_rai_dt, dn_rai_dt, dq_ice_dt, dn_ice_dt, dq_rim_dt, db_rim_dt)
912-
return (; dq_lcl_dt, dn_lcl_dt, dq_rai_dt, dn_rai_dt, dq_ice_dt, dq_rim_dt, db_rim_dt)
897+
# --- --------------- ---
898+
# --- Ice Nucleation ---
899+
# --- --------------- ---
900+
901+
# Note: Intuitively, you choose one of two parameterization pathways,
902+
# either 1. process-based (e.g. using CM_HetIce.deposition_J, etc.) or
903+
# 2. empirical (e.g. using CM_HetIce.INP_concentration_mean)
904+
# 1. In the former, you try to differentiate between different aerosol types
905+
# and their properties, homogeneous freezing, heterogeneous deposition
906+
# freezing, heterogeneous immersion freezing, etc.
907+
# 2. The latter, which we adopt here, empirically relates some environmental
908+
# conditions (e.g. temperature) to the number of ice crystals nucleated.
909+
# We do not track the aerosol population, and do not consider different
910+
# nucleation pathways explicitly.
911+
912+
# --- Ice Nucleation (empirical) ---
913+
# Assume mass loss is mean condensate mass
914+
m_lcl = ifelse(n_lcl > ϵₙ, q_lcl / n_lcl, zero(q_lcl)) # mean liquid mass
915+
916+
# TODO: The parameterixation should return a rate, `∂N/∂t`, not number changes `ΔN`
917+
inpc = CM_HetIce.INP_concentration_mean(ice_nucleation, T) / ρ # [particles / kg air]
918+
τ_nuc = FT(1) # TODO: Make this a real tendency
919+
∂ₜn_nuc = max(0, (inpc - n_ice) / τ_nuc) # [particles / kg air]
920+
∂ₜq_nuc = ∂ₜn_nuc * m_lcl # [kg ice / kg air]
921+
922+
dn_ice_dt += ∂ₜn_nuc
923+
dq_ice_dt += ∂ₜq_nuc
924+
dq_rim_dt += ∂ₜq_nuc
925+
db_rim_dt += ∂ₜq_nuc / p3.ρ_i # ρ_i = 916.7 kg m⁻³, the density of solid bulk ice
926+
dn_lcl_dt -= ∂ₜn_nuc
927+
dq_lcl_dt -= ∂ₜq_nuc
928+
929+
# --- Cloud Droplet Condensation Freezing ---
930+
# ref: `homogeneous_freezing` in `parcel/ParcelTendencies.jl`
931+
# get mean diameter of cloud droplets, then convert to volume
932+
933+
# --- Ice Sublimation / Deposition ---
934+
n_per_q_ice = ifelse(q_ice > ϵₘ, n_ice / q_ice, zero(n_ice))
935+
# Deposition/sublimation of cloud ice
936+
∂ₜq_ice_dep = CMNonEq.conv_q_vap_to_q_lcl_icl_MM2015(subdep, tps, q_tot, q_lcl, q_ice, q_rai, zero(q_ice), ρ, T)
937+
# No ice deposition above freezing (lack of INPs)
938+
∂ₜq_ice_dep = ifelse(T > tps.T_freeze, min(∂ₜq_ice_dep, zero(T)), ∂ₜq_ice_dep)
939+
# During sublimation, the number of ice particles decreases in proportion to the mean ice mass
940+
# During deposition, the number of ice particles remain unchanged
941+
∂ₜn_ice_dep = ifelse(∂ₜq_ice_dep < 0, n_per_q_ice * ∂ₜq_ice_dep, zero(∂ₜq_ice_dep))
942+
dq_ice_dt += ∂ₜq_ice_dep
943+
dn_ice_dt += ∂ₜn_ice_dep
944+
945+
# --- Rain Heterogeneous Freezing ---
946+
# TODO: Implement heterogeneous freezing of rain
947+
# This process is currently missing in P3_processes.jl
948+
# S_rai_frz = ...
949+
# dq_rai_dt -= S_rai_frz
950+
# dq_ice_dt += S_rai_frz
951+
952+
return (; dq_lcl_dt, dn_lcl_dt, dq_rai_dt, dn_rai_dt, dq_ice_dt, dn_ice_dt, dq_rim_dt, db_rim_dt)
913953
end
914954

915955
end # module BulkMicrophysicsTendencies

src/Common.jl

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,22 @@ See e.g., Seifert and Beheng (2006), https://doi.org/10.1007/s00703-005-0112-4,
463463
464464
# Returns
465465
- `F_v(D)`: ventilation factor function (dimensionless)
466+
467+
# Extended help
468+
469+
The ventilation factor is given by
470+
471+
```math
472+
F_v(D) = a_v + b_v ⋅ ∛Sc ⋅ √Re(D)
473+
```
474+
475+
where
476+
477+
```math
478+
Sc = ν_{air} / D_{vapor}
479+
Re(D) = D * v_{term}(D) / ν_{air}
480+
```
481+
466482
"""
467483
@inline function ventilation_factor(vent, aps, v_term)
468484
(; aᵥ, bᵥ) = vent

src/P3_integral_properties.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ Integrate the function `f` over each subinterval of the integration bounds, `bnd
9090
"""
9191
function integrate(f, bnds...; quad = ChebyshevGauss(100))
9292
# compute integral over each subinterval (a, b), (b, c), (c, d), ...
93-
return sum(integrate(f, a, b; quad) for (a, b) in zip(Base.front(bnds), Base.tail(bnds)))
93+
return UU.unrolled_sum(
94+
integrate(f, a, b; quad) for (a, b) in zip(Base.front(bnds), Base.tail(bnds))
95+
)
9496
end
9597

9698
"""

src/P3_particle_properties.jl

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ Create a [`P3State`](@ref) from [`CMP.ParametersP3`](@ref) and rime state parame
7474
function get_state(params::CMP.ParametersP3; L_ice, N_ice, F_rim, ρ_rim)
7575
# rime mass fraction must always be non-negative ...
7676
# ... and there must always be some unrimed part
77-
@assert 0 F_rim < 1 "Rime mass fraction, `F_rim`, must be between 0 and 1"
77+
0 F_rim < 1 || throw(DomainError(F_rim, "Rime mass fraction, F_rim, must be 0 ≤ F_rim < 1"))
7878
# rime density must be positive ...
7979
# ... and as a bulk ice density can't exceed the density of water
80-
@assert 0 < ρ_rim params.ρ_l "Rime density, `ρ_rim`, must be between 0 and ρ_l"
80+
0 ρ_rim params.ρ_l || throw(DomainError(ρ_rim, "Rime density, ρ_rim, must be 0 ≤ ρ_rim ≤ ρ_l"))
8181
return P3State(; params, L_ice, N_ice, F_rim, ρ_rim)
8282
end
8383

@@ -211,7 +211,7 @@ get_D_cr(mass::CMP.MassPowerLaw, F_rim, ρ_g) = _get_threshold(mass, ρ_g * (1 -
211211
# Returns
212212
- `(; D_th, D_gr, D_cr, ρ_g)`: The thresholds for the size distribution,
213213
and the density of total (deposition + rime) ice mass for graupel [kg/m³]
214-
214+
If `F_rim = 0`, then we set `D_gr = D_cr = Inf`, and `ρ_g = NaN` (should not be used).
215215
216216
See [`get_D_th`](@ref), [`get_D_gr`](@ref), [`get_D_cr`](@ref), and [`get_ρ_g`](@ref) for more details.
217217
"""
@@ -220,23 +220,27 @@ function get_thresholds_ρ_g(state::P3State)
220220
return get_thresholds_ρ_g(params, F_rim, ρ_rim)
221221
end
222222
function get_thresholds_ρ_g(params::CMP.ParametersP3, F_rim, ρ_rim)
223+
FT = eltype(F_rim)
223224
(; mass, ρ_i) = params
225+
D_th = get_D_th(mass, ρ_i)
226+
224227
ρ_d = get_ρ_d(mass, F_rim, ρ_rim)
225228
ρ_g = get_ρ_g(F_rim, ρ_rim, ρ_d)
226-
227-
D_th = get_D_th(mass, ρ_i)
228229
D_gr = get_D_gr(mass, ρ_g)
229230
D_cr = get_D_cr(mass, F_rim, ρ_g)
230231

232+
# If unrimed, set D_gr and D_cr to infinity
233+
D_gr = ifelse(iszero(F_rim), FT(Inf), D_gr)
234+
D_cr = ifelse(iszero(F_rim), FT(Inf), D_cr)
235+
231236
return (; D_th, D_gr, D_cr, ρ_g)
232237
end
233238

234-
function get_bounded_thresholds(state::P3State, D_min = 0, D_max = Inf)
235-
FT = eltype(state)
239+
function get_bounded_thresholds(
240+
state::P3State{FT}, D_min::FT = FT(0), D_max::FT = FT(Inf)
241+
) where {FT}
236242
(; D_th, D_gr, D_cr) = get_thresholds_ρ_g(state)
237-
thresholds = clamp.((FT(D_min), D_th, D_gr, D_cr, FT(D_max)), FT(D_min), FT(D_max))
238-
bounded_thresholds = replace(thresholds, NaN => FT(D_max))
239-
return bounded_thresholds
243+
return clamp.((D_min, D_th, D_gr, D_cr, D_max), D_min, D_max)
240244
end
241245

242246
"""
@@ -253,9 +257,9 @@ Return the segments of the size distribution as a tuple of intervals.
253257
For example, if the thresholds are `(D_th, D_gr, D_cr)`, then the segments are:
254258
- `(0, D_th)`, `(D_th, D_gr)`, `(D_gr, D_cr)`, `(D_cr, Inf)`
255259
"""
256-
function get_segments(state::P3State, D_min = 0, D_max = Inf)
257-
thresholds = get_bounded_thresholds(state, D_min, D_max)
258-
segments = tuple.(Base.front(thresholds), Base.tail(thresholds))
260+
function get_segments(state::P3State{FT}, D_min::FT = FT(0), D_max::FT = FT(Inf)) where {FT}
261+
(D_min, D_th, D_gr, D_cr, D_max) = get_bounded_thresholds(state, D_min, D_max)
262+
segments = ((D_min, D_th), (D_th, D_gr), (D_gr, D_cr), (D_cr, D_max))
259263
return segments
260264
end
261265

0 commit comments

Comments
 (0)