Skip to content

Commit f004be0

Browse files
committed
feat(P3): add more P3 ice tendencies
new tendencies: - 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 cc6bda2 commit f004be0

11 files changed

+369
-79
lines changed

src/BulkMicrophysicsTendencies.jl

Lines changed: 91 additions & 41 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_M(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,13 +823,26 @@ 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

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+
819839
# Unpack warm rain parameters (always present)
820840
aps = mp.warm_rain.air_properties
841+
subdep = mp.warm_rain.subdep
821842

822843
# Initialize ice-related tendencies
823844
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)
845+
dn_ice_dt = zero(ρ) # Ice number tendency (changes due to melting, aggregation)
826846
dq_rim_dt = zero(ρ)
827847
db_rim_dt = zero(ρ)
828848

@@ -833,51 +853,24 @@ to be non-Nothing, eliminating runtime type checks and dynamic dispatch.
833853
dq_rai_dt = warm.dq_rai_dt
834854
dn_rai_dt = warm.dn_rai_dt
835855

836-
# Convert to number densities for remaining functions
837-
N_lcl = ρ * n_lcl
838-
N_rai = ρ * n_rai
839-
840856
# --- P3 Ice Processes ---
841857
p3 = mp.ice.scheme
842858
vel = mp.ice.terminal_velocity
843859
pdf_c = mp.ice.cloud_pdf
844860
pdf_r = mp.ice.rain_pdf
861+
heterogeneous = mp.ice.heterogeneous
862+
deposition_condfreeze = mp.ice.deposition_condfreeze
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³]
866+
if q_ice > ϵₘ && n_ice > ϵₙ
867+
state = CMP3.P3State(p3, L_ice, N_ice, F_rim, ρ_rim)
859868

860869
# Liquid-ice collision sources (core P3 process)
861870
# Only compute if there is ice present
862871
if L_ice > zero(L_ice) && N_ice > zero(N_ice)
863872
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,
873+
state, logλ, pdf_c, pdf_r, L_lcl, N_lcl, L_rai, N_rai, aps, tps, vel, ρ, T,
881874
)
882875

883876
# Add collision tendencies
@@ -895,7 +888,6 @@ to be non-Nothing, eliminating runtime type checks and dynamic dispatch.
895888
# Ice melting (above freezing temperature)
896889
T_freeze = TDI.TD.Parameters.T_freeze(tps)
897890
if T > T_freeze && L_ice > zero(L_ice)
898-
state = CMP3.P3State(p3, L_ice, N_ice, F_rim, ρ_rim)
899891
melt = CMP3.ice_melt(vel, aps, tps, T, ρ, state, logλ)
900892

901893
# Melting converts ice to rain
@@ -907,9 +899,67 @@ to be non-Nothing, eliminating runtime type checks and dynamic dispatch.
907899
end
908900
end
909901

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)
902+
# --- --------------- ---
903+
# --- Ice Nucleation ---
904+
# --- --------------- ---
905+
906+
# Note: Intuitively, you choose one of two parameterization pathways,
907+
# either 1. process-based (e.g. using CM_HetIce.deposition_J, etc.) or
908+
# 2. empirical (e.g. using CM_HetIce.INP_concentration_mean)
909+
# 1. In the former, you try to differentiate between different aerosol types
910+
# and their properties, homogeneous freezing, heterogeneous deposition
911+
# freezing, heterogeneous immersion freezing, etc.
912+
# 2. The latter, which we adopt here, empirically relates some environmental
913+
# conditions (e.g. temperature) to the number of ice crystals nucleated.
914+
# We do not track the aerosol population, and do not consider different
915+
# nucleation pathways explicitly.
916+
917+
# --- Ice Nucleation (empirical) ---
918+
# Assume mass loss is mean condensate mass
919+
m_lcl = ifelse(n_lcl > ϵₙ, q_lcl / n_lcl, zero(q_lcl)) # mean liquid mass
920+
921+
# TODO: The parameterixation should return a rate, `∂N/∂t`, not number changes `ΔN`
922+
inpc = CM_HetIce.INP_concentration_mean(heterogeneous, T) / ρ # [particles / kg air]
923+
n_het = max(0, inpc - n_ice) # [particles / kg air]
924+
q_het = n_het * m_lcl # [kg ice / kg air]
925+
926+
dn_ice_dt += n_het
927+
dq_ice_dt += q_het
928+
dq_rim_dt += q_het
929+
db_rim_dt += q_het / p3.ρ_i # ρ_i = 916.7 kg m⁻³, the density of solid bulk ice
930+
dn_lcl_dt -= n_het
931+
dq_lcl_dt -= q_het
932+
933+
# --- Cloud Droplet Condensation Freezing ---
934+
# ref: `homogeneous_freezing` in `parcel/ParcelTendencies.jl`
935+
# get mean diameter of cloud droplets, then convert to volume
936+
937+
# --- Ice Sublimation / Deposition ---
938+
# Deposition/sublimation of cloud ice
939+
∂ₜ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)
940+
# No ice deposition above freezing (lack of INPs)
941+
∂ₜq_ice_dep = ifelse(T > tps.T_freeze, min(∂ₜq_ice_dep, zero(T)), ∂ₜq_ice_dep)
942+
# During sublimation, the number of ice particles decreases in proportion to the mean ice mass
943+
# During deposition, the number of ice particles remain unchanged
944+
∂ₜn_ice_dep = ifelse(∂ₜq_ice_dep < 0, (n_ice / q_ice) * ∂ₜq_ice_dep, zero(∂ₜq_ice_dep))
945+
dq_ice_dt += ∂ₜq_ice_dep
946+
dn_ice_dt += ∂ₜn_ice_dep
947+
948+
# --- Ice Self-collection (Aggregation) ---
949+
if q_ice > ϵₘ && n_ice > ϵₙ
950+
state = CMP3.P3State(p3, L_ice, N_ice, F_rim, ρ_rim)
951+
S_ice_agg = CMP3.ice_self_collection(state, logλ, aps, tps, vel, ρ, T)
952+
dn_ice_dt -= S_ice_agg.dNdt / ρ
953+
end
954+
955+
# --- Rain Heterogeneous Freezing ---
956+
# TODO: Implement heterogeneous freezing of rain
957+
# This process is currently missing in P3_processes.jl
958+
# S_rai_frz = ...
959+
# dq_rai_dt -= S_rai_frz
960+
# dq_ice_dt += S_rai_frz
961+
962+
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)
913963
end
914964

915965
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)