Skip to content

Commit a9cb0c4

Browse files
committed
Add 1M AD-Rosenbrock averaged-tendency mode
Add a 1-moment RosenbrockAverage option that linearizes the raw 1M instantaneous tendency with the exact ForwardDiff Jacobian at each substep, alongside the existing hand-built LinearizedAverage (which is left behavior-unchanged). It reuses the dimension-generic Rosenbrock substep kernel: a MicroState1M{4} FieldVector, a Raw1MTendency functor, a four-mass-channel mask, and the unchanged _rosenbrock_update. Make the 1M kernel stack AD-ready. As written, every kernel bound the working numeric type to the parameter type (q::FT, ρ::FT with params{FT} sharing one FT), so a Dual q with float ρ/params failed dispatch throughout and the tendency could not be differentiated. Loosen the mass/q arguments to be unconstrained relative to the parameter type (params, ρ, collision efficiencies stay FT) across get_n0, lambda_inverse, terminal_velocity, accretion, accretion_rain_sink, accretion_snow_rain, and the evaporation/sublimation/melt/autoconversion process kernels. Each early-return or accumulator seed that would otherwise mismatch under mixed Dual/float arguments is keyed to the promotion of its inputs, keeping uniform-float behavior bit-identical and the hot path allocation-free. The differentiated path needs no parameter-type converter. Add test/rosenbrock_1m_tests.jl: convergence versus a fine explicit reference across substep counts, agreement with LinearizedAverage at refined substepping, zero-allocation and inference-clean hot call on Float32 and Float64, and finite, non-negative behavior across regimes.
1 parent f227cd9 commit a9cb0c4

5 files changed

Lines changed: 429 additions & 29 deletions

File tree

src/BMT_rosenbrock.jl

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,165 @@ fields as the `Instantaneous` entry (without the activation diagnostic).
219219
Tuple(rates),
220220
)
221221
end
222+
223+
#####
224+
##### 1M Rosenbrock-Euler substepping (`RosenbrockAverage`)
225+
#####
226+
227+
"""
228+
MicroState1M{FT}
229+
230+
The four prognostic 1M species as a `StaticArrays.FieldVector`: vector algebra
231+
for substep updates and linear solves, named fields for readability. Internal
232+
to the [`RosenbrockAverage`](@ref) implementation, mirroring
233+
[`MicroState2MP3`](@ref) at dimension 4 (1M carries no number species).
234+
"""
235+
struct MicroState1M{FT} <: SA.FieldVector{4, FT}
236+
q_lcl::FT
237+
q_icl::FT
238+
q_rai::FT
239+
q_sno::FT
240+
end
241+
SA.similar_type(::Type{<:MicroState1M}, ::Type{FT}, ::SA.Size{(4,)}) where {FT} =
242+
MicroState1M{FT}
243+
244+
"""
245+
_instantaneous_1m_tendency(mp, tps, ρ, T, q_tot, q_lcl, q_icl, q_rai, q_sno)
246+
247+
The raw instantaneous 1M tendency projected onto the four prognostic species:
248+
the unlimited process rates of the `Microphysics1Moment` `Instantaneous` entry
249+
(`_microphysics_source_terms` aggregated by `_aggregate_tendencies`), without
250+
timestep-dependent clipping.
251+
252+
This is the function the 1M Rosenbrock step differentiates — the same raw RHS
253+
the hand-built `LinearizedAverage` donor-linearizes (the hand operator is the
254+
system matrix of a donor-modified problem, not `∂f/∂q`). The shipped 1M
255+
`LinearizedAverage` applies no supersaturation cap or coupled-sink limiter, so
256+
differentiating the raw tendency is the faithful counterpart of that scheme.
257+
"""
258+
@inline function _instantaneous_1m_tendency(mp, tps,
259+
ρ, T, q_tot, q_lcl, q_icl, q_rai, q_sno,
260+
)
261+
src = _microphysics_source_terms(Microphysics1Moment(), mp, tps,
262+
ρ, T, q_tot, q_lcl, q_icl, q_rai, q_sno,
263+
)
264+
return _aggregate_tendencies(src)
265+
end
266+
267+
"""
268+
Raw1MTendency(mp, tps, ρ, T, q_tot)
269+
270+
Callable bundling the frozen per-substep context; applying it to the species
271+
vector evaluates [`_instantaneous_1m_tendency`](@ref). A top-level struct
272+
rather than a closure, so `ForwardDiff` differentiates a concretely-typed
273+
callable, mirroring [`Instantaneous2MP3Tendency`](@ref).
274+
275+
The frozen `q_tot` is promoted to the state's element type at the call so a
276+
zero-partial Dual is exact for the constant and the promotion-keyed sentinel
277+
returns in the 1M kernels stay concretely typed (a no-op in the primal pass);
278+
`T` and `ρ` stay plain because they feed working-type computations that must
279+
remain floats. The differentiability of the 1M kernels w.r.t. the mass
280+
channels (with `ρ`/`T`/params held float) is provided by their AD-readiness:
281+
the collected/collecting masses are unconstrained relative to the parameter
282+
type, so a Dual working type flows through without a parameter-type converter.
283+
"""
284+
struct Raw1MTendency{P, H, F}
285+
mp::P
286+
tps::H
287+
ρ::F
288+
T::F
289+
q_tot::F
290+
end
291+
@inline function (g::Raw1MTendency)(x::SA.StaticVector{4})
292+
(q_lcl, q_icl, q_rai, q_sno) = x
293+
tend = _instantaneous_1m_tendency(g.mp, g.tps,
294+
g.ρ, g.T, eltype(x)(g.q_tot),
295+
q_lcl, q_icl, q_rai, q_sno,
296+
)
297+
return MicroState1M(tend.dq_lcl_dt, tend.dq_icl_dt, tend.dq_rai_dt, tend.dq_sno_dt)
298+
end
299+
300+
"""
301+
_rosenbrock_channel_mask(x::MicroState1M)
302+
303+
Diagonal of the channel projection `P` for the 1M state: 1 for healthy
304+
channels, 0 for near-empty ones (mass below `1e-10`, per channel: liquid, ice,
305+
rain, snow). With no number species the mask is the four mass channels
306+
directly. See the [`MicroState2MP3`](@ref) method for the role of `P` in
307+
[`_rosenbrock_update`](@ref).
308+
"""
309+
@inline function _rosenbrock_channel_mask(x::MicroState1M{FT}) where {FT}
310+
ϵ_empty = FT(1e-10)
311+
lcl = ifelse(x.q_lcl < ϵ_empty, zero(FT), one(FT))
312+
icl = ifelse(x.q_icl < ϵ_empty, zero(FT), one(FT))
313+
rai = ifelse(x.q_rai < ϵ_empty, zero(FT), one(FT))
314+
sno = ifelse(x.q_sno < ϵ_empty, zero(FT), one(FT))
315+
return MicroState1M(lcl, icl, rai, sno)
316+
end
317+
318+
"""
319+
bulk_microphysics_tendencies(::RosenbrockAverage, ::Microphysics1Moment,
320+
mp, tps, ρ, T, q_tot, q_lcl, q_icl, q_rai, q_sno, Δt, nsub = 1)
321+
322+
Compute average 1M microphysics tendencies over `Δt` using `nsub`
323+
linearized-implicit (Rosenbrock-Euler) substeps of the raw instantaneous
324+
tendency. A separate option from the hand-built `LinearizedAverage`: this
325+
linearizes with the exact `ForwardDiff` Jacobian of the raw tendency at each
326+
substep instead of a donor-cell-modified system matrix.
327+
328+
# Algorithm
329+
330+
For each substep of `h = Δt / nsub`:
331+
332+
1. Evaluate the raw tendency `f` ([`_instantaneous_1m_tendency`](@ref)) and its
333+
exact 4×4 Jacobian `J` via `ForwardDiff` at the current state.
334+
2. Advance with [`_rosenbrock_update`](@ref): solve `(I/h - P J P) Δx = f` in
335+
equilibrated variables, where the projection `P` from
336+
[`_rosenbrock_channel_mask`](@ref) routes near-empty channels to forward
337+
Euler.
338+
3. Update the local temperature from the latent heating of the realized
339+
increments, matching the `LinearizedAverage` 1M convention (constant latent
340+
heats, liquid+rain on `L_v`, ice+snow on `L_s`).
341+
342+
A non-finite state or Jacobian falls back to a forward-Euler substep of the raw
343+
tendency; `q_tot` is held fixed across substeps. The discrete safeguards are
344+
h-free conditioning and projection (channel mask, equilibration, positivity
345+
clamp), not model terms.
346+
347+
Returns the net change in the species over `Δt` divided by `Δt`, in the same
348+
fields as the `Instantaneous` entry.
349+
"""
350+
@inline function bulk_microphysics_tendencies(::RosenbrockAverage, cm::Microphysics1Moment,
351+
mp::CMP.Microphysics1MParams, tps,
352+
ρ, T, q_tot, q_lcl, q_icl, q_rai, q_sno, Δt, nsub = 1,
353+
)
354+
FT = typeof(q_tot)
355+
nsub_eff = max(Int(nsub), 1)
356+
h = Δt / FT(nsub_eff)
357+
Lv_over_cp = TDI.TD.Parameters.LH_v0(tps) / TDI.TD.Parameters.cp_d(tps)
358+
Ls_over_cp = TDI.TD.Parameters.LH_s0(tps) / TDI.TD.Parameters.cp_d(tps)
359+
360+
x = MicroState1M{FT}(q_lcl, q_icl, q_rai, q_sno)
361+
x₀ = x
362+
Tsub = T
363+
for _ in 1:nsub_eff
364+
g = Raw1MTendency(mp, tps, ρ, Tsub, q_tot)
365+
f = g(x)
366+
x_prev = x
367+
x = if all(isfinite, x)
368+
J = FD.jacobian(g, x)
369+
z = _rosenbrock_channel_mask(x)
370+
all(isfinite, J) ? _rosenbrock_update(x, f, J, z, h) : _euler_update(x, f, h)
371+
else
372+
_euler_update(x, f, h)
373+
end
374+
Δ = x - x_prev
375+
Tsub += Lv_over_cp *.q_lcl + Δ.q_rai) + Ls_over_cp *.q_icl + Δ.q_sno)
376+
end
377+
378+
rates = (x - x₀) / Δt
379+
return (;
380+
dq_lcl_dt = rates.q_lcl, dq_icl_dt = rates.q_icl,
381+
dq_rai_dt = rates.q_rai, dq_sno_dt = rates.q_sno,
382+
)
383+
end

src/BulkMicrophysicsTendencies.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ struct LinearizedAverage <: TendencyMode end
123123
Return time-averaged tendencies computed via repeated linearized-implicit
124124
(Rosenbrock-Euler) substeps, linearizing with the exact `ForwardDiff` Jacobian
125125
of the raw instantaneous pointwise tendency at each substep. Implemented for the
126-
2-moment + P3 configuration.
126+
1-moment and the 2-moment + P3 configurations; a separate option from the
127+
hand-built `LinearizedAverage` (which uses a donor-cell-modified system matrix).
127128
"""
128129
struct RosenbrockAverage <: TendencyMode end
129130

src/Microphysics1M.jl

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,13 @@ Returns the intercept parameter of the assumed Marshall-Palmer distribution
8080
- `q_sno`: snow specific content (snow only)
8181
- `ρ`: air density (snow only)
8282
"""
83-
@inline get_n0((; ν, μ)::CMP.ParticlePDFSnow{FT}, q_sno::FT, ρ::FT) where {FT} =
84-
q_sno > UT.ϵ_numerics(FT) ? μ ** q_sno)^ν : zero(FT)
83+
# `q_sno`/`ρ` are left unconstrained relative to the parameter type `FT` so a
84+
# `ForwardDiff.Dual` working type (from differentiating the 1M tendency w.r.t.
85+
# the mass channels) flows through; the sentinel is typed by the promoted type
86+
# to stay concrete under mixed Dual/float arguments. Uniform-`FT` calls are
87+
# unaffected.
88+
@inline get_n0((; ν, μ)::CMP.ParticlePDFSnow{FT}, q_sno, ρ) where {FT} =
89+
q_sno > UT.ϵ_numerics(FT) ? μ ** q_sno)^ν : zero(UT.promote_typeof(q_sno, ρ, μ))
8590
@inline get_n0((; n0)::CMP.ParticlePDFIceRain{FT}, args...) where {FT} = n0
8691

8792
"""
@@ -125,15 +130,18 @@ average particles. The value is clipped at `r0 * 1e-5` to prevent numerical issu
125130
#(; pdf, mass)::Union{CMP.Snow{FT}, CMP.Rain{FT}, CMP.CloudIce{FT}},
126131
pdf::Union{CMP.ParticlePDFIceRain{FT}, CMP.ParticlePDFSnow{FT}},
127132
mass::CMP.ParticleMass{FT},
128-
q::FT,
129-
ρ::FT,
133+
q,
134+
ρ,
130135
) where {FT}
136+
# `q`/`ρ` may be a Dual working type while the params stay `FT`; key the
137+
# working/result type off the promotion so it is concrete in either case.
138+
R = UT.promote_typeof(q, ρ, mass.r0)
131139
# size distribution
132-
n0::FT = get_n0(pdf, q, ρ)
140+
n0 = get_n0(pdf, q, ρ)
133141
# mass(size)
134142
(; r0, m0, me, Δm, χm, gamma_coeff) = mass
135143

136-
λ_inv = FT(0)
144+
λ_inv = zero(R)
137145
if q > UT.ϵ_numerics(FT) && ρ > UT.ϵ_numerics(FT)
138146
# Note: Julia compiles x^y to exp(y * log(x))
139147
# gamma_coeff is pre-computed in ParticleMass constructor for GPU performance
@@ -211,11 +219,14 @@ Fall velocity of individual particles is parameterized:
211219
# Returns
212220
- Mass-weighted terminal velocity [m/s]
213221
"""
222+
# `ρ`/`q` are unconstrained relative to the parameter type `FT` so a Dual
223+
# working type flows through; the zero branch is typed by the promotion to stay
224+
# concrete under mixed Dual/float arguments. Uniform-`FT` calls are unchanged.
214225
@inline function terminal_velocity(
215226
(; pdf, mass)::Union{CMP.Rain, CMP.Snow},
216227
vel::Union{CMP.Blk1MVelTypeRain{FT}, CMP.Blk1MVelTypeSnow{FT}},
217-
ρ::FT,
218-
q::FT,
228+
ρ,
229+
q,
219230
) where {FT}
220231
if q > UT.ϵ_numerics(FT)
221232
# terminal_velocity(size)
@@ -230,7 +241,7 @@ Fall velocity of individual particles is parameterized:
230241
# gamma_coeff = SF.gamma(me + Δm + 1) (pre-computed in mass)
231242
return χv * v0 * (λ_inv / r0)^(ve + Δv) * gamma_term / gamma_coeff
232243
else
233-
return FT(0)
244+
return zero(UT.promote_typeof(ρ, q, vel.χv))
234245
end
235246
end
236247

@@ -395,7 +406,11 @@ end
395406
(; pdf, mass) = mp.cloud.ice
396407
aps = mp.air_properties
397408
FT = eltype(ρ)
398-
acnv_rate = FT(0)
409+
# Differentiating the 1M tendency w.r.t. the mass channels makes the `q_*`
410+
# a Dual working type while `ρ` (hence `FT`) stays float; seed the rate by
411+
# the promotion so the sentinel matches the active branch. `FT`-keyed
412+
# constants/thresholds are unchanged, preserving uniform-`FT` behavior.
413+
acnv_rate = zero(UT.promote_typeof(q_icl, q_lcl, q_rai, q_sno, ρ))
399414
S = TDI.supersaturation_over_ice(tps, q_tot, q_lcl + q_rai, q_icl + q_sno, ρ, T)
400415

401416
# Only allow ice autoconversion below freezing with positive supersaturation
@@ -449,21 +464,26 @@ Internal low-level kernel. Prefer the option-dispatched API.
449464
- `q_pre`: rain or snow specific content
450465
- `ρ`: air density
451466
"""
467+
# The collected/collecting masses `q_clo`/`q_pre` are unconstrained relative to
468+
# the parameter type `FT` so a Dual working type flows through when the 1M
469+
# tendency is differentiated w.r.t. the mass channels; `E`/`ρ` stay `FT`. The
470+
# sentinel is typed by the promotion to stay concrete under mixed Dual/float
471+
# arguments. Uniform-`FT` calls keep their original behavior exactly.
452472
@inline function accretion(
453473
cloud::CMP.CloudCondensateType,
454474
precip::CMP.PrecipitationType,
455475
vel::Union{CMP.Blk1MVelTypeRain{FT}, CMP.Blk1MVelTypeSnow{FT}},
456476
E::FT,
457-
q_clo::FT,
458-
q_pre::FT,
459-
ρ::FT,
477+
q_clo,
478+
q_pre,
479+
ρ,
460480
) where {FT}
461481

462-
accr_rate = FT(0)
482+
accr_rate = zero(UT.promote_typeof(q_clo, q_pre, ρ, E))
463483
if (q_clo > UT.ϵ_numerics(FT) && q_pre > UT.ϵ_numerics(FT))
464484

465-
n0::FT = get_n0(precip.pdf, q_pre, ρ)
466-
v0::FT = get_v0(vel, ρ)
485+
n0 = get_n0(precip.pdf, q_pre, ρ)
486+
v0 = get_v0(vel, ρ)
467487

468488
(; r0) = precip.mass
469489
(; χv, ve, Δv, gamma_accr) = vel
@@ -483,16 +503,19 @@ end
483503
#
484504
# Returns the sink of rain water (partial source of snow) due to collisions
485505
# with cloud ice.
506+
# `q_icl`/`q_rai` are unconstrained relative to the parameter type `FT` so a Dual
507+
# working type flows through; `E`/`ρ` stay `FT`. The sentinel is typed by the
508+
# promotion to stay concrete under mixed Dual/float arguments.
486509
@inline function accretion_rain_sink(
487510
rain::CMP.Rain,
488511
ice::CMP.CloudIce,
489512
vel::CMP.Blk1MVelTypeRain{FT},
490513
E::FT,
491-
q_icl::FT,
492-
q_rai::FT,
493-
ρ::FT,
514+
q_icl,
515+
q_rai,
516+
ρ,
494517
) where {FT}
495-
accr_rate = FT(0)
518+
accr_rate = zero(UT.promote_typeof(q_icl, q_rai, ρ, E))
496519
if (q_icl > UT.ϵ_numerics(FT) && q_rai > UT.ϵ_numerics(FT))
497520

498521
n0_ice = get_n0(ice.pdf)
@@ -538,19 +561,22 @@ deviations are proportional to the mean fall velocities, with coefficient
538561
- `q_i`, `q_j`: specific contents of snow or rain [kg/kg]
539562
- `ρ`: air density [kg/m³]
540563
"""
564+
# `q_i`/`q_j` are unconstrained relative to the parameter type `FT` so a Dual
565+
# working type flows through; `E_ij`/`coeff_disp`/`ρ` stay `FT`. The sentinel is
566+
# typed by the promotion to stay concrete under mixed Dual/float arguments.
541567
@inline function accretion_snow_rain(
542568
type_i::CMP.PrecipitationType,
543569
type_j::CMP.PrecipitationType,
544570
blk1mveltype_ti,
545571
blk1mveltype_tj,
546572
E_ij::FT,
547573
coeff_disp::FT,
548-
q_i::FT,
549-
q_j::FT,
550-
ρ::FT,
574+
q_i,
575+
q_j,
576+
ρ,
551577
) where {FT}
552578

553-
accr_rate = FT(0)
579+
accr_rate = zero(UT.promote_typeof(q_i, q_j, ρ, E_ij))
554580
if (q_i > UT.ϵ_numerics(FT) && q_j > UT.ϵ_numerics(FT))
555581

556582
n0_i = get_n0(type_i.pdf, q_i, ρ)
@@ -695,7 +721,10 @@ Only evaporation is considered (sub-saturated over liquid); result is clamped
695721
vel = mp.terminal_velocity.rain
696722
aps = mp.air_properties
697723
FT = eltype(ρ)
698-
evap_rate = FT(0)
724+
# Seed the rate by the promotion: under differentiation w.r.t. the mass
725+
# channels the `q_*` are a Dual working type while `FT` stays float, so the
726+
# sentinel must match the active branch. `FT`-keyed constants are unchanged.
727+
evap_rate = zero(UT.promote_typeof(q_rai, q_lcl, q_icl, q_sno, ρ))
699728

700729
if q_rai > UT.ϵ_numerics(FT)
701730
S = TDI.supersaturation_over_liquid(tps, q_tot, q_lcl + q_rai, q_icl + q_sno, ρ, T)
@@ -760,7 +789,9 @@ end
760789
vel = mp.terminal_velocity.snow
761790
aps = mp.air_properties
762791
FT = eltype(ρ)
763-
subl_rate = FT(0)
792+
# Seed the rate by the promotion so the sentinel matches the active branch
793+
# when the `q_*` are a Dual working type. `FT`-keyed constants are unchanged.
794+
subl_rate = zero(UT.promote_typeof(q_sno, q_lcl, q_icl, q_rai, ρ))
764795

765796
if q_sno > UT.ϵ_numerics(FT)
766797
(; ν_air, D_vapor) = aps
@@ -811,7 +842,9 @@ Returns the tendency due to cloud ice melt.
811842
(; pdf, mass) = mp.cloud.ice
812843
(; K_therm) = mp.air_properties
813844
FT = eltype(ρ)
814-
cloud_ice_melt_rate = FT(0)
845+
# Seed by the promotion so the sentinel matches the Dual working type that
846+
# `q_icl` carries under differentiation. `FT`-keyed constants are unchanged.
847+
cloud_ice_melt_rate = zero(UT.promote_typeof(q_icl, ρ))
815848
T_freeze = TDI.T_freeze(tps)
816849

817850
if (q_icl > UT.ϵ_numerics(FT) && T > T_freeze)
@@ -845,7 +878,9 @@ Returns the tendency due to snow melt.
845878
vel = mp.terminal_velocity.snow
846879
aps = mp.air_properties
847880
FT = eltype(ρ)
848-
snow_melt_rate = FT(0)
881+
# Seed by the promotion so the sentinel matches the Dual working type that
882+
# `q_sno` carries under differentiation. `FT`-keyed constants are unchanged.
883+
snow_melt_rate = zero(UT.promote_typeof(q_sno, ρ))
849884
T_freeze = TDI.T_freeze(tps)
850885

851886
if (q_sno > UT.ϵ_numerics(FT) && T > T_freeze)

0 commit comments

Comments
 (0)