Skip to content

feat(Rosenbrock): unified RosenbrockAverage substepping framework + 2M+P3 exact-AD mode#726

Open
haakon-e wants to merge 8 commits into
he/ice-selfcol-trianglefrom
he/rosenbrock-2m-p3
Open

feat(Rosenbrock): unified RosenbrockAverage substepping framework + 2M+P3 exact-AD mode#726
haakon-e wants to merge 8 commits into
he/ice-selfcol-trianglefrom
he/rosenbrock-2m-p3

Conversation

@haakon-e

@haakon-e haakon-e commented Jun 12, 2026

Copy link
Copy Markdown
Member

What this adds

Introduces the RosenbrockAverage{Jacobian, GrowthTreatment, TendencyLimiter} options framework for averaged-tendency microphysics substepping, with its first use: the 2-moment warm-rain + P3 ice (Microphysics2Moment) exact-AD mode.

Each call divides Δt into nsub linearized-implicit (Rosenbrock-Euler) substeps; per substep it solves (I/h − J) Δ = f(x), clamps the updated state to nonnegative values, and advances temperature from the latent heat of the realized increment. f is the instantaneous tendency and J is the ForwardDiff Jacobian of f(x); logλ and q_tot are held fixed across the interval.

The mode is configured by:

  • Jacobian, the substep matrix. Options: ExactJacobian, the exact ForwardDiff derivative. (DonorJacobian and CoupledDonorJacobian, the donor-based linearizations, are 1M-only and arrive in feat(Rosenbrock): 1M donor / coupled / exact modes on the unified framework #731.)
  • GrowthTreatment, how the positive growth diagonal enters the operator. Options: ImplicitGrowth; ExplicitGrowthDiagonal, which zeros the positive growth diagonal so a growth mode is taken explicitly.
  • TendencyLimiter, a limiter on the realized increment. Options: NoLimiter; EndStateSaturationAdjustment, which scales the increment so the latent-heated end state stays at or above ice saturation.

The preset rosenbrock_exact() = ExactJacobian + ExplicitGrowthDiagonal + EndStateSaturationAdjustment is the 2M+P3 mode. On 2M+P3 only ExactJacobian is supported.

Coarse-step ice-growth instability

At cold, ice-supersaturated states carrying supercooled cloud liquid, the ice-growth tendency has an autocatalytic mode: rime mass grows by collecting cloud droplets, and the exact Jacobian carries this as a positive diagonal in the rime-mass (q_rim) row. At a representative isolated cell — ρ = 1.0 kg m⁻³, T = 263 K, q_tot = 10⁻², q_lcl = 2 × 10⁻³, n_lcl = 10⁸ m⁻³, q_ice = 2 × 10⁻³, n_ice = 10⁴ m⁻³, unrimed, ice supersaturation S_ice ≈ 1.8 — this diagonal is +5 × 10⁻² s⁻¹ (time scale ≈ 20 s, identical in Float32 and Float64), and it grows past 10⁻¹ s⁻¹ at colder, more liquid-rich states. The mode requires supercooled liquid: with the same ice state but no cloud liquid the rime-mass diagonal falls to order 10⁻⁵ s⁻¹, and the pure-deposition diagonal is negative there (vapor depletion opposes further deposition).

With ImplicitGrowth the implicit operator loses definiteness once the substep is coarse relative to the growth time scale, and a single coarse step overshoots saturation. ExplicitGrowthDiagonal removes that mode from the implicit operator and EndStateSaturationAdjustment supplies the nonlinear saturation bound; together they make the exact scheme robust across the resolved range of time steps. The crash itself is a property of the single-column convective configuration, not of an isolated cell: the growth diagonal above is an isolated-cell measurement, but the non-physical blow-up reproduces only in the coupled single-column run. docs/src/RosenbrockNumerics.md documents the mechanism, the resolution, the API and how to extend it, and the experiments that did not resolve it.

Conditioning

Per substep, a diagonal equilibration brings the number and mass rows (which differ by about nine orders of magnitude) to a comparable scale before the solve, and max(x + Δ, 0) is applied afterward; a non-finite Jacobian falls back to a forward-Euler substep. For the ImplicitGrowth configuration, a mask can advance near-empty species by forward Euler rather than including them in the implicit solve (the tendency gradients are very large at near-zero mass, which degrades the conditioning and accuracy of the solve).

Float32 robustness

The exact ForwardDiff Jacobian requires the tendency's derivatives to stay finite in single precision, where two sites did not:

  • sgs_weight_function (the rime-density blend): the sigmoid argument u can round to ±1 within the transition band, so atanh(±1) = ±Inf returns a finite value with a NaN derivative at rimed pure-ice states. This is chiefly a single-precision hazard, but the same band-edge rounding also occurs in Float64. The branch now uses the identity tanh(2·atanh(u)) = 2u/(1 + u²), which differentiates finitely across the band and matches the original value to within one to two ULP.
  • gamma_inc_moment: given an analytic ForwardDiff derivative (∂/∂α = −gamma_inc_moment(·, p+1, α), ∂/∂D = D^p e^{−αD}), so the closed-form rain integrals differentiate through the exact integrand rather than through a difference of near-equal incomplete gammas, which loses precision on the narrow subintervals from the fall-speed-crossing split. Values are unchanged.

Testing

Tests of the 2M+P3 exact mode: finite tendencies across Δt × nsub in Float32 and Float64; a non-ExactJacobian RosenbrockAverage and warm-rain-only (with_ice = false) parameters both throw; the rosenbrock_exact() bulk tendencies broadcast over ClimaCore fields; and an assertion that the exact 2M+P3 substep is type-stable (@inferred, JET @test_opt) and allocation-free. The exact-AD 2M+P3 substep is type-stable and allocation-free on the CI Julia versions; the allocation check uses an interpolated BenchmarkTools benchmark (as in bench_press), which measures the call's own allocations rather than a surrounding closure, so the assertion runs unconditionally.

Foundation for the 1M modes (#731) and the verbose diagnostics (#732).

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.49558% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.65%. Comparing base (355b541) to head (8ae90c4).

Additional details and impacted files
@@                     Coverage Diff                     @@
##           he/ice-selfcol-triangle     #726      +/-   ##
===========================================================
- Coverage                    92.81%   92.65%   -0.16%     
===========================================================
  Files                           56       57       +1     
  Lines                         2952     3065     +113     
===========================================================
+ Hits                          2740     2840     +100     
- Misses                         212      225      +13     
Components Coverage Δ
src 93.40% <88.49%> (-0.20%) ⬇️
ext 69.47% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 2171cac to ba40a9f Compare June 12, 2026 02:17
@haakon-e
haakon-e changed the base branch from he/ad-compat-2m-p3 to he/sentinel-return-types June 12, 2026 04:12
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 1e73ee4 to f250be2 Compare June 12, 2026 04:12
@haakon-e
haakon-e changed the base branch from he/sentinel-return-types to he/ad-compat-2m-p3 June 12, 2026 15:31
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch 4 times, most recently from ddc9f3a to 897e48f Compare June 12, 2026 18:21
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch 2 times, most recently from 7514748 to 415fe44 Compare June 14, 2026 02:02
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 897e48f to 730166f Compare June 14, 2026 02:02
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 415fe44 to b150213 Compare June 14, 2026 02:45
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch 2 times, most recently from 1fa016b to 9bffa2c Compare June 16, 2026 20:00
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 9bffa2c to 5ba20ad Compare June 17, 2026 21:20
@haakon-e haakon-e changed the title Add a Rosenbrock substepping mode for 2M+P3 feat(Rosenbrock): unified RosenbrockAverage substepping framework + 2M+P3 exact-AD mode Jun 17, 2026
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 5ba20ad to c161025 Compare June 18, 2026 21:42
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 643b5a8 to aef2c68 Compare June 18, 2026 22:41
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from c161025 to 326fb30 Compare June 18, 2026 22:41
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from d9cb1e6 to a5d4a45 Compare June 26, 2026 00:38
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from ee98c2d to 98e82b8 Compare June 26, 2026 01:22
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from a5d4a45 to 631d52a Compare June 26, 2026 01:22
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 98e82b8 to 6878dd5 Compare June 26, 2026 01:31
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 631d52a to 62a00be Compare June 26, 2026 01:31
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 6878dd5 to 044509d Compare July 1, 2026 05:34
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 62a00be to 0f9989e Compare July 1, 2026 05:34
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 044509d to 11eb605 Compare July 1, 2026 05:45
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 0f9989e to b3cba94 Compare July 1, 2026 05:46
@haakon-e
haakon-e force-pushed the he/ad-compat-2m-p3 branch from 11eb605 to ddd64f0 Compare July 1, 2026 06:24
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch 2 times, most recently from 4cc8acb to 5336a31 Compare July 1, 2026 15:01
Base automatically changed from he/ad-compat-2m-p3 to main July 1, 2026 22:48
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 5336a31 to 558fe72 Compare July 8, 2026 04:34
@haakon-e
haakon-e changed the base branch from main to he/ice-selfcol-triangle July 10, 2026 02:14
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch 2 times, most recently from 4eff57c to f91130e Compare July 10, 2026 23:15
@haakon-e
haakon-e force-pushed the he/ice-selfcol-triangle branch from bbb3da8 to 8a2faf4 Compare July 11, 2026 00:58
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch 2 times, most recently from 1082418 to 61d0407 Compare July 11, 2026 02:31
haakon-e added 8 commits July 10, 2026 20:24
…D substepping

Introduce the RosenbrockAverage{Jacobian, GrowthTreatment, TendencyLimiter}
tendency-mode framework and its two-moment + P3 exact-AD substep path.

- RosenbrockAverage struct, keyword constructor, and the rosenbrock_exact()
  preset (ExactJacobian + ExplicitGrowthDiagonal + EndStateSaturationAdjustment).
- The Jacobian / GrowthTreatment / TendencyLimiter option families with the
  concrete subtypes used by the exact preset.
- The 2M+P3 linearized-implicit (Rosenbrock-Euler) driver differentiating the
  raw instantaneous tendency with ForwardDiff, the channel projection, the
  equilibrated solve, and the ice-only end-state saturation adjustment.
- Documentation page (RosenbrockNumerics.md), API entries, and a 2M+P3
  ExactJacobian framework test.
…fields

Add a ClimaCore test that broadcasts bulk_microphysics_tendencies
(RosenbrockAverage exact Jacobian, 2M+P3) over column and extruded-sphere
spaces, exercising the highest-level substepping entry point. Gated to
Julia >= 1.12, matching the inference-depth limit of the existing 2M+P3
kernel tests.
The Rosenbrock/2M+P3 docstrings cross-reference internal helper functions
(e.g. the substep and per-process functions) that are not part of the
exported API and therefore have no `@docs` entry. Set
`warnonly = [:cross_references]` so these `@ref`s warn rather than fail the
documentation build.
…elpers

Add a dedicated error method for RosenbrockAverage with warm-rain-only
Microphysics2MParams, so the failure names the missing P3 ice parameters
instead of misreporting a Jacobian-choice problem.

Document the internal Rosenbrock substep helpers in an API.md docs block
and drop the site-wide warnonly cross-reference relaxation. Note on the
Jacobian abstract type which options have methods.
Add a Dual method for `gamma_inc_moment` that takes the value from the
plain-`Real` method and assembles the partials from the closed-form integrand:
`∂M/∂D₁ = -D₁ᵖ e^{-αD₁}`, `∂M/∂D₂ = D₂ᵖ e^{-αD₂}`, `∂M/∂α = -M(D₁, D₂, p+1, α)`.

Differentiating the plain method traces ForwardDiff through the difference of two
near-equal regularized incomplete gammas; on narrow subintervals this loses
precision and can flip the sign of the derivative in Float32. The analytic rule
avoids the differencing and leaves the primal values unchanged.
The regularised-ratio blend weight (`sgs_weight_function`, used by
`rime_density` and `rime_mass_fraction`) evaluated
`(1 + tanh(2 * atanh(u))) / 2` with `u = 1 - 2 * (1 - a)^(-1 / log2(1 - a_half))`.
In the sigmoid transition band, `u` rounds to exactly `±1` in single
precision (the half-ULP gap at `1` exceeds `2 * (1 - a)^p`), so `atanh(u)`
returns `±Inf`. The composed `tanh` recovers a finite value, but the
ForwardDiff derivative is `Inf * 0 = NaN`. The existing extreme-value guards
(`a > 42 * a_half`, `4 * a < eps`) do not cover this band for Float32, where
`42 * eps(Float32) ≈ 5e-6` overlaps the physical scale of the rime volume
`b_rim`.

Replace the branch with the algebraic identity
`tanh(2 * atanh(u)) = 2u / (1 + u^2)`, giving `(u + 1)^2 / (2 * (1 + u^2))`.
This removes the `atanh`/`tanh` saturation, is value-identical to within one
ULP across the band (byte-identical where the value is already `0`/`0.5`/`1`),
and has finite derivatives throughout. It unblocks the exact-AD 2M+P3
Jacobian at rimed pure-ice states, where `ρ_rim = q_rim / b_rim` previously
produced NaN partials.
… test

The exact-AD 2M+P3 Rosenbrock substep is type-stable and allocation-free on
all CI Julia versions; the previous `VERSION >= v"1.12"` guard hid a
test-harness artifact, not a real allocation. The `_framework_exact_call`
helper returned a closure that captured the element type `FT`; capturing a
type in a closure boxes it on Julia < 1.12, which made both `@inferred` report
a non-concrete return and `@allocated` count the boxed call.

Return a materialized argument tuple instead of an `FT`-capturing closure, and
measure allocations with an interpolated `BenchmarkTools` benchmark (as in
`bench_press`), which reports the call's own allocations rather than the
surrounding closure. The `@inferred`, JET `@test_opt`, and zero-allocation
assertions then pass on Julia 1.10, 1.11, and 1.12, so the guard is removed.
@haakon-e
haakon-e force-pushed the he/ice-selfcol-triangle branch from dc1db96 to 355b541 Compare July 11, 2026 03:28
@haakon-e
haakon-e force-pushed the he/rosenbrock-2m-p3 branch from 61d0407 to 8ae90c4 Compare July 11, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant