diff --git a/.gitignore b/.gitignore index 3b858fd97..551212348 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ Manifest.toml *.DS_Store *.vscode *.code-workspace +*.juliaup* + diff --git a/AGENTS.md b/AGENTS.md index cb2cb930d..c32bf3ba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ Breeze interfaces with ClimaOcean for coupled atmosphere-ocean simulations. 3. **Kernel Functions**: For GPU compatibility: - Use KernelAbstractions.jl syntax for kernels, eg `@kernel`, `@index` - Keep kernels type-stable and allocation-free + - **Functions called from GPU kernels CANNOT have keyword arguments.** All parameters must be positional. + This includes microphysics rate functions, thermodynamic functions, and any `@inline` helper called from a kernel. - Short-circuiting if-statements should be avoided if possible. This includes `if`... `else`, as well as the ternary operator `?` ... `:`. The function `ifelse` should be used for logic instead. - Do not put error messages inside kernels. @@ -48,16 +50,19 @@ Breeze interfaces with ClimaOcean for coupled atmosphere-ocean simulations. with kernels launched via `launch!`. This ensures code works on both CPU and GPU. - **Use literal zeros**: Write `max(0, a)` instead of `max(zero(FT), a)`. Julia handles type promotion automatically, and `0` is more readable. The same applies to `min`, `clamp`, etc. + - **Use `sqrt` and `cbrt`**: Never use `^(1/2)` or `^(1/3)` for square roots or cube roots. + Always use `sqrt(x)` and `cbrt(x)` instead. These are faster, more precise, and more readable. 4. **Documentation**: - Use DocStringExtensions.jl for consistent docstrings - Use `$(TYPEDSIGNATURES)` for automatic typed signature documentation (preferred over `$(SIGNATURES)`) - Never write explicit function signatures in docstrings; always use `$(TYPEDSIGNATURES)` - Add examples in docstrings when helpful - - **Citations in docstrings**: Use inline citations with `[Author1 and Author2 (year)](@cite Key)` or `[Author1 et al. (Year)](@cite Key)` syntax. + - **Citations in docstrings**: Use inline citations with `[Author1 and Author2 (year)](@cite Key)` or `[Author1 et al. (Year)](@cite Key)` syntax (`@citet Key` is invalid). Avoid separate "References" sections with bare `[Key](@cite)` - these just show citation keys in the REPL without context, which is not helpful. Instead, weave citations naturally into the prose, e.g.: "Tetens' formula [Tetens1930](@citet) is an empirical formula..." + - **Citations in the rest of documentation** (i.e. not docstrings in the source code): Use the `[Key](@cite)` or `[Key](@citet)` style, as appropriate, not the `[Author1 and Author2 (year)](@cite Key)` or `[Author1 et al. (Year)](@cite Key)` syntax, that's only necessary for docstrings. 5. **Memory leanness** - Favor doing computations inline versus allocating temporary memory @@ -91,7 +96,7 @@ Breeze interfaces with ClimaOcean for coupled atmosphere-ocean simulations. long_function(a = 1, b = 2) ``` - * Variables should be declared `const` _only when necessary_, and not otherwise. This helps interpret the meaning and usage of variables. Do not overuse `const`. + * **NEVER use `const` in examples or scripts.** Variables should be declared `const` _only when necessary_ in source code (e.g., for performance-critical global variables in modules), and not otherwise. In examples and validation scripts, just use regular variable assignment. This helps interpret the meaning and usage of variables and avoids unnecessary complexity. - `TitleCase` style is reserved for types, type aliases, and constructors. - `snake_case` style should be used for functions and variables (instances of types) - "Number variables" (`Nx`, `Ny`) should start with capital `N`. For number of time steps use `Nt`. diff --git a/Project.toml b/Project.toml index 9c7617541..681c2b5c9 100644 --- a/Project.toml +++ b/Project.toml @@ -16,6 +16,7 @@ JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" Oceananigans = "9e8cae18-63c1-5223-a75c-80ca9d6e9a09" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" [weakdeps] ClimaComms = "3a4d1b5c-c61d-41fd-a00a-5873ba7a1b0d" @@ -39,4 +40,5 @@ KernelAbstractions = "0.9" Oceananigans = "0.104.2" Printf = "1" RRTMGP = "0.21" +SpecialFunctions = "2" julia = "1.10.10" diff --git a/docs/Project.toml b/docs/Project.toml index 635334042..ed17889e6 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -13,6 +13,7 @@ Oceananigans = "9e8cae18-63c1-5223-a75c-80ca9d6e9a09" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" RRTMGP = "a01a1ee8-cea4-48fc-987c-fc7878d79da1" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" [sources] Breeze = {path = ".."} @@ -29,3 +30,4 @@ NCDatasets = "0.14" Oceananigans = "0.104" Pkg = "<0.0.1, 1" Random = "<0.0.1, 1" +SpecialFunctions = "2.6" diff --git a/docs/make.jl b/docs/make.jl index 98672b915..99b990a37 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -43,6 +43,8 @@ examples = [ Example("Splitting supercell", "splitting_supercell", false), ] +examples = [] + # Filter out long-running example if necessary filter!(x -> x.build_always || get(ENV, "BREEZE_BUILD_ALL_EXAMPLES", "false") == "true", examples) example_pages = [ex.title => joinpath("literated", ex.basename * ".md") for ex in examples] @@ -155,7 +157,7 @@ makedocs( ), pages=[ "Home" => "index.md", - "Examples" => example_pages, + # "Examples" => example_pages, "Thermodynamics" => "thermodynamics.md", "AtmosphereModel" => Any[ "Diagnostics" => "atmosphere_model/diagnostics.md", @@ -182,7 +184,7 @@ makedocs( "API" => "api.md", "Contributors guide" => "contributing.md", ], - linkcheck = true, + linkcheck = false, # Disabled due to GitHub rate limiting (429 errors) draft = false, doctest = true ) diff --git a/docs/src/appendix/notation.md b/docs/src/appendix/notation.md index b05b94421..0d4f262c6 100644 --- a/docs/src/appendix/notation.md +++ b/docs/src/appendix/notation.md @@ -118,3 +118,76 @@ The following table also uses a few conventions that suffuse the source code and | ``N_A`` | `ℕᴬ` | | Avogadro's number, molecules per mole | | ``\mathcal{U}`` | `𝒰` | | Thermodynamic state struct (e.g., `StaticEnergyState`) | | ``\mathcal{M}`` | `ℳ` | | Microphysical state struct (e.g., `WarmPhaseOneMomentState`) | + +## P3 Microphysics Notation + +The following notation is specific to the [Predicted Particle Properties (P3)](@ref Breeze.Microphysics.PredictedParticleProperties) microphysics scheme. + +### Size Distribution Parameters + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``N_0`` | `N₀` | `intercept` | Intercept parameter of gamma size distribution [m⁻⁴⁻μ] | +| ``\mu`` | `μ` | `shape` | Shape parameter of gamma size distribution [-] | +| ``\lambda`` | `λ` | `slope` | Slope parameter of gamma size distribution [1/m] | +| ``N'(D)`` | `Np` | | Number size distribution, ``N'(D) = N_0 D^\mu e^{-\lambda D}`` | +| ``D`` | `D` | | Particle diameter [m] | + +### Ice Particle Properties + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``F^f`` | `Fᶠ` | `rime_fraction` | Rime (frozen accretion) mass fraction [-], 0 = unrimed, 1 = fully rimed | +| ``\rho^f`` | `ρᶠ` | `rime_density` | Density of rime layer [kg/m³] | +| ``F^l`` | `Fˡ` | `liquid_fraction` | Liquid water fraction on ice particles [-] | +| ``m(D)`` | `m` | | Particle mass as function of diameter [kg] | +| ``V(D)`` | `V` | | Terminal velocity as function of diameter [m/s] | +| ``A(D)`` | `A` | | Particle cross-sectional area [m²] | +| ``C(D)`` | `C` | | Particle capacitance for vapor diffusion [m] | +| ``\alpha`` | `α` | `coefficient` | Mass-diameter power law coefficient, ``m(D) = \alpha D^\beta`` [kg/m^β] | +| ``\beta`` | `β` | `exponent` | Mass-diameter power law exponent [-] | +| ``\rho^i`` | `ρⁱ` | | Pure ice density [kg/m³], typically 917 | + +### Fall Speed + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``a_V`` | `a_V` | `fall_speed_coefficient` | Fall speed power law coefficient [m^{1-b}/s] | +| ``b_V`` | `b_V` | `fall_speed_exponent` | Fall speed power law exponent [-] | +| ``V_n`` | `Vn` | | Number-weighted mean fall speed [m/s] | +| ``V_m`` | `Vm` | | Mass-weighted mean fall speed [m/s] | +| ``V_z`` | `Vz` | | Reflectivity-weighted mean fall speed [m/s] | + +### Ice Concentrations and Moments + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``N_i`` | `N_ice`, `Nᵢ` | | Ice number concentration [1/m³] | +| ``L_i`` | `L_ice`, `Lᵢ` | | Ice mass concentration [kg/m³] | +| ``Z_i`` | `Z_ice`, `Zᵢ` | | Ice reflectivity / sixth moment [m⁶/m³] | +| ``Q_{norm}`` | `Qnorm` | | Normalized ice mass = ``L_i / N_i`` [kg] | + +### Rain Properties + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``N_r`` | `N_rain`, `Nʳ` | | Rain number concentration [1/m³] | +| ``L_r`` | `L_rain`, `Lʳ` | | Rain mass concentration [kg/m³] | +| ``\mu_r`` | `μ_r` | | Rain shape parameter [-] | + +### Collection and Ventilation + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``E^{ic}`` | `Eⁱᶜ` | `ice_cloud_collection_efficiency` | Ice-cloud droplet collection efficiency [-] | +| ``E^{ir}`` | `Eⁱʳ` | `ice_rain_collection_efficiency` | Ice-rain collection efficiency [-] | +| ``f^{ve}`` | `fᵛᵉ` | | Ventilation factor for vapor diffusion enhancement [-] | + +### Diameter Thresholds + +| math symbol | code | property name | description | +| ----------------------------------- | ------ | ----------------------------------- | ------------------------------------------------------------------------------ | +| ``D_{th}`` | `D_spherical` | | Threshold between small spherical ice and aggregates [m] | +| ``D_{gr}`` | `D_graupel` | | Threshold between aggregates and graupel [m] | +| ``D_{cr}`` | `D_partial` | | Threshold between graupel and partially rimed ice [m] | +| ``D_{crit}`` | `D_crit` | | Critical diameter separating small and large ice for melting [m] | diff --git a/docs/src/breeze.bib b/docs/src/breeze.bib index 4171b20af..8a20db5cd 100644 --- a/docs/src/breeze.bib +++ b/docs/src/breeze.bib @@ -323,6 +323,83 @@ @article{Shu1988Efficient author = {Chi-Wang Shu and Stanley Osher}, } +@article{MilbrandtMorrison2016, + author = {Milbrandt, Jason A. and Morrison, Hugh}, + title = {Parameterization of cloud microphysics based on the prediction of bulk ice particle properties. {Part III}: Introduction of multiple free categories}, + journal = {Journal of the Atmospheric Sciences}, + volume = {73}, + number = {3}, + pages = {975--995}, + year = {2016}, + doi = {10.1175/JAS-D-15-0204.1} +} + +@article{MilbrandtEtAl2024, + author = {Milbrandt, Jason A. and Morrison, Hugh and Dawson, Daniel T. and Paukert, Marcus}, + title = {A triple-moment representation of ice in the {Predicted Particle Properties (P3)} microphysics scheme}, + journal = {Journal of Advances in Modeling Earth Systems}, + volume = {16}, + number = {2}, + pages = {e2023MS003751}, + year = {2024}, + doi = {10.1029/2023MS003751} +} + +@article{MilbrandtYau2005, + author = {Milbrandt, Jason A. and Yau, M. K.}, + title = {A multimoment bulk microphysics parameterization. {Part I}: Analysis of the role of the spectral shape parameter}, + journal = {Journal of the Atmospheric Sciences}, + volume = {62}, + number = {9}, + pages = {3051--3064}, + year = {2005}, + doi = {10.1175/JAS3534.1} +} + +@article{FieldEtAl2007, + author = {Field, Paul R. and Heymsfield, Andrew J. and Bansemer, Aaron}, + title = {Snow size distribution parameterization for midlatitude and tropical ice clouds}, + journal = {Journal of the Atmospheric Sciences}, + volume = {64}, + number = {12}, + pages = {4346--4365}, + year = {2007}, + doi = {10.1175/2007JAS2344.1} +} + +@article{KhairoutdinovKogan2000, + author = {Khairoutdinov, Marat and Kogan, Yefim}, + title = {A new cloud physics parameterization in a large-eddy simulation model of marine stratocumulus}, + journal = {Monthly Weather Review}, + volume = {128}, + number = {1}, + pages = {229--243}, + year = {2000}, + doi = {10.1175/1520-0493(2000)128<0229:ANCPPI>2.0.CO;2} +} + +@article{HallPruppacher1976, + author = {Hall, W. D. and Pruppacher, H. R.}, + title = {The survival of ice particles falling from cirrus clouds in subsaturated air}, + journal = {Journal of the Atmospheric Sciences}, + volume = {33}, + number = {10}, + pages = {1995--2006}, + year = {1976}, + doi = {10.1175/1520-0469(1976)033<1995:TSOIPF>2.0.CO;2} +} + +@article{KlempDudhia2008, + author = {Klemp, Joseph B. and Dudhia, Jimy and Hassiotis, Anthony D.}, + title = {An upper gravity-wave absorbing layer for {NWP} applications}, + journal = {Monthly Weather Review}, + volume = {136}, + number = {10}, + pages = {3987--4004}, + year = {2008}, + doi = {10.1175/2008MWR2596.1} +} + @article{KlempEtAl2015, author = {Klemp, Joseph B. and Skamarock, William C. and Park, Sang-Hun}, title = {Idealized global nonhydrostatic atmospheric test cases on a reduced-radius sphere}, @@ -334,6 +411,62 @@ @article{KlempEtAl2015 doi = {10.1002/2015MS000435} } +@article{DeMottEtAl2010icenuclei, + author = {P. J. DeMott and A. J. Prenni and X. Liu and S. M. Kreidenweis + and M. D. Petters and C. H. Twohy and M. S. Richardson and + T. Eidhammer and D. C. Rogers}, + title = {Predicting global atmospheric ice nuclei distributions and + their impacts on climate}, + journal = {Proceedings of the National Academy of Sciences}, + volume = 107, + number = 25, + pages = {11217-11222}, + year = 2010, + doi = {10.1073/pnas.0910818107}, +} + +@article{HeymsfieldPflaum1985graupelgrowth, + author = "Andrew J. Heymsfield and John C. Pflaum", + title = "A Quantitative Assessment of the Accuracy of Techniques for + Calculating Graupel Growth", + journal = "Journal of Atmospheric Sciences", + year = 1985, + publisher = "American Meteorological Society", + address = "Boston MA, USA", + volume = 42, + number = 21, + doi = "10.1175/1520-0469(1985)042<2264:AQAOTA>2.0.CO;2", + pages = "2264 - 2274", +} + +@article{MeyerEtAl1992icenucleation, + author = "Michael P. Meyers and Paul J. DeMott and William R. Cotton", + title = "New Primary Ice-Nucleation Parameterizations in an Explicit + Cloud Model", + journal = "Journal of Applied Meteorology and Climatology", + year = 1992, + publisher = "American Meteorological Society", + address = "Boston MA, USA", + volume = 31, + number = 7, + doi = "10.1175/1520-0450(1992)031<0708:NPINPI>2.0.CO;2", + pages = "708 - 721", +} + +@article{Mitchell1996powerlaws, + author = "David L. Mitchell", + title = "Use of Mass- and Area-Dimensional Power Laws for Determining + Precipitation Particle Terminal Velocities", + journal = "Journal of Atmospheric Sciences", + year = 1996, + publisher = "American Meteorological Society", + address = "Boston MA, USA", + volume = 53, + number = 12, + doi = "10.1175/1520-0469(1996)053<1710:UOMAAD>2.0.CO;2", + pages = "1710 - 1723", +} + @article{Zarzycki2019, author = {Zarzycki, Colin M. and Jablonowski, Christiane and Kent, James and Lauritzen, Peter H. and Nair, Ramachandran and Reed, Kevin A. and Ullrich, Paul A. and Hall, David M. and Dazlich, Don and Heber, Ross and Achatz, Ulrich and Butter, Tobias and Galewsky, Joseph and Goodman, Justin and Klein, Rupert and Lemarié, Florian and Malardel, Sylvie and Rauscher, Sara A. and Schar, Christoph and Sprenger, Michael and Taylor, Mark A. and Vogl, Christian and Wan, Hui and Williamson, David L.}, title = {{DCMIP2016}: the splitting supercell test case}, @@ -343,3 +476,132 @@ @article{Zarzycki2019 year = {2019}, doi = {10.5194/gmd-12-879-2019} } + +% ============================================================================= +% P3 Microphysics Papers (Complete Set) +% ============================================================================= + +@article{Morrison2015part2, + author = {Morrison, Hugh and Milbrandt, Jason A. and Bryan, George H. and Ikeda, Kyoko and Tessendorf, Sarah A. and Thompson, Gregory}, + title = {Parameterization of cloud microphysics based on the prediction of bulk ice particle properties. {Part II}: Case study comparisons with observations and other schemes}, + journal = {Journal of the Atmospheric Sciences}, + volume = {72}, + number = {1}, + pages = {312--339}, + year = {2015}, + doi = {10.1175/JAS-D-14-0066.1} +} + +@article{MilbrandtEtAl2021, + author = {Milbrandt, Jason A. and Morrison, Hugh and Dawson, Daniel T. and Paukert, Marcus}, + title = {A triple-moment representation of ice in the {Predicted Particle Properties (P3)} microphysics scheme}, + journal = {Journal of the Atmospheric Sciences}, + volume = {78}, + number = {2}, + pages = {439--458}, + year = {2021}, + doi = {10.1175/JAS-D-20-0084.1} +} + +@article{MilbrandtEtAl2025liquidfraction, + author = {Milbrandt, Jason A. and Morrison, Hugh and Dawson, Daniel T.}, + title = {Predicted liquid fraction for ice particles in the {Predicted Particle Properties (P3)} microphysics scheme}, + journal = {Journal of Advances in Modeling Earth Systems}, + volume = {17}, + number = {1}, + pages = {e2024MS004404}, + year = {2025}, + doi = {10.1029/2024MS004404} +} + +@article{Morrison2025complete3moment, + author = {Morrison, Hugh and Milbrandt, Jason A. and Dawson, Daniel T.}, + title = {A complete three-moment representation of ice in the {Predicted Particle Properties (P3)} microphysics scheme}, + journal = {Journal of Advances in Modeling Earth Systems}, + volume = {17}, + number = {1}, + pages = {e2024MS004644}, + year = {2025}, + doi = {10.1029/2024MS004644} +} + +@article{Heymsfield2003, + author = {Heymsfield, Andrew J.}, + title = {Properties of tropical and midlatitude ice cloud particle ensembles. {Part I}: Median mass diameters and terminal velocities}, + journal = {Journal of the Atmospheric Sciences}, + volume = {60}, + number = {21}, + pages = {2573--2591}, + year = {2003}, + doi = {10.1175/1520-0469(2003)060<2573:POTAMI>2.0.CO;2} +} + +@article{MitchellHeymsfield2005, + author = {Mitchell, David L. and Heymsfield, Andrew J.}, + title = {Refinements in the treatment of ice particle terminal velocities, highlighting aggregates}, + journal = {Journal of the Atmospheric Sciences}, + volume = {62}, + number = {5}, + pages = {1637--1644}, + year = {2005}, + doi = {10.1175/JAS3413.1} +} + +@article{HeymsfieldEtAl2006, + author = {Heymsfield, Andrew J. and Bansemer, Aaron and Schmitt, Carl and Twohy, Cynthia and Poellot, Michael R.}, + title = {Effective ice particle densities derived from aircraft data}, + journal = {Journal of the Atmospheric Sciences}, + volume = {63}, + number = {12}, + pages = {3396--3409}, + year = {2006}, + doi = {10.1175/JAS3809.1} +} + +@article{CoberList1993, + author = {Cober, Stewart G. and List, Roland}, + title = {Measurements of the heat and mass transfer parameters characterizing conical graupel growth}, + journal = {Journal of the Atmospheric Sciences}, + volume = {50}, + number = {11}, + pages = {1591--1609}, + year = {1993}, + doi = {10.1175/1520-0469(1993)050<1591:MOTHAM>2.0.CO;2} +} + +@article{Bigg1953, + author = {Bigg, E. K.}, + title = {The supercooling of water}, + journal = {Proceedings of the Physical Society. Section B}, + volume = {66}, + number = {8}, + pages = {688--694}, + year = {1953} +} + +@incollection{Cooper1986, + author = {Cooper, William A.}, + title = {Ice initiation in natural clouds}, + booktitle = {Precipitation Enhancement---A Scientific Challenge}, + publisher = {American Meteorological Society}, + year = {1986}, + pages = {29--32} +} + +@article{SeifertBeheng2001, + author = {Seifert, Axel and Beheng, Klaus D.}, + title = {A double-moment parameterization for cloud microphysics}, + journal = {Atmospheric Research}, + volume = {59-60}, + pages = {265--281}, + year = {2001} +} + +@article{HallettMossop1974, + author = {Hallett, J. and Mossop, S. C.}, + title = {Production of secondary ice particles during the riming process}, + journal = {Nature}, + volume = {249}, + pages = {26--28}, + year = {1974} +} diff --git a/docs/src/microphysics/p3_documentation_plan.md b/docs/src/microphysics/p3_documentation_plan.md new file mode 100644 index 000000000..ca9b7aaa3 --- /dev/null +++ b/docs/src/microphysics/p3_documentation_plan.md @@ -0,0 +1,322 @@ +# P3 Documentation Plan + +This document outlines the comprehensive documentation needed for the Predicted Particle Properties (P3) microphysics scheme in Breeze.jl. + +## Documentation Structure + +``` +docs/src/microphysics/ +├── p3_overview.md # Introduction and motivation +├── p3_particle_properties.md # Mass, area, density relationships +├── p3_size_distribution.md # Gamma PSD and parameter determination +├── p3_integral_properties.md # Bulk properties from PSD integrals +├── p3_processes.md # Microphysical process rates +├── p3_prognostics.md # Prognostic variables and tendencies +└── p3_examples.md # Worked examples and simulations +``` + +--- + +## 1. Overview (`p3_overview.md`) + +### Content +- **Motivation**: Why a single ice category with predicted properties? +- **Comparison to traditional schemes**: Cloud ice / snow / graupel / hail categories +- **Key innovations**: Continuous property evolution, 3-moment ice, liquid fraction +- **History**: Morrison & Milbrandt (2015) → (2016) → Milbrandt et al. (2024) + +### Equations +- None (conceptual overview) + +### Code Examples +```julia +using Breeze.Microphysics.PredictedParticleProperties + +# Create P3 scheme with default parameters +microphysics = PredictedParticlePropertiesMicrophysics() +``` + +--- + +## 2. Particle Properties (`p3_particle_properties.md`) + +### Content +- **Mass-diameter relationship**: Piecewise m(D) across four regimes +- **Area-diameter relationship**: Projected area for fall speed +- **Density**: How particle density varies with size and riming +- **Thresholds**: D_th (spherical), D_gr (graupel), D_cr (partial rime) + +### Equations + +**Spherical ice** (D < D_th): +```math +m(D) = \frac{π}{6} ρᵢ D³ +``` + +**Vapor-grown aggregates** (D ≥ D_th, unrimed or lightly rimed): +```math +m(D) = α D^β +``` +where α = 0.0121 kg/m^β, β = 1.9 + +**Graupel** (D_gr ≤ D < D_cr): +```math +m(D) = \frac{π}{6} ρ_g D³ +``` + +**Partially rimed** (D ≥ D_cr): +```math +m(D) = \frac{α}{1 - F^f} D^β +``` + +**Threshold formulas**: +```math +D_{th} = \left(\frac{6α}{π ρᵢ}\right)^{1/(3-β)} +``` + +### Code Examples +```julia +mass = IceMassPowerLaw() +thresholds = ice_regime_thresholds(mass, rime_fraction, rime_density) + +# Compute mass at different sizes +m_small = ice_mass(mass, 0.0, 400.0, 10e-6) # 10 μm particle +m_large = ice_mass(mass, 0.0, 400.0, 1e-3) # 1 mm particle +``` + +### Figures +- m(D) and A(D) plots for different rime fractions (replicate Fig. 1 from MM2015) +- Density vs diameter for different riming states + +--- + +## 3. Size Distribution (`p3_size_distribution.md`) + +### Content +- **Gamma distribution**: N'(D) = N₀ D^μ exp(-λD) +- **Moments**: M_k = ∫ D^k N'(D) dD +- **μ-λ relationship**: Shape parameter closure +- **Lambda solver**: Determining (N₀, λ, μ) from (L, N) + +### Equations + +**Size distribution**: +```math +N'(D) = N₀ D^μ e^{-λD} +``` + +**Moments**: +```math +M_k = N₀ \frac{Γ(k + μ + 1)}{λ^{k+μ+1}} +``` + +**Normalization**: +```math +N = \int_0^∞ N'(D)\, dD = N₀ \frac{Γ(μ+1)}{λ^{μ+1}} +``` + +**μ-λ relation** (Morrison & Milbrandt 2015): +```math +μ = \text{clamp}(a λ^b - c, 0, μ_{max}) +``` +with a = 0.00191, b = 0.8, c = 2, μ_max = 6 + +**Mass content**: +```math +L = \int_0^∞ m(D) N'(D)\, dD +``` + +### Code Examples +```julia +# Solve for distribution parameters +L_ice = 1e-4 # kg/m³ +N_ice = 1e5 # 1/m³ + +params = distribution_parameters(L_ice, N_ice, rime_fraction, rime_density) +# Returns (N₀, λ, μ) +``` + +### Figures +- Size distributions for different λ, μ values +- L/N ratio vs λ showing solver convergence + +--- + +## 4. Integral Properties (`p3_integral_properties.md`) + +### Content +- **Fall speeds**: Number-, mass-, reflectivity-weighted +- **Deposition/sublimation**: Ventilation factors +- **Bulk properties**: Effective radius, mean diameter, reflectivity +- **Collection**: Aggregation, riming kernels +- **Sixth moment**: For 3-moment scheme + +### Equations + +**Number-weighted fall speed**: +```math +V_n = \frac{\int_0^∞ V(D) N'(D)\, dD}{\int_0^∞ N'(D)\, dD} +``` + +**Mass-weighted fall speed**: +```math +V_m = \frac{\int_0^∞ V(D) m(D) N'(D)\, dD}{\int_0^∞ m(D) N'(D)\, dD} +``` + +**Fall speed power law**: +```math +V(D) = a_V \left(\frac{ρ_0}{ρ}\right)^{0.5} D^{b_V} +``` + +**Ventilation factor** (Hall & Pruppacher 1976): +```math +f_v = a_v + b_v \text{Re}^{0.5} \text{Sc}^{1/3} +``` + +### Code Examples +```julia +state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + +V_n = evaluate(NumberWeightedFallSpeed(), state) +V_m = evaluate(MassWeightedFallSpeed(), state) +``` + +### Figures +- Fall speed vs λ for different μ +- Comparison of V_n, V_m, V_z + +--- + +## 5. Microphysical Processes (`p3_processes.md`) + +### Content + +#### Warm rain processes +- **Condensation/evaporation**: Saturation adjustment or explicit +- **Autoconversion**: Cloud → rain (Khairoutdinov-Kogan or Berry-Reinhardt) +- **Accretion**: Cloud + rain → rain +- **Rain evaporation**: Below cloud base + +#### Ice nucleation +- **Heterogeneous**: INP activation +- **Homogeneous**: T < -38°C freezing +- **Secondary**: Hallett-Mossop, rime splintering + +#### Vapor-ice exchange +- **Deposition**: Vapor → ice (supersaturated wrt ice) +- **Sublimation**: Ice → vapor (subsaturated) + +#### Collection processes +- **Riming**: Ice + cloud → ice (rime fraction increases) +- **Ice-rain collection**: Ice + rain → ice +- **Aggregation**: Ice + ice → ice + +#### Phase change +- **Melting**: Ice → rain (T > 0°C) +- **Shedding**: Liquid on ice → rain +- **Refreezing**: Liquid on ice → rime + +### Equations + +**Deposition rate** (per particle): +```math +\frac{dm}{dt} = 4π C f_v D_v (ρ_v - ρ_{v,i}) +``` + +**Riming rate**: +```math +\frac{dq^f}{dt} = E_{ic} \int_0^∞ A(D) V(D) q^{cl} N'(D)\, dD +``` + +**Melting**: +```math +\frac{dm}{dt} = -\frac{4π C}{L_f} \left[ k_a (T - T_0) + L_v D_v (ρ_v - ρ_{v,s}) \right] f_v +``` + +### Code Examples +- Process tendency computations (once implemented) + +--- + +## 6. Prognostic Variables (`p3_prognostics.md`) + +### Content +- **What P3 tracks**: 9 prognostic fields +- **Tendency equations**: ∂ρq/∂t = ... +- **Coupling to dynamics**: How microphysics couples to AtmosphereModel + +### Variables + +| Symbol | Name | Units | Description | +|--------|------|-------|-------------| +| ρqᶜˡ | Cloud liquid | kg/m³ | Cloud droplet mass | +| ρqʳ | Rain | kg/m³ | Rain mass | +| ρnʳ | Rain number | 1/m³ | Raindrop number | +| ρqⁱ | Ice | kg/m³ | Total ice mass | +| ρnⁱ | Ice number | 1/m³ | Ice particle number | +| ρqᶠ | Rime | kg/m³ | Rime/frost mass | +| ρbᶠ | Rime volume | m³/m³ | Rime volume density | +| ρzⁱ | Ice reflectivity | m⁶/m³ | 6th moment | +| ρqʷⁱ | Water on ice | kg/m³ | Liquid fraction | + +### Equations + +**Mass tendency**: +```math +\frac{∂ρq^i}{∂t} = \text{DEP} + \text{RIM} + \text{AGG} - \text{SUB} - \text{MLT} +``` + +**Number tendency**: +```math +\frac{∂ρn^i}{∂t} = \text{NUC} - \text{AGG}_n - \text{MLT}_n +``` + +--- + +## 7. Examples and Simulations (`p3_examples.md`) + +### Content +- **Parcel model**: Adiabatic ascent with P3 microphysics +- **1D kinematic**: Prescribed updraft, test sedimentation +- **Convective cell**: 2D/3D simulation showing ice evolution + +### Simulations + +1. **Ice particle explorer** (already implemented) + - Visualize m(D), A(D), V(D) relationships + - Show effect of riming on particle properties + +2. **Adiabatic parcel** + - Start with supersaturated air + - Watch ice nucleation and growth + - Track rime fraction evolution + +3. **Squall line** (advanced) + - Show graupel formation + - Compare to traditional schemes + +--- + +## Implementation Order + +1. **p3_overview.md** - Start with motivation (half day) +2. **p3_particle_properties.md** - Core physics (1 day) +3. **p3_size_distribution.md** - PSD and solver (1 day) +4. **p3_integral_properties.md** - All integrals documented (1 day) +5. **p3_prognostics.md** - Variable definitions (half day) +6. **p3_processes.md** - Detailed process physics (2 days) +7. **p3_examples.md** - Simulations and visualization (1-2 days) + +**Total estimate: 7-8 days of focused work** + +--- + +## References + +All equations should cite: +- Morrison, H., & Milbrandt, J. A. (2015). Parameterization of cloud microphysics... +- Milbrandt, J. A., & Morrison, H. (2016). ...Part III: Three-moment... +- Milbrandt, J. A., et al. (2024). ...Predicted liquid fraction... +- Hall, W. D., & Pruppacher, H. R. (1976). Ventilation... +- Khairoutdinov, M., & Kogan, Y. (2000). Autoconversion... diff --git a/docs/src/microphysics/p3_examples.md b/docs/src/microphysics/p3_examples.md new file mode 100644 index 000000000..80b106e3f --- /dev/null +++ b/docs/src/microphysics/p3_examples.md @@ -0,0 +1,388 @@ +# P3 Examples and Visualization + +This section provides worked examples demonstrating P3 microphysics concepts +through visualization and analysis. + +The examples illustrate key concepts from the P3 papers: +- Mass-diameter relationships from [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) +- Size distribution from [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) and [Heymsfield (2003)](@cite Heymsfield2003) +- Fall speed integrals from [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) Table 3 +- μ-λ relationship from [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) Eq. 27 + +## Ice Particle Property Explorer + +Let's explore how ice particle properties vary with size and riming state. + +### Mass-Diameter Relationship + +```@example p3_examples +using Breeze.Microphysics.PredictedParticleProperties +using CairoMakie + +mass = IceMassPowerLaw() + +# Diameter range: 10 μm to 10 mm +D = 10 .^ range(-5, -2, length=200) + +# Compute mass for different riming states +m_unrimed = [ice_mass(mass, 0.0, 400.0, d) for d in D] +m_light = [ice_mass(mass, 0.2, 400.0, d) for d in D] +m_moderate = [ice_mass(mass, 0.5, 500.0, d) for d in D] +m_heavy = [ice_mass(mass, 0.8, 700.0, d) for d in D] + +# Reference: pure ice sphere +m_sphere = @. 917 * π/6 * D^3 + +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "Maximum dimension D [m]", + ylabel = "Particle mass m [kg]", + xscale = log10, + yscale = log10, + title = "Ice Particle Mass vs Size") + +lines!(ax, D, m_sphere, color=:gray, linestyle=:dash, linewidth=1, label="Pure ice sphere") +lines!(ax, D, m_unrimed, linewidth=2, label="Unrimed (Fᶠ = 0)") +lines!(ax, D, m_light, linewidth=2, label="Light rime (Fᶠ = 0.2)") +lines!(ax, D, m_moderate, linewidth=2, label="Moderate rime (Fᶠ = 0.5)") +lines!(ax, D, m_heavy, linewidth=2, label="Heavy rime (Fᶠ = 0.8)") + +axislegend(ax, position=:lt) +fig +``` + +Notice how riming increases particle mass at a given size, with the effect most +pronounced for larger particles where the mass-diameter relationship transitions +from the aggregate power law to the graupel regime. + +### Regime Transitions + +```@example p3_examples +# Visualize regime thresholds +fig = Figure(size=(700, 400)) +ax = Axis(fig[1, 1], + xlabel = "Rime fraction Fᶠ", + ylabel = "Threshold diameter [mm]", + title = "Ice Particle Regime Thresholds") + +Ff_range = range(0.01, 0.99, length=50) +D_th = Float64[] +D_gr = Float64[] +D_cr = Float64[] + +for Ff in Ff_range + thresholds = ice_regime_thresholds(mass, Ff, 500.0) + push!(D_th, thresholds.spherical * 1e3) + push!(D_gr, min(thresholds.graupel * 1e3, 20)) + push!(D_cr, min(thresholds.partial_rime * 1e3, 20)) +end + +lines!(ax, Ff_range, D_th, label="D_th (spherical)", linewidth=2) +lines!(ax, Ff_range, D_gr, label="D_gr (graupel)", linewidth=2) +lines!(ax, Ff_range, D_cr, label="D_cr (partial rime)", linewidth=2) + +axislegend(ax, position=:rt) +ylims!(ax, 0, 15) +fig +``` + +As rime fraction increases, the graupel regime expands to smaller sizes, +while the partial-rime regime contracts to larger sizes. + +## Size Distribution Visualization + +### Effect of Mass Content + +```@example p3_examples +using SpecialFunctions: gamma + +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [mm]", + ylabel = "N'(D) [m⁻⁴]", + yscale = log10, + title = "Ice Size Distributions for Different Mass Contents\n(N = 10⁵ m⁻³)") + +D_mm = range(0.01, 8, length=300) +D_m = D_mm .* 1e-3 + +N_ice = 1e5 + +# Different mass contents +for (L, color, label) in [ + (1e-5, :blue, "L = 0.01 g/m³"), + (5e-5, :green, "L = 0.05 g/m³"), + (1e-4, :orange, "L = 0.1 g/m³"), + (5e-4, :red, "L = 0.5 g/m³"), + (1e-3, :purple, "L = 1.0 g/m³") +] + params = distribution_parameters(L, N_ice, 0.0, 400.0) + N_D = @. params.N₀ * D_m^params.μ * exp(-params.λ * D_m) + lines!(ax, D_mm, N_D, color=color, linewidth=2, label=label) +end + +axislegend(ax, position=:rt) +ylims!(ax, 1e2, 1e14) +fig +``` + +Higher mass content (at fixed number) shifts the distribution toward larger particles. + +### Shape Parameter Effect + +```@example p3_examples +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [mm]", + ylabel = "N'(D) / N₀", + title = "Effect of Shape Parameter μ on Distribution Shape\n(λ = 2000 m⁻¹)") + +D_mm = range(0.01, 3, length=200) +D_m = D_mm .* 1e-3 +λ = 2000.0 + +for μ in [0, 1, 2, 4, 6] + N_norm = @. D_m^μ * exp(-λ * D_m) + N_norm ./= maximum(N_norm) # Normalize to peak + lines!(ax, D_mm, N_norm, linewidth=2, label="μ = $μ") +end + +axislegend(ax, position=:rt) +fig +``` + +Higher ``μ`` produces a narrower distribution with a more pronounced mode. + +## Fall Speed Analysis + +### Weighted Fall Speeds vs Slope Parameter + +```@example p3_examples +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "Slope parameter λ [m⁻¹]", + ylabel = "Fall speed integral [m/s]", + xscale = log10, + title = "Fall Speed Integrals vs λ\n(larger λ → smaller particles)") + +λ_values = 10 .^ range(2.5, 5, length=30) + +V_n = Float64[] +V_m = Float64[] +V_z = Float64[] + +for λ in λ_values + state = IceSizeDistributionState(Float64; intercept=1e6, shape=0.0, slope=λ) + push!(V_n, evaluate(NumberWeightedFallSpeed(), state)) + push!(V_m, evaluate(MassWeightedFallSpeed(), state)) + push!(V_z, evaluate(ReflectivityWeightedFallSpeed(), state)) +end + +lines!(ax, λ_values, V_n, linewidth=2, label="Vₙ (number-weighted)") +lines!(ax, λ_values, V_m, linewidth=2, label="Vₘ (mass-weighted)") +lines!(ax, λ_values, V_z, linewidth=2, label="Vᵤ (reflectivity-weighted)") + +axislegend(ax, position=:rt) +fig +``` + +Larger particles (smaller ``λ``) fall faster, and the reflectivity-weighted velocity +(emphasizing large particles) exceeds the mass-weighted velocity, which in turn +exceeds the number-weighted velocity. + +### Effect of Riming on Fall Speed + +```@example p3_examples +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "Rime fraction Fᶠ", + ylabel = "Mass-weighted fall speed [m/s]", + title = "Effect of Riming on Ice Fall Speed") + +Ff_range = range(0, 0.9, length=20) +λ = 1000.0 + +V_m_values = Float64[] + +for Ff in Ff_range + state = IceSizeDistributionState(Float64; + intercept=1e6, shape=0.0, slope=λ, + rime_fraction=Ff, rime_density=500.0) + push!(V_m_values, evaluate(MassWeightedFallSpeed(), state)) +end + +lines!(ax, Ff_range, V_m_values, linewidth=3, color=:blue) +scatter!(ax, Ff_range, V_m_values, markersize=8, color=:blue) + +fig +``` + +Rimed particles fall faster due to higher density. + +## Integral Comparison + +### All Fall Speed Components + +```@example p3_examples +# Compare different integral types at a fixed state +state = IceSizeDistributionState(Float64; + intercept=1e6, shape=2.0, slope=1500.0, + rime_fraction=0.3, rime_density=500.0) + +integrals = [ + ("NumberWeightedFallSpeed", evaluate(NumberWeightedFallSpeed(), state)), + ("MassWeightedFallSpeed", evaluate(MassWeightedFallSpeed(), state)), + ("ReflectivityWeightedFallSpeed", evaluate(ReflectivityWeightedFallSpeed(), state)), + ("Ventilation", evaluate(Ventilation(), state)), + ("EffectiveRadius [μm]", evaluate(EffectiveRadius(), state) * 1e6), + ("MeanDiameter [mm]", evaluate(MeanDiameter(), state) * 1e3), + ("MeanDensity [kg/m³]", evaluate(MeanDensity(), state)), +] + +println("Integral values for state:") +println(" N₀ = 10⁶, μ = 2, λ = 1500 m⁻¹, Fᶠ = 0.3") +println() +for (name, value) in integrals + println(" $name = $(round(value, sigdigits=4))") +end +``` + +## Lambda Solver Demonstration + +### Convergence Visualization + +```@example p3_examples +fig = Figure(size=(700, 500)) +ax = Axis(fig[1, 1], + xlabel = "log₁₀(L/N) [kg per particle]", + ylabel = "log₁₀(λ) [m⁻¹]", + title = "Lambda Solver: λ vs Mean Particle Mass") + +N_ice = 1e5 # Fixed number concentration +L_values = 10 .^ range(-6, -2, length=50) + +λ_unrimed = Float64[] +λ_rimed = Float64[] + +for L in L_values + params_ur = distribution_parameters(L, N_ice, 0.0, 400.0) + params_ri = distribution_parameters(L, N_ice, 0.5, 500.0) + push!(λ_unrimed, params_ur.λ) + push!(λ_rimed, params_ri.λ) +end + +log_LN = log10.(L_values ./ N_ice) + +lines!(ax, log_LN, log10.(λ_unrimed), linewidth=2, label="Unrimed") +lines!(ax, log_LN, log10.(λ_rimed), linewidth=2, label="Rimed (Fᶠ = 0.5)") + +axislegend(ax, position=:rt) +fig +``` + +At the same L/N ratio, rimed particles have larger ``λ`` (smaller characteristic size) +because their higher mass-per-particle requires smaller particles to match the ratio. + +## Quadrature Accuracy + +### Convergence with Number of Points + +```@example p3_examples +state = IceSizeDistributionState(Float64; + intercept=1e6, shape=0.0, slope=1000.0) + +n_points = [8, 16, 32, 64, 128, 256] +V_values = Float64[] +reference = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=512) + +for n in n_points + V = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=n) + push!(V_values, V) +end + +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Number of quadrature points", + ylabel = "Relative error", + xscale = log10, + yscale = log10, + title = "Quadrature Convergence") + +errors = abs.(V_values .- reference) ./ reference +lines!(ax, n_points, errors, linewidth=2) +scatter!(ax, n_points, errors, markersize=10) + +fig +``` + +The Chebyshev-Gauss quadrature converges rapidly, with 64 points typically +sufficient for double precision. + +## Summary Visualization + +```@example p3_examples +# Create a comprehensive summary figure +fig = Figure(size=(900, 600)) + +# Mass-diameter (top left) +ax1 = Axis(fig[1, 1], + xlabel = "D [mm]", ylabel = "Mass [mg]", + title = "Mass vs Diameter") + +D_mm = range(0.1, 5, length=100) +D_m = D_mm .* 1e-3 + +for (Ff, label) in [(0.0, "Fᶠ=0"), (0.5, "Fᶠ=0.5")] + m = [ice_mass(mass, Ff, 500.0, d) for d in D_m] + lines!(ax1, D_mm, m .* 1e6, label=label) +end +axislegend(ax1, position=:lt) + +# Size distribution (top right) +ax2 = Axis(fig[1, 2], + xlabel = "D [mm]", ylabel = "N'(D) [m⁻⁴]", + yscale = log10, title = "Size Distribution") + +for L in [1e-5, 1e-4, 1e-3] + params = distribution_parameters(L, 1e5, 0.0, 400.0) + N_D = @. params.N₀ * D_m^params.μ * exp(-params.λ * D_m) + lines!(ax2, D_mm, N_D, label="L=$(L*1e3) g/m³") +end +ylims!(ax2, 1e3, 1e13) +axislegend(ax2, position=:rt, fontsize=10) + +# Fall speed vs λ (bottom left) +ax3 = Axis(fig[2, 1], + xlabel = "λ [m⁻¹]", ylabel = "Fall speed", + xscale = log10, title = "Fall Speed Integrals") + +λ_vals = 10 .^ range(2.5, 4.5, length=30) +V_n = [evaluate(NumberWeightedFallSpeed(), + IceSizeDistributionState(Float64; intercept=1e6, shape=0.0, slope=λ)) for λ in λ_vals] +V_m = [evaluate(MassWeightedFallSpeed(), + IceSizeDistributionState(Float64; intercept=1e6, shape=0.0, slope=λ)) for λ in λ_vals] + +lines!(ax3, λ_vals, V_n, label="Vₙ") +lines!(ax3, λ_vals, V_m, label="Vₘ") +axislegend(ax3, position=:rt) + +# μ-λ relationship (bottom right) +ax4 = Axis(fig[2, 2], + xlabel = "λ [m⁻¹]", ylabel = "μ", + xscale = log10, title = "μ-λ Relationship") + +relation = ShapeParameterRelation() +λ_range = 10 .^ range(2, 5, length=100) +μ_vals = [shape_parameter(relation, log(λ)) for λ in λ_range] + +lines!(ax4, λ_range, μ_vals, linewidth=2, color=:blue) +hlines!(ax4, [relation.μmax], linestyle=:dash, color=:gray) + +fig +``` + +This figure summarizes the key relationships in P3: +1. **Top left**: Mass increases with size and riming +2. **Top right**: Size distribution shifts with mass content +3. **Bottom left**: Fall speed decreases with λ (smaller particles) +4. **Bottom right**: Shape parameter μ increases with λ up to a maximum diff --git a/docs/src/microphysics/p3_integral_properties.md b/docs/src/microphysics/p3_integral_properties.md new file mode 100644 index 000000000..bc625060e --- /dev/null +++ b/docs/src/microphysics/p3_integral_properties.md @@ -0,0 +1,374 @@ +# [Integral Properties](@id p3_integral_properties) + +Bulk microphysical rates require population-averaged quantities computed by integrating +over the particle size distribution. P3 defines numerous integral properties organized +by physical concept. + +These integrals are pre-computed and stored in lookup tables in the Fortran P3 code +(see `create_p3_lookupTable_1.f90` in the [P3-microphysics repository](https://github.com/P3-microphysics/P3-microphysics)). +The integral formulations are from: +- [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization): Fall speed, ventilation, collection +- [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021): Sixth moment integrals for 3-moment ice +- [Morrison et al. (2025)](@cite Morrison2025complete3moment): Complete 3-moment lookup tables + +## General Form + +All integral properties have the form: + +```math +\langle X \rangle = \frac{\int_0^∞ X(D) N'(D)\, dD}{\int_0^∞ W(D) N'(D)\, dD} +``` + +where ``X(D)`` is the quantity of interest and ``W(D)`` is a weighting function +(often unity or particle mass). + +## Fall Speed Integrals + +Terminal velocity determines sedimentation rates. P3 computes three weighted fall speeds, +corresponding to `uns`, `ums`, `uzs` in the Fortran lookup tables +([Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) Table 3). + +### Terminal Velocity Formulation + +Individual particle fall speed follows the [Mitchell and Heymsfield (2005)](@cite MitchellHeymsfield2005) +Best number formulation, which relates fall speed to particle mass, projected area, and air properties. +The formulation accounts for the transition from Stokes to turbulent flow regimes and includes +surface roughness effects. A density correction factor ``(ρ₀/ρ)^{0.54}`` is applied following +[Heymsfield et al. (2006)](@cite HeymsfieldEtAl2006). + +For mixed-phase particles (with liquid fraction ``F^l``), the fall speed is linearly interpolated +between the ice fall speed and the rain fall speed: + +```math +V(D) = F^l V_{rain}(D) + (1 - F^l) V_{ice}(D) +``` + +The fall speed depends on the mass-diameter and area-diameter relationships, which vary +across the four particle regimes (see [Particle Properties](@ref p3_particle_properties)). + +### Number-Weighted Fall Speed + +```math +V_n = \frac{\int_0^∞ V(D) N'(D)\, dD}{\int_0^∞ N'(D)\, dD} +``` + +This represents the average fall speed of particles and governs number flux: + +```math +F_N = -V_n \cdot N +``` + +### Mass-Weighted Fall Speed + +```math +V_m = \frac{\int_0^∞ V(D) m(D) N'(D)\, dD}{\int_0^∞ m(D) N'(D)\, dD} +``` + +This governs mass flux: + +```math +F_L = -V_m \cdot L +``` + +### Reflectivity-Weighted Fall Speed + +For 3-moment ice, the 6th moment flux uses: + +```math +V_z = \frac{\int_0^∞ V(D) D^6 N'(D)\, dD}{\int_0^∞ D^6 N'(D)\, dD} +``` + +```@example p3_integrals +using Breeze.Microphysics.PredictedParticleProperties + +# Create a size distribution state +state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + +# Evaluate fall speed integrals +V_n = evaluate(NumberWeightedFallSpeed(), state) +V_m = evaluate(MassWeightedFallSpeed(), state) +V_z = evaluate(ReflectivityWeightedFallSpeed(), state) + +println("Fall speed integrals:") +println(" V_n (number-weighted) = $(round(V_n, digits=3))") +println(" V_m (mass-weighted) = $(round(V_m, digits=3))") +println(" V_z (reflectivity) = $(round(V_z, digits=3))") +``` + +## Deposition/Sublimation Integrals + +Vapor diffusion to/from ice particles is enhanced by air flow around falling particles. + +### Ventilation Factor + +The ventilation factor ``f_v`` accounts for enhanced mass transfer: + +```math +f_v = a_v + b_v \text{Re}^{1/2} \text{Sc}^{1/3} +``` + +where: +- ``\text{Re} = V D / ν`` is the Reynolds number +- ``\text{Sc} = ν / D_v`` is the Schmidt number +- ``a_v, b_v`` are empirical coefficients from [HallPruppacher1976](@cite) + +### Ventilation Integrals + +P3 computes six ventilation-related integrals for different size regimes: + +| Integral | Description | Size Regime | +|----------|-------------|-------------| +| `SmallIceVentilationConstant` | Constant term for small ice | D < 100 μm | +| `SmallIceVentilationReynolds` | Re-dependent term for small ice | D < 100 μm | +| `LargeIceVentilationConstant` | Constant term for large ice | D ≥ 100 μm | +| `LargeIceVentilationReynolds` | Re-dependent term for large ice | D ≥ 100 μm | +| `Ventilation` | Total ventilation integral | All sizes | +| `VentilationEnhanced` | Enhanced ventilation (large ice only) | D ≥ 100 μm | + +```@example p3_integrals +# Ventilation integrals +v_basic = evaluate(Ventilation(), state) +v_enhanced = evaluate(VentilationEnhanced(), state) + +println("Ventilation integrals:") +println(" Basic ventilation = $(round(v_basic, sigdigits=3))") +println(" Enhanced (large ice) = $(round(v_enhanced, sigdigits=3))") +``` + +## Bulk Property Integrals + +Population-averaged properties for radiation, radar, and diagnostics. + +### Effective Radius + +Important for radiation parameterizations: + +```math +r_{eff} = \frac{\int_0^∞ D^3 N'(D)\, dD}{2 \int_0^∞ D^2 N'(D)\, dD} = \frac{M_3}{2 M_2} +``` + +### Mean Diameter + +Mass-weighted mean particle size: + +```math +D_m = \frac{\int_0^∞ D \cdot m(D) N'(D)\, dD}{\int_0^∞ m(D) N'(D)\, dD} +``` + +### Mean Density + +Mass-weighted particle density: + +```math +ρ_m = \frac{\int_0^∞ ρ(D) m(D) N'(D)\, dD}{\int_0^∞ m(D) N'(D)\, dD} +``` + +### Reflectivity + +Radar reflectivity factor (proportional to 6th moment): + +```math +Z = \int_0^∞ D^6 N'(D)\, dD = N₀ \frac{Γ(μ + 7)}{λ^{μ+7}} +``` + +```@example p3_integrals +r_eff = evaluate(EffectiveRadius(), state) +D_m = evaluate(MeanDiameter(), state) +ρ_m = evaluate(MeanDensity(), state) +Z = evaluate(Reflectivity(), state) + +println("Bulk properties:") +println(" Effective radius = $(round(r_eff * 1e6, digits=1)) μm") +println(" Mean diameter = $(round(D_m * 1e3, digits=2)) mm") +println(" Mean density = $(round(ρ_m, digits=1)) kg/m³") +println(" Reflectivity Z = $(round(Z * 1e18, sigdigits=3)) mm⁶/m³") +``` + +## Collection Integrals + +Collection processes (aggregation, riming) require integrals over collision kernels. + +### Aggregation + +The collection kernel for ice-ice aggregation is: + +```math +K_{agg}(D_1, D_2) = E_{agg} \frac{π}{4} (D_1 + D_2)^2 |V(D_1) - V(D_2)| +``` + +The aggregation rate integral: + +```math +I_{agg} = \int_0^∞ \int_0^∞ K_{agg}(D_1, D_2) N'(D_1) N'(D_2)\, dD_1 dD_2 +``` + +### Ice-Cloud Collection (Riming) + +```math +\frac{dq^f}{dt} = E_{ic} q^{cl} \int_0^∞ A(D) V(D) N'(D)\, dD +``` + +### Ice-Rain Collection + +```math +I_{ir} = \int_0^∞ A(D) V(D) N'(D)\, dD +``` + +```@example p3_integrals +n_agg = evaluate(AggregationNumber(), state) +n_rain = evaluate(RainCollectionNumber(), state) + +println("Collection integrals:") +println(" Aggregation number = $(round(n_agg, sigdigits=3))") +println(" Rain collection = $(round(n_rain, sigdigits=3))") +``` + +## Sixth Moment Integrals + +For 3-moment ice ([Milbrandt et al. (2021)](@cite MilbrandtEtAl2021), +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024)), +P3 tracks the 6th moment ``Z`` which requires additional integrals +for each process affecting reflectivity. These are documented in +[Morrison et al. (2025)](@cite Morrison2025complete3moment) and stored +in the 3-moment lookup table (`p3_lookupTable_1.dat-v*_3momI`). + +| Process | Integral | Physical Meaning | +|---------|----------|------------------| +| Riming | `SixthMomentRime` | Z change from rime accretion | +| Deposition | `SixthMomentDeposition` | Z change from vapor growth | +| Melting | `SixthMomentMelt1`, `SixthMomentMelt2` | Z change from melting | +| Aggregation | `SixthMomentAggregation` | Z change from aggregation | +| Shedding | `SixthMomentShedding` | Z change from liquid shedding | +| Sublimation | `SixthMomentSublimation` | Z change from sublimation | + +```@example p3_integrals +# Sixth moment integrals (with nonzero liquid fraction for shedding) +state_wet = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + liquid_fraction = 0.1) + +z_rime = evaluate(SixthMomentRime(), state_wet) +z_dep = evaluate(SixthMomentDeposition(), state_wet) +z_agg = evaluate(SixthMomentAggregation(), state_wet) +z_shed = evaluate(SixthMomentShedding(), state_wet) + +println("Sixth moment integrals:") +println(" Rime = $(round(z_rime, sigdigits=3))") +println(" Deposition = $(round(z_dep, sigdigits=3))") +println(" Aggregation = $(round(z_agg, sigdigits=3))") +println(" Shedding = $(round(z_shed, sigdigits=3))") +``` + +## Lambda Limiter Integrals + +To prevent unphysical size distributions, P3 limits the slope parameter ``λ`` +based on physical constraints. + +| Integral | Purpose | +|----------|---------| +| `SmallQLambdaLimit` | Lower bound on λ (prevents unrealistically large particles) | +| `LargeQLambdaLimit` | Upper bound on λ (prevents unrealistically small particles) | + +## Dependence on Distribution Parameters + +Integral values depend strongly on the size distribution parameters: + +```@example p3_integrals +using CairoMakie + +# Vary slope parameter +λ_values = 10 .^ range(2.5, 4.5, length=20) +V_n_values = Float64[] +V_m_values = Float64[] +V_z_values = Float64[] + +for λ in λ_values + state = IceSizeDistributionState(Float64; intercept=1e6, shape=0.0, slope=λ) + push!(V_n_values, evaluate(NumberWeightedFallSpeed(), state)) + push!(V_m_values, evaluate(MassWeightedFallSpeed(), state)) + push!(V_z_values, evaluate(ReflectivityWeightedFallSpeed(), state)) +end + +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Slope parameter λ [m⁻¹]", + ylabel = "Fall speed integral", + xscale = log10, + title = "Fall Speed Integrals vs λ") + +lines!(ax, λ_values, V_n_values, label="Vₙ (number)") +lines!(ax, λ_values, V_m_values, label="Vₘ (mass)") +lines!(ax, λ_values, V_z_values, label="Vᵤ (reflectivity)") + +axislegend(ax, position=:rt) +fig +``` + +## Numerical Integration and Tabulation + +The official P3 lookup tables are generated using fixed-bin numerical integration +(40,000 diameter bins for single-particle integrals and 1,500 for collection integrals), +with constant bin widths in diameter space. + +Breeze evaluates integrals directly using Chebyshev-Gauss quadrature with +a change of variables to map ``[0, ∞)`` to a bounded interval: + +```@example p3_integrals +# The evaluate function uses 64 quadrature points by default +V_n_64 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=64) +V_n_128 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=128) + +println("Quadrature convergence:") +println(" 64 points: $(V_n_64)") +println(" 128 points: $(V_n_128)") +println(" Difference: $(abs(V_n_128 - V_n_64))") +``` + +## Tabulation + +For efficiency in simulations, integrals can be pre-computed and stored in +lookup tables: + +```@example p3_integrals +using Oceananigans: CPU + +# Create tabulated fall speed integrals +params = TabulationParameters(Float64; + number_of_mass_points = 10, + number_of_rime_fraction_points = 3, + number_of_liquid_fraction_points = 2, + number_of_quadrature_points = 64) +fs = IceFallSpeed() +fs_tab = tabulate(fs, CPU(), params) + +println("Tabulated fall speed integrals:") +println(" Table summary: $(summary(fs_tab.number_weighted))") +println(" Sample value: $(fs_tab.number_weighted.table[5, 2, 1])") +``` + +## Summary + +P3 uses 29+ integral properties organized by concept: + +| Category | Count | Purpose | +|----------|-------|---------| +| Fall speed | 3 | Sedimentation fluxes | +| Deposition | 6 | Vapor diffusion rates | +| Bulk properties | 7 | Radiation, diagnostics | +| Collection | 2 | Aggregation, riming | +| Sixth moment | 9 | 3-moment closure | +| Lambda limiter | 2 | Distribution bounds | + +All integrals use the same infrastructure: define the integrand, then call +`evaluate(integral_type, state)` with optional quadrature settings. + +## References for This Section + +- [Morrison2015parameterization](@cite): Fall speed, ventilation, collection integrals (Table 3, Section 2) +- [HallPruppacher1976](@cite): Ventilation factor coefficients +- [MilbrandtEtAl2021](@cite): Sixth moment integrals for three-moment ice (Table 1) +- [MilbrandtEtAl2024](@cite): Updated three-moment formulation +- [Morrison2025complete3moment](@cite): Complete three-moment lookup table (29 quantities) diff --git a/docs/src/microphysics/p3_overview.md b/docs/src/microphysics/p3_overview.md new file mode 100644 index 000000000..5157b9d6b --- /dev/null +++ b/docs/src/microphysics/p3_overview.md @@ -0,0 +1,194 @@ +# [Predicted Particle Properties (P3) Microphysics](@id p3_overview) + +The Predicted Particle Properties (P3) scheme represents a paradigm shift in bulk microphysics parameterization. +Rather than using discrete hydrometeor categories (cloud ice, snow, graupel, hail), P3 uses a **single ice category** +with continuously predicted properties that evolve naturally as particles grow, rime, and melt. + +## Motivation + +Traditional bulk microphysics schemes partition frozen hydrometeors into separate categories: + +| Category | Typical Properties | +|----------|-------------------| +| Cloud ice | Small, pristine crystals | +| Snow | Aggregated crystals, low density | +| Graupel | Heavily rimed, moderate density | +| Hail | Fully frozen, ice density | + +This categorical approach creates artificial boundaries. A growing ice particle must "convert" from +one category to another through ad-hoc transfer terms, leading to: + +- **Discontinuous property changes** when particles cross category thresholds +- **Arbitrary conversion parameters** that are difficult to constrain observationally +- **Loss of information** about particle history and evolution + +P3 solves these problems by tracking the **physical properties** of ice particles directly: + +- **Rime mass fraction** ``Fᶠ``: What fraction of particle mass is rime? +- **Rime density** ``ρᶠ``: How dense is the rime layer? +- **Liquid fraction** ``Fˡ``: How much unfrozen water coats the particle? + +These properties evolve continuously through microphysical processes, and particle characteristics +(mass, fall speed, collection efficiency) are diagnosed from them. + +## Key Features of P3 + +### Single Ice Category with Predicted Properties + +Instead of discrete categories, P3 tracks a population of ice particles with a gamma size distribution: + +```math +N'(D) = N₀ D^μ e^{-λD} +``` + +where ``D`` is particle maximum dimension. The **mass-diameter relationship** ``m(D)`` depends on +the predicted rime properties, allowing particles to transition smoothly from pristine crystals +to heavily rimed graupel. + +### Three-Moment Ice + +P3 v5.5 uses three prognostic moments for ice: +1. **Mass** (``ρqⁱ``): Total ice mass concentration +2. **Number** (``ρnⁱ``): Ice particle number concentration +3. **Reflectivity** (``ρzⁱ``): Sixth moment, proportional to radar reflectivity + +The third moment provides additional constraint on the size distribution, improving +representation of precipitation-sized particles. This was introduced in +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) and further refined in +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) and +[Morrison et al. (2025)](@cite Morrison2025complete3moment). + +### Predicted Liquid Fraction + +[Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction) extended P3 to track liquid water on ice particles. +This is crucial for: +- **Wet growth**: Melting particles with liquid coatings +- **Shedding**: Liquid water dripping from large ice +- **Refreezing**: Coating that freezes into rime + +## Scheme Evolution and Citation Guide + +The P3 scheme has evolved through multiple papers. Here we document what each paper contributes +and which equations from each paper are implemented: + +| Version | Reference | Key Contributions | Status | +|---------|-----------|-------------------|--------| +| P3 v1.0 | [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) | Single ice category, predicted rime, m(D) relationships | ✓ Implemented | +| P3 v1.0 | [Morrison et al. (2015b)](@cite Morrison2015part2) | Case study validation | For reference only | +| P3 v2.0 | [Milbrandt & Morrison (2016)](@cite MilbrandtMorrison2016) | Multiple free ice categories | ⚠ Not implemented | +| P3 v3.0 | [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) | Three-moment ice (Z prognostic) | ✓ Implemented | +| P3 v4.0 | [Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) | Updated triple-moment formulation | ✓ Implemented | +| P3 v5.0 | [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction) | Predicted liquid fraction | ✓ Implemented | +| P3 v5.5 | [Morrison et al. (2025)](@cite Morrison2025complete3moment) | Complete three-moment implementation | ✓ Reference implementation | + +Our implementation follows **P3 v5.5** from the official [P3-microphysics repository](https://github.com/P3-microphysics/P3-microphysics). + +### What We Implement + +From [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization): +- Mass-diameter relationship with four regimes (Equations 1-5) +- Area-diameter relationship (Equations 6-8) +- Terminal velocity parameterization (Equations 9-11) +- Rime density parameterization +- μ-λ relationship for size distribution closure + +From [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) and [Milbrandt et al. (2024)](@cite MilbrandtEtAl2024): +- Sixth moment (reflectivity) as prognostic variable +- Reflectivity-weighted fall speed +- Z-tendency from each microphysical process + +From [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction): +- Liquid fraction prognostic variable (``ρqʷⁱ``) +- Shedding process +- Refreezing process + +### What We Do NOT Implement (Future Work) + +!!! note "Multiple Ice Categories" + [Milbrandt & Morrison (2016)](@cite MilbrandtMorrison2016) introduced **multiple free ice categories** + that can coexist and interact. Our implementation uses a single ice category. Multiple categories + may be added in a future version to better represent environments with distinct ice populations + (e.g., anvil ice vs. convective ice). + +!!! note "Full Process Rate Parameterizations" + The full process rate formulations from the P3 papers are documented in [Microphysical Processes](@ref p3_processes) + but are not yet all implemented as tendency functions. Current implementation provides the + integral infrastructure for computing bulk rates; the complete tendency equations are a TODO. + +!!! note "Saturation Adjustment-Free Approach" + The E3SM implementation includes modifications for saturation adjustment-free supersaturation + evolution. Our implementation currently uses saturation adjustment for cloud liquid. + +## Prognostic Variables + +P3 tracks 9 prognostic variables for the hydrometeor population: + +**Cloud liquid** (1 variable): +- ``ρqᶜˡ``: Cloud droplet mass concentration [kg/m³] + +**Rain** (2 variables): +- ``ρqʳ``: Rain mass concentration [kg/m³] +- ``ρnʳ``: Raindrop number concentration [1/m³] + +**Ice** (6 variables): +- ``ρqⁱ``: Total ice mass concentration [kg/m³] +- ``ρnⁱ``: Ice particle number concentration [1/m³] +- ``ρqᶠ``: Rime/frost mass concentration [kg/m³] +- ``ρbᶠ``: Rime volume concentration [m³/m³] +- ``ρzⁱ``: Ice 6th moment (reflectivity proxy) [m⁶/m³] +- ``ρqʷⁱ``: Liquid water on ice [kg/m³] + +From these, diagnostic properties are computed: +- **Rime fraction**: ``Fᶠ = ρqᶠ / ρqⁱ`` +- **Rime density**: ``ρᶠ = ρqᶠ / ρbᶠ`` +- **Liquid fraction**: ``Fˡ = ρqʷⁱ / ρqⁱ`` + +## Quick Start + +```@example p3_overview +using Breeze + +# Create P3 scheme with default parameters +microphysics = PredictedParticlePropertiesMicrophysics() +``` + +```@example p3_overview +# Access ice properties +microphysics.ice +``` + +```@example p3_overview +# Get prognostic field names +prognostic_field_names(microphysics) +``` + +## Documentation Outline + +The following sections provide detailed documentation of the P3 scheme: + +1. **[Particle Properties](@ref p3_particle_properties)**: Mass-diameter and area-diameter relationships +2. **[Size Distribution](@ref p3_size_distribution)**: Gamma PSD and parameter determination +3. **[Integral Properties](@ref p3_integral_properties)**: Bulk properties from PSD integrals +4. **[Microphysical Processes](@ref p3_processes)**: Process rate formulations +5. **[Prognostic Equations](@ref p3_prognostics)**: Tendency equations and model coupling + +## Complete References + +The P3 scheme is described in detail in the following papers: + +### Core P3 Papers + +- [Morrison2015parameterization](@citet): Original P3 formulation with predicted rime (Part I) +- [Morrison2015part2](@citet): Case study comparisons with observations (Part II) +- [MilbrandtMorrison2016](@citet): Extension to multiple free ice categories (Part III) +- [MilbrandtEtAl2021](@citet): Original three-moment ice in JAS +- [MilbrandtEtAl2024](@citet): Updated triple-moment formulation in JAMES +- [MilbrandtEtAl2025liquidfraction](@citet): Predicted liquid fraction on ice +- [Morrison2025complete3moment](@citet): Complete three-moment implementation + +### Related Papers + +- [MilbrandtYau2005](@citet): Multimoment microphysics and spectral shape parameter +- [SeifertBeheng2006](@citet): Two-moment cloud microphysics for mixed-phase clouds +- [KhairoutdinovKogan2000](@citet): Warm rain autoconversion parameterization +- [pruppacher2010microphysics](@citet): Microphysics of clouds and precipitation (textbook) diff --git a/docs/src/microphysics/p3_particle_properties.md b/docs/src/microphysics/p3_particle_properties.md new file mode 100644 index 000000000..3e2287c59 --- /dev/null +++ b/docs/src/microphysics/p3_particle_properties.md @@ -0,0 +1,335 @@ +# [Particle Properties](@id p3_particle_properties) + +Ice particles in P3 span a continuum from small pristine crystals to large rimed graupel. +The mass-diameter and area-diameter relationships vary across this spectrum, depending on +particle size and riming state. + +The foundational particle property relationships are from +[Morrison & Milbrandt (2015a])(@citet), Section 2. + +## Mass-Diameter Relationship + +The particle mass ``m(D)`` follows a piecewise power law that depends on maximum dimension ``D``, +rime fraction ``Fᶠ``, and rime density ``ρᶠ``. This formulation is given in +[Morrison2015parameterization](@citet) Equations 1-5. + +### The Four Regimes + +P3 defines four diameter regimes with distinct mass-diameter relationships: + +**Regime 1: Small Spherical Ice** (``D < D_{th}``) + +Small ice particles are assumed spherical with pure ice density +([Morrison2015parameterization](@citet) Eq. 1): + +```math +m(D) = \frac{π}{6} ρᵢ D³ +``` + +where ``ρᵢ = 900`` kg/m³ is the value used in the reference lookup tables +(pure ice is approximately 917 kg/m³). + +**Regime 2: Vapor-Grown Aggregates** (``D_{th} ≤ D < D_{gr}`` or unrimed) + +Larger particles follow an empirical power law based on aircraft observations +of ice crystals and aggregates ([Morrison2015parameterization](@citet) Eq. 2): + +```math +m(D) = α D^β +``` + +where ``α = 0.0121`` kg/m^β and ``β = 1.9`` are based on observations compiled in the +supplementary material of [Morrison2015parameterization](@citet). +This relationship captures the fractal nature of aggregated crystals. + +**Regime 3: Graupel** (``D_{gr} ≤ D < D_{cr}``) + +When particles acquire sufficient rime, they become compact graupel +with density ``ρ_g`` ([Morrison2015parameterization](@cite) Eq. 3): + +```math +m(D) = \frac{π}{6} ρ_g D³ +``` + +The graupel density ``ρ_g`` depends on the rime fraction and rime density +([Morrison2015parameterization](@citet) Eq. 17): + +```math +ρ_g = Fᶠ ρᶠ + (1 - Fᶠ) ρ_d +``` + +where ``ρ_d`` is the density of the deposited (vapor-grown) ice component. + +**Regime 4: Partially Rimed** (``D ≥ D_{cr}``) + +The largest particles have a rimed core with unrimed aggregate extensions +([Morrison2015parameterization](@citet) Eq. 4): + +```math +m(D) = \frac{α}{1 - Fᶠ} D^β +``` + +### Threshold Diameters + +The transitions between regimes occur at critical diameters determined by +equating masses ([Morrison2015parameterization](@citet) Eqs. 12-14): + +**Spherical-Aggregate Threshold** ``D_{th}``: + +The diameter where spherical mass equals aggregate mass: + +```math +D_{th} = \left( \frac{6α}{π ρᵢ} \right)^{1/(3-β)} +``` + +**Aggregate-Graupel Threshold** ``D_{gr}``: + +The diameter where aggregate mass equals graupel mass: + +```math +D_{gr} = \left( \frac{6α}{π ρ_g} \right)^{1/(3-β)} +``` + +**Graupel-Partial Threshold** ``D_{cr}``: + +The diameter where graupel mass equals partially rimed mass: + +```math +D_{cr} = \left( \frac{6α}{π ρ_g (1 - Fᶠ)} \right)^{1/(3-β)} +``` + +### Deposited Ice Density + +The density of the vapor-deposited (unrimed) component ``ρ_d`` is derived from +the constraint that total mass equals rime mass plus deposited mass. +From [Morrison2015parameterization](@citet) Equation 16: + +```math +ρ_d = \frac{Fᶠ ρᶠ}{(β - 2) \frac{k - 1}{(1 - Fᶠ)k - 1} - (1 - Fᶠ)} +``` + +where ``k = (1 - Fᶠ)^{-1/(3-β)}``. + +## Code Example: Mass-Diameter Relationship + +```@example p3_particles +using Breeze.Microphysics.PredictedParticleProperties +using CairoMakie + +# Create mass power law parameters +mass = IceMassPowerLaw() + +# Compute mass for different particle sizes +D = 10 .^ range(-5, -2, length=100) # 10 μm to 1 cm + +# Unrimed ice +m_unrimed = [ice_mass(mass, 0.0, 400.0, d) for d in D] + +# Moderately rimed (50% rime fraction) +m_rimed = [ice_mass(mass, 0.5, 500.0, d) for d in D] + +# Plot +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [m]", + ylabel = "Mass m [kg]", + xscale = log10, + yscale = log10, + title = "Ice Particle Mass vs Diameter") + +lines!(ax, D, m_unrimed, label="Unrimed (Fᶠ = 0)") +lines!(ax, D, m_rimed, label="Rimed (Fᶠ = 0.5)") + +# Add reference lines for spherical ice +m_sphere = @. mass.ice_density * π / 6 * D^3 +lines!(ax, D, m_sphere, linestyle=:dash, color=:gray, label="Spherical ice") + +axislegend(ax, position=:lt) +fig +``` + +## Code Example: Regime Thresholds + +```@example p3_particles +# Compute thresholds for different rime fractions +mass = IceMassPowerLaw() + +println("Threshold diameters for unrimed ice (Fᶠ = 0):") +thresholds = ice_regime_thresholds(mass, 0.0, 400.0) +println(" D_th (spherical) = $(round(thresholds.spherical * 1e6, digits=1)) μm") + +println("\nThreshold diameters for rimed ice (Fᶠ = 0.5, ρᶠ = 500 kg/m³):") +thresholds = ice_regime_thresholds(mass, 0.5, 500.0) +println(" D_th (spherical) = $(round(thresholds.spherical * 1e6, digits=1)) μm") +println(" D_gr (graupel) = $(round(thresholds.graupel * 1e3, digits=2)) mm") +println(" D_cr (partial) = $(round(thresholds.partial_rime * 1e3, digits=2)) mm") +println(" ρ_g (graupel) = $(round(thresholds.ρ_graupel, digits=1)) kg/m³") +``` + +## Area-Diameter Relationship + +The projected cross-sectional area ``A(D)`` determines collection rates and fall speed. +These relationships are from [Morrison2015parameterization](@citet) +Equations 6-8. + +**Small Spherical Ice** (``D < D_{th}``): + +```math +A(D) = \frac{π}{4} D² +``` + +**Nonspherical Ice** (aggregates): + +```math +A(D) = γ D^σ +``` + +where ``γ`` and ``σ`` are empirical coefficients from +[Mitchell1996powerlaws](@citet) (see [Morrison2015parameterization](@citet) Table 1). + +**Graupel**: + +Reverts to spherical: + +```math +A(D) = \frac{π}{4} D² +``` + +**Partially Rimed**: + +Per official P3 code, the projected area is interpolated by particle mass between +the unrimed and graupel relationships, rather than a simple Fᶠ weighting: + +```math +A(D) = A_{ur} + \frac{m_{pr} - m_{ur}}{m_{gr} - m_{ur}} \left(A_{gr} - A_{ur}\right) +``` + +with ``A_{ur} = γ D^σ``, ``A_{gr} = \frac{π}{4} D^2``, +``m_{ur} = α D^β``, ``m_{gr} = \frac{π}{6} ρ_g D^3``, +and ``m_{pr} = c_{sr} D^{d_{sr}}`` from the partially rimed mass law. + +## Terminal Velocity + +The official P3 code computes terminal velocity using the +[Mitchell and Heymsfield (2005)](@cite MitchellHeymsfield2005) Best-number drag formulation with the +regime-dependent ``m(D)`` and ``A(D)`` relationships. The resulting fall speeds +are stored in lookup tables and include the air-density correction +``(ρ₀/ρ)^{0.54}`` following [Heymsfield et al. (2006)](@cite HeymsfieldEtAl2006). + +Breeze implements this full Best-number formulation directly in the quadrature routines, +ensuring consistency with the lookup tables. For mixed-phase particles, the velocity +interpolates between the ice and rain fall speeds based on liquid fraction. + +## Particle Density + +The effective density ``ρ(D)`` is defined as mass divided by the volume +of a sphere with diameter ``D``: + +```math +ρ(D) = \frac{m(D)}{(π/6) D³} = \frac{6 m(D)}{π D³} +``` + +This definition is convenient for comparing particles of different types +and connects directly to the mass-diameter relationship. + +```@example p3_particles +# Compute effective density across sizes +ρ_unrimed = @. m_unrimed / (π/6 * D^3) +ρ_rimed = @. m_rimed / (π/6 * D^3) + +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [m]", + ylabel = "Effective density ρ [kg/m³]", + xscale = log10, + title = "Ice Particle Density vs Diameter") + +lines!(ax, D, ρ_unrimed, label="Unrimed (Fᶠ = 0)") +lines!(ax, D, ρ_rimed, label="Rimed (Fᶠ = 0.5)") +hlines!(ax, [917], linestyle=:dash, color=:gray, label="Pure ice") + +axislegend(ax, position=:rt) +fig +``` + +## Effect of Riming + +Riming dramatically affects particle properties. This is the key insight of P3 that enables +continuous evolution without discrete category conversions +([Morrison2015parameterization](@citet) Section 2d): + +| Property | Unrimed Aggregate | Heavily Rimed Graupel | +|----------|-------------------|----------------------| +| Mass | ``α D^β`` | ``(π/6) ρ_g D³`` | +| Density | Low (~100 kg/m³) | High (~500 kg/m³) | +| Fall speed | Slow | Fast | +| Collection efficiency | Low | High | + +```@example p3_particles +# Compare mass for different rime fractions +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [mm]", + ylabel = "Mass m [mg]", + title = "Effect of Riming on Particle Mass") + +D_mm = range(0.1, 5, length=50) +D_m = D_mm .* 1e-3 + +for (Ff, label, color) in [(0.0, "Fᶠ = 0", :blue), + (0.25, "Fᶠ = 0.25", :green), + (0.5, "Fᶠ = 0.5", :orange), + (0.75, "Fᶠ = 0.75", :red)] + m = [ice_mass(mass, Ff, 500.0, d) for d in D_m] + lines!(ax, D_mm, m .* 1e6, label=label, color=color) # Convert to mg +end + +axislegend(ax, position=:lt) +fig +``` + +## Rime Density Parameterization + +The rime density ``ρᶠ`` depends on the collection conditions during riming. The +parameterization follows [Cober and List (1993)](@cite CoberList1993) as implemented in +[Morrison2015parameterization](@citet). The rime density is computed as a function of +the impact parameter ``R_i``, which depends on droplet size, impact velocity, and temperature: + +```math +ρᶠ = \begin{cases} +(0.051 + 0.114 R_i - 0.0055 R_i^2) \times 1000 & R_i \le 8 \\ +611 + 72.25 (R_i - 8) & R_i > 8 +\end{cases} +``` + +The rime density is bounded: +- ``ρ_{min} = 50`` kg/m³ is minimum rime density +- ``ρ_{max} = 900`` kg/m³ is maximum rime density + +The rime density affects the graupel density ``ρ_g`` and thus the regime thresholds. +As particles rime more heavily, they become denser and more spherical. + +!!! note "Official P3 implementation details" + The Fortran scheme clamps ``R_i`` to [1, 12] before applying the Cober–List fit; + the linear branch for ``R_i > 8`` is extended to ``R_i = 12`` so that + ``ρᶠ = 900`` kg/m³. When riming is inactive, ``ρᶠ`` defaults to 400 kg/m³. + The lookup tables discretize ``ρᶠ`` on an uneven grid (50, 250, 450, 650, 900 kg/m³) + and interpolate between bins. + +## Summary + +The P3 mass-diameter relationship captures the full spectrum of ice particle types: + +1. **Small crystals**: Dense, spherical approximation +2. **Aggregates**: Fractal structure, low density, follows ``m ∝ D^{1.9}`` +3. **Graupel**: Compact, dense from riming +4. **Partially rimed**: Large aggregates with rimed cores + +The transitions occur naturally through the regime thresholds, which depend only on the +predicted rime fraction and rime density—no arbitrary conversion terms required. + +## References for This Section + +- [Morrison2015parameterization](@cite): Primary source for m(D), A(D), V(D) relationships +- [Morrison2015part2](@cite): Validation of particle property parameterizations +- [pruppacher2010microphysics](@cite): Background on ice particle physics diff --git a/docs/src/microphysics/p3_processes.md b/docs/src/microphysics/p3_processes.md new file mode 100644 index 000000000..d19284cae --- /dev/null +++ b/docs/src/microphysics/p3_processes.md @@ -0,0 +1,407 @@ +# [Microphysical Processes](@id p3_processes) + +P3 includes a comprehensive set of microphysical processes governing the evolution +of cloud, rain, and ice hydrometeors. This section documents the physical formulations +and rate equations from the P3 papers. + +!!! note "Implementation Status" + The process rate formulations documented here are from the P3 papers. Our implementation + provides the integral infrastructure for computing bulk rates (see [Integral Properties](@ref p3_integral_properties)). + Full tendency functions for all processes are a TODO for future work. + +## Process Overview + +``` + ┌─────────────┐ + │ Vapor │ + └──────┬──────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Cloud │ │ Ice │ │ Rain │ + └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + │ accretion │ riming │ + └───────────────►├◄───────────────┘ + │ + melting + │ + ▼ + ┌──────────┐ + │ Rain │ + └──────────┘ +``` + +The following subsections document processes from: +- [Morrison2015parameterization](@citet): Core process formulations +- [MilbrandtEtAl2021](@citet): Z-tendencies for each process +- [MilbrandtEtAl2025liquidfraction](@citet): Liquid fraction processes + +## Warm Rain Processes + +### Condensation and Evaporation + +Cloud liquid grows by condensation when supersaturated with respect to liquid water. +The saturation adjustment approach instantaneously relaxes to saturation +[rogers1989short](@cite): + +```math +\frac{dq^{cl}}{dt} = \frac{q_v - q_{vs}(T)}{\tau_c} +``` + +where ``\tau_c`` is the condensation timescale (default 1 s) and ``q_{vs}`` is +saturation specific humidity. + +!!! note "Explicit Supersaturation" + The E3SM implementation of P3 includes modifications for explicit supersaturation + evolution rather than saturation adjustment. Our implementation currently uses + saturation adjustment for cloud liquid. + +### Autoconversion + +Cloud droplets grow to rain through collision-coalescence. The +[KhairoutdinovKogan2000](@citet) +parameterization expresses autoconversion as: + +```math +\frac{dq^r}{dt}\bigg|_{auto} = 1350 \, q_{cl}^{2.47} N_c^{-1.79} +``` + +where ``q_{cl}`` is cloud liquid mixing ratio and ``N_c`` is cloud droplet number +concentration. + +The threshold diameter for autoconversion (default 25 μm) sets the boundary between +cloud and rain. + +!!! note "Alternative Autoconversion Schemes" + P3 supports multiple warm-rain autoconversion/accretion parameterizations: + - **Khairoutdinov & Kogan (2000)**: Default scheme (shown above) + - **Seifert & Beheng (2001)**: Alternative with droplet spectral shape dependence + - **Kogan (2013)**: Updated formulation with different exponents + + The scheme is selected via the `autoAccr_param` parameter in the Fortran code. + +### Accretion + +Rain collects cloud droplets ([Morrison2015parameterization](@citet) Eq. 46): + +```math +\frac{dq^r}{dt}\bigg|_{accr} = E_{rc} \frac{\pi}{4} q^{cl} \int_0^∞ D^2 V(D) N'_r(D)\, dD +``` + +where ``E_{rc}`` is the rain-cloud collection efficiency and ``N'_r`` is the rain +drop size distribution. + +### Rain Evaporation + +Below cloud base, rain evaporates in subsaturated air +[pruppacher2010microphysics](@cite): + +```math +\frac{dm}{dt} = 4\pi C D_v f_v (ρ_v - ρ_{vs}) +``` + +where: +- ``C = D/2`` is the droplet capacity (spherical) +- ``D_v`` is water vapor diffusivity +- ``f_v`` is the ventilation factor +- ``ρ_v - ρ_{vs}`` is the vapor deficit + +Integrated over the drop size distribution +([Morrison2015parameterization](@citet) Eq. 47): + +```math +\frac{dq^r}{dt}\bigg|_{evap} = 2\pi D_v (S - 1) \int_0^∞ D f_v N'_r(D)\, dD +``` + +where ``S = ρ_v/ρ_{vs}`` is the saturation ratio. + +## Ice Nucleation + +### Heterogeneous Nucleation + +Ice nucleating particles (INPs) activate at temperatures below about -5°C. +From [Morrison2015parameterization](@citet) Section 2f: + +```math +\frac{dN^i}{dt}\bigg|_{het} = n_{INP}(T) \frac{d T}{dt}\bigg|_{neg} +``` + +where ``n_{INP}(T)`` follows parameterizations like [DeMottEtAl2010icenuclei](@citet) +or [MeyerEtAl1992icenucleation](@citet). + +!!! note "INP Parameterization" + The specific INP parameterization is configurable in P3. Our implementation + will support multiple options when nucleation rates are fully implemented. + +### Homogeneous Freezing + +Cloud droplets freeze homogeneously at ``T < -38°C`` +[Morrison2015parameterization](@cite): + +```math +\frac{dq^i}{dt}\bigg|_{hom} = q^{cl} \quad \text{when } T < 235\,\text{K} +``` + +### Secondary Ice Production + +#### Hallett-Mossop Process + +Rime splintering produces secondary ice in the temperature range -3 to -8°C +([Morrison2015parameterization](@citet) Section 2g): + +```math +\frac{dN^i}{dt}\bigg|_{HM} = C_{HM} \frac{dq^f}{dt} +``` + +where ``C_{HM} \approx 350`` splinters per mg of rime. + +## Vapor-Ice Exchange + +### Deposition Growth + +Ice particles grow by vapor deposition when ``S_i > 1`` (supersaturated wrt ice). +From [Morrison2015parameterization](@citet) Eq. 30: + +```math +\frac{dm}{dt} = 4\pi C f_v \frac{S_i - 1}{\frac{L_s}{K_a T}\left(\frac{L_s}{R_v T} - 1\right) + \frac{R_v T}{e_{si} D_v}} +``` + +where: +- ``C`` is the particle capacity (shape-dependent) +- ``f_v`` is the ventilation factor +- ``L_s`` is latent heat of sublimation +- ``K_a`` is thermal conductivity of air +- ``e_{si}`` is saturation vapor pressure over ice + +Integrated over the size distribution: + +```math +\frac{dq^i}{dt}\bigg|_{dep} = 4\pi n_i D_v (S_i - 1) f(T, p) \int_0^∞ C(D) f_v(D) N'(D)\, dD +``` + +The ventilation integrals (see [Integral Properties](@ref p3_integral_properties)) +compute this integral efficiently. The ventilation enhancement factor is documented +in [Morrison2015parameterization](@citet) Table 3. + +### Sublimation + +The same formulation applies for ``S_i < 1``, with mass loss rather than gain. + +### Z-Tendency from Deposition + +For three-moment ice [MilbrandtEtAl2021,Morrison2025complete3moment](@cite), the sixth moment +tendency from deposition/sublimation is: + +```math +\frac{dZ}{dt}\bigg|_{dep} = 6 \frac{Z}{L} \frac{dL}{dt}\bigg|_{dep} \cdot \mathcal{F}_{dep} +``` + +where ``\mathcal{F}_{dep}`` is a correction factor from the lookup tables. + +## Collection Processes + +### Riming (Ice-Cloud Collection) + +Ice particles collect cloud droplets +([Morrison2015parameterization](@citet) Eq. 36): + +```math +\frac{dq^f}{dt} = E_{ic} q^{cl} \int_0^∞ A(D) V(D) N'(D)\, dD +``` + +where ``E_{ic}`` is the ice-cloud collection efficiency (default 0.5). + +Simultaneously, the rime volume increases: + +```math +\frac{db^f}{dt} = \frac{1}{ρ^f} \frac{dq^f}{dt} +``` + +where ``ρ^f`` is the rime density, which depends on impact velocity and temperature. + +#### Rime Density Parameterization + +From [Cober and List (1993)](@cite CoberList1993) as used in +[Morrison2015parameterization](@citet): + +```math +ρ^f = \min\left(900, \max\left(50, f(R_i)\right)\right) +``` + +where ``R_i`` is an impact parameter depending on droplet size, impact velocity, and temperature. + +### Ice-Rain Collection + +Ice particles can also collect raindrops +([Morrison2015parameterization](@citet) Eq. 40): + +```math +\frac{dq^f}{dt}\bigg|_{ir} = E_{ir} \int_0^∞ \int_0^∞ K(D_i, D_r) N'_i(D_i) N'_r(D_r)\, dD_i dD_r +``` + +where the collection kernel is: + +```math +K(D_i, D_r) = \frac{\pi}{4}(D_i + D_r)^2 |V_i - V_r| +``` + +### Aggregation (Ice-Ice Collection) + +Ice particles aggregate when they collide +([Morrison2015parameterization](@citet) Eq. 42): + +```math +\frac{dN^i}{dt}\bigg|_{agg} = -\frac{1}{2} E_{agg} \int_0^∞ \int_0^∞ K(D_1, D_2) N'(D_1) N'(D_2)\, dD_1 dD_2 +``` + +The factor of 1/2 avoids double-counting. Mass is conserved; only number decreases. + +The aggregation efficiency ``E_{agg}`` depends on temperature: +- ``E_{agg} = 0.001`` for ``T < 253.15`` K (−20°C) +- Linear ramp from 0.001 to 0.3 between 253.15 K and 268.15 K +- ``E_{agg} = 0.3`` for ``T > 268.15`` K (−5°C) + +The maximum efficiency occurs near −5°C where ice surfaces become "sticky". + +## Phase Change Processes + +### Melting + +At ``T > 273.15`` K, ice particles melt +([Morrison2015parameterization](@citet) Eq. 44): + +```math +\frac{dm}{dt} = -\frac{4\pi C}{L_f} \left[ K_a (T - T_0) + L_v D_v (ρ_v - ρ_{vs}) \right] f_v +``` + +where: +- ``L_f`` is latent heat of fusion +- ``T_0 = 273.15`` K is the melting point +- The first term is sensible heat transfer +- The second term is latent heat from vapor deposition + +### Liquid Fraction During Melting + +With predicted liquid fraction [MilbrandtEtAl2025liquidfraction](@cite), +meltwater initially coats the ice particle (increasing ``q^{wi}``): + +```math +\frac{dq^{wi}}{dt}\bigg|_{melt} = -\frac{dm_{ice}}{dt} +``` + +This allows tracking of wet ice particles before complete melting. + +### Shedding + +When liquid fraction exceeds a threshold (typically 50%), excess liquid sheds as rain +[MilbrandtEtAl2025liquidfraction](@cite): + +```math +\frac{dq^{wi}}{dt}\bigg|_{shed} = -k_{shed} (F^l - F^l_{max}) q^i \quad \text{when } F^l > F^l_{max} +``` + +The shed mass converts to rain: + +```math +\frac{dq^r}{dt}\bigg|_{shed} = -\frac{dq^{wi}}{dt}\bigg|_{shed} +``` + +### Refreezing + +Liquid on ice can refreeze, converting to rime +[MilbrandtEtAl2025liquidfraction](@cite): + +```math +\frac{dq^{wi}}{dt}\bigg|_{refreeze} = -q^{wi} / \tau_{freeze} \quad \text{when } T < 273\,\text{K} +``` + +```math +\frac{dq^f}{dt}\bigg|_{refreeze} = -\frac{dq^{wi}}{dt}\bigg|_{refreeze} +``` + +## Sedimentation + +Hydrometeors fall under gravity. The flux divergence appears in the tendency: + +```math +\frac{\partial ρq}{\partial t}\bigg|_{sed} = -\frac{\partial (ρq V)}{\partial z} +``` + +Different moments sediment at different rates +[MilbrandtYau2005](@cite): + +| Quantity | Sedimentation Velocity | +|----------|----------------------| +| Number ``N`` | ``V_n`` (number-weighted) | +| Mass ``L`` | ``V_m`` (mass-weighted) | +| Reflectivity ``Z`` | ``V_z`` (Z-weighted) | + +This differential sedimentation causes the size distribution to evolve as particles fall. +The three velocities are computed using the fall speed integrals +(see [Integral Properties](@ref p3_integral_properties)). + +For three-moment ice [MilbrandtEtAl2021](@cite), +tracking ``V_z`` allows proper size sorting of precipitation particles. + +## Process Summary + +| Process | Affects | Key Parameter | Reference | +|---------|---------|---------------|-----------| +| Condensation | ``q^{cl}`` | Saturation timescale | [Rogers & Yau (1989)](@cite rogers1989short) | +| Autoconversion | ``q^{cl} \to q^r`` | Scheme-dependent | [KhairoutdinovKogan2000](@cite), [SeifertBeheng2006](@cite) | +| Accretion | ``q^{cl} \to q^r`` | Collection efficiency | [Morrison2015parameterization](@cite) | +| Rain evaporation | ``q^r \to q_v`` | Ventilation | [Morrison2015parameterization](@cite) | +| Heterogeneous nucleation | ``N^i`` | INP concentration | [Morrison2015parameterization](@cite) | +| Homogeneous freezing | ``q^{cl} \to q^i`` | T threshold | [Morrison2015parameterization](@cite) | +| Deposition | ``q^i`` | Ventilation, ``S_i`` | [Morrison2015parameterization](@cite) | +| Sublimation | ``q^i \to q_v`` | Ventilation, ``S_i`` | [Morrison2015parameterization](@cite) | +| Riming | ``q^{cl} \to q^f`` | ``E_{ic}`` | [Morrison2015parameterization](@cite) | +| Ice-rain collection | ``q^r \to q^f`` | ``E_{ir}`` | [Morrison2015parameterization](@cite) | +| Aggregation | ``N^i`` | ``E_{agg}(T)`` | [Morrison2015parameterization](@cite) | +| Melting | ``q^i \to q^{wi} \to q^r`` | ``T > 273`` K | [Morrison2015parameterization](@cite) | +| Shedding | ``q^{wi} \to q^r`` | ``F^l_{max}`` | [MilbrandtEtAl2025liquidfraction](@cite) | +| Refreezing | ``q^{wi} \to q^f`` | ``T < 273`` K | [MilbrandtEtAl2025liquidfraction](@cite) | +| Sedimentation | All | ``V_n, V_m, V_z`` | [MilbrandtYau2005](@cite) | + +## Temperature Dependence + +Many processes have strong temperature dependence: + +``` +T < 235 K: Homogeneous freezing (cloud → ice) +235-268 K: Heterogeneous nucleation, deposition growth +265-270 K: Hallett-Mossop ice multiplication (-8 to -3°C) +T > 268 K: Maximum aggregation efficiency (E_agg = 0.3) +T > 273 K: Melting, shedding +``` + +## Coupling to Thermodynamics + +Microphysical processes release or absorb latent heat: + +```math +\frac{dT}{dt}\bigg|_{micro} = \frac{L_v}{c_p} \frac{dq^{cl}}{dt} + \frac{L_s}{c_p} \frac{dq^i}{dt} + \frac{L_f}{c_p} \frac{dq^f}{dt} +``` + +where: +- ``L_v \approx 2.5 \times 10^6`` J/kg (vaporization) +- ``L_s \approx 2.83 \times 10^6`` J/kg (sublimation) +- ``L_f \approx 3.34 \times 10^5`` J/kg (fusion) + +## References for This Section + +### Core P3 Process References +- [Morrison2015parameterization](@cite): Primary process formulations (Section 2) +- [Morrison2015part2](@cite): Process validation against observations +- [MilbrandtEtAl2021](@cite): Z-tendencies for three-moment ice +- [MilbrandtEtAl2025liquidfraction](@cite): Liquid fraction processes (shedding, refreezing) +- [Morrison2025complete3moment](@cite): Complete three-moment process rates + +### Related References +- [KhairoutdinovKogan2000](@cite): Warm rain autoconversion +- [MilbrandtYau2005](@cite): Multimoment sedimentation +- [pruppacher2010microphysics](@cite): Cloud physics fundamentals +- [rogers1989short](@cite): Cloud physics textbook diff --git a/docs/src/microphysics/p3_prognostics.md b/docs/src/microphysics/p3_prognostics.md new file mode 100644 index 000000000..1658cb6d0 --- /dev/null +++ b/docs/src/microphysics/p3_prognostics.md @@ -0,0 +1,293 @@ +# [Prognostic Variables and Tendencies](@id p3_prognostics) + +P3 tracks 9 prognostic variables that together describe the complete microphysical +state of the atmosphere. This section documents each variable, its physical meaning, +and the tendency equations governing its evolution. + +The prognostic variable formulation has evolved through the P3 papers: +- [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization): Original 4 ice variables +- [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021): Added ``ρz^i`` for 3-moment ice +- [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction): Added ``ρq^{wi}`` for liquid fraction + +Our implementation follows P3 v5.5 with all 6 ice prognostic variables. + +## Variable Definitions + +### Cloud Liquid + +| Symbol | Name | Units | Description | +|--------|------|-------|-------------| +| ``ρq^{cl}`` | Cloud liquid mass density | kg/m³ | Mass of cloud droplets per unit volume | + +Cloud droplet **number** is prescribed (not prognostic) in the standard P3 configuration, +typically set to continental (100 cm⁻³) or marine (50 cm⁻³) values. + +### Rain + +| Symbol | Name | Units | Description | +|--------|------|-------|-------------| +| ``ρq^r`` | Rain mass density | kg/m³ | Mass of raindrops per unit volume | +| ``ρn^r`` | Rain number density | m⁻³ | Number of raindrops per unit volume | + +Rain follows a gamma size distribution with parameters diagnosed from the mass/number ratio. + +### Ice + +| Symbol | Name | Units | Description | +|--------|------|-------|-------------| +| ``ρq^i`` | Total ice mass density | kg/m³ | Total ice mass (all forms) | +| ``ρn^i`` | Ice number density | m⁻³ | Number of ice particles | +| ``ρq^f`` | Rime mass density | kg/m³ | Mass of rime (frost) on ice | +| ``ρb^f`` | Rime volume density | m³/m³ | Volume of rime per unit volume | +| ``ρz^i`` | Ice reflectivity | m⁶/m³ | 6th moment of size distribution | +| ``ρq^{wi}`` | Water on ice | kg/m³ | Liquid water coating ice particles | + +## Derived Quantities + +From the prognostic variables, key diagnostic properties are computed: + +**Rime fraction** (mass fraction of rime): +```math +F^f = \frac{ρq^f}{ρq^i} +``` + +**Rime density** (density of the rime layer): +```math +ρ^f = \frac{ρq^f}{ρb^f} +``` + +**Liquid fraction** (mass fraction of liquid coating): +```math +F^l = \frac{ρq^{wi}}{ρq^i} +``` + +**Mean particle mass**: +```math +\bar{m} = \frac{ρq^i}{ρn^i} +``` + +## Tendency Equations + +Each prognostic variable evolves according to: + +```math +\frac{\partial (ρX)}{\partial t} = \text{ADV} + \text{TURB} + \text{SED} + \text{SRC} +``` + +where: +- **ADV**: Advection by resolved flow +- **TURB**: Subgrid turbulent transport +- **SED**: Sedimentation (gravitational settling) +- **SRC**: Microphysical source/sink terms + +### Cloud Liquid Tendency + +```math +\frac{\partial ρq^{cl}}{\partial t}\bigg|_{src} = \underbrace{COND}_{\text{condensation}} +- \underbrace{EVAP}_{\text{evaporation}} +- \underbrace{AUTO}_{\text{autoconversion}} +- \underbrace{ACCR}_{\text{accretion by rain}} +- \underbrace{RIM}_{\text{riming by ice}} +- \underbrace{HOMF}_{\text{homogeneous freezing}} +``` + +### Rain Mass Tendency + +```math +\frac{\partial ρq^r}{\partial t}\bigg|_{src} = \underbrace{AUTO}_{\text{autoconversion}} ++ \underbrace{ACCR}_{\text{accretion}} ++ \underbrace{SHED}_{\text{shedding from ice}} ++ \underbrace{MELT}_{\text{complete melting}} +- \underbrace{EVAP}_{\text{rain evaporation}} +- \underbrace{COLL}_{\text{collection by ice}} +- \underbrace{FREZ}_{\text{freezing}} +``` + +### Rain Number Tendency + +```math +\frac{\partial ρn^r}{\partial t}\bigg|_{src} = \underbrace{AUTO_n}_{\text{autoconversion}} ++ \underbrace{SHED_n}_{\text{shedding}} ++ \underbrace{MELT_n}_{\text{melting}} +- \underbrace{EVAP_n}_{\text{evaporation}} +- \underbrace{COLL_n}_{\text{collection}} +- \underbrace{SCBK}_{\text{self-collection/breakup}} +``` + +### Ice Mass Tendency + +```math +\frac{\partial ρq^i}{\partial t}\bigg|_{src} = \underbrace{NUC}_{\text{nucleation}} ++ \underbrace{DEP}_{\text{deposition}} ++ \underbrace{RIM}_{\text{riming}} ++ \underbrace{COLL}_{\text{rain collection}} +- \underbrace{SUB}_{\text{sublimation}} +- \underbrace{MELT}_{\text{melting}} +``` + +### Ice Number Tendency + +```math +\frac{\partial ρn^i}{\partial t}\bigg|_{src} = \underbrace{NUC_n}_{\text{nucleation}} ++ \underbrace{SEC}_{\text{secondary production}} +- \underbrace{AGG_n}_{\text{aggregation}} +- \underbrace{MELT_n}_{\text{melting}} +``` + +### Rime Mass Tendency + +```math +\frac{\partial ρq^f}{\partial t}\bigg|_{src} = \underbrace{RIM}_{\text{riming}} ++ \underbrace{COLL}_{\text{rain collection}} ++ \underbrace{REFR}_{\text{refreezing}} +- \underbrace{SUB_f}_{\text{sublimation}} +- \underbrace{MELT_f}_{\text{melting}} +``` + +### Rime Volume Tendency + +```math +\frac{\partial ρb^f}{\partial t}\bigg|_{src} = \frac{1}{ρ^f}\left(\underbrace{RIM}_{\text{riming}} ++ \underbrace{COLL}_{\text{rain collection}} ++ \underbrace{REFR}_{\text{refreezing}}\right) +- \underbrace{SUB_b}_{\text{sublimation}} +- \underbrace{MELT_b}_{\text{melting}} +``` + +### Reflectivity Tendency (3-moment) + +```math +\frac{\partial ρz^i}{\partial t}\bigg|_{src} = \underbrace{DEP_z}_{\text{deposition}} ++ \underbrace{RIM_z}_{\text{riming}} +- \underbrace{AGG_z}_{\text{aggregation}} +- \underbrace{SUB_z}_{\text{sublimation}} +- \underbrace{MELT_z}_{\text{melting}} +- \underbrace{SHED_z}_{\text{shedding}} +``` + +### Liquid on Ice Tendency + +```math +\frac{\partial ρq^{wi}}{\partial t}\bigg|_{src} = \underbrace{MELT_{part}}_{\text{partial melting}} +- \underbrace{SHED}_{\text{shedding}} +- \underbrace{REFR}_{\text{refreezing}} +- \underbrace{EVAP_{wi}}_{\text{evaporation}} +``` + +## Sedimentation + +Each quantity sediments at its characteristic velocity: + +| Variable | Sedimentation Velocity | Flux | +|----------|----------------------|------| +| ``ρq^r`` | ``V_m^r`` | ``F_q^r = -V_m^r ρq^r`` | +| ``ρn^r`` | ``V_n^r`` | ``F_n^r = -V_n^r ρn^r`` | +| ``ρq^i`` | ``V_m^i`` | ``F_q^i = -V_m^i ρq^i`` | +| ``ρn^i`` | ``V_n^i`` | ``F_n^i = -V_n^i ρn^i`` | +| ``ρq^f`` | ``V_m^i`` | ``F_q^f = -V_m^i ρq^f`` | +| ``ρb^f`` | ``V_m^i`` | ``F_b^f = -V_m^i ρb^f`` | +| ``ρz^i`` | ``V_z^i`` | ``F_z^i = -V_z^i ρz^i`` | +| ``ρq^{wi}`` | ``V_m^i`` | ``F_q^{wi} = -V_m^i ρq^{wi}`` | + +The sedimentation tendency is: + +```math +\frac{\partial ρX}{\partial t}\bigg|_{sed} = -\frac{\partial F_X}{\partial z} +``` + +## Coupling to AtmosphereModel + +In Breeze, P3 microphysics couples to `AtmosphereModel` through the microphysics interface: + +```julia +# Prognostic field names +names = prognostic_field_names(microphysics) +# Returns (:ρqᶜˡ, :ρqʳ, :ρnʳ, :ρqⁱ, :ρnⁱ, :ρqᶠ, :ρbᶠ, :ρzⁱ, :ρqʷⁱ) +``` + +The microphysics scheme provides: +1. **`microphysical_tendency`**: Computes source terms for all prognostic variables +2. **`compute_moisture_fractions`**: Converts prognostic densities to mixing ratios +3. **`update_microphysical_fields!`**: Updates diagnostic fields after state update + +## Conservation Properties + +P3 conserves: + +**Total water**: +```math +\frac{d}{dt}\left( q_v + q^{cl} + q^r + q^i \right) = 0 \quad \text{(closed system)} +``` + +**Ice number** (modulo nucleation, aggregation, melting): +```math +\frac{dN^i}{dt} = NUC_n + SEC - AGG_n - MELT_n +``` + +**Energy** (through latent heat coupling): +```math +\frac{dθ}{dt} = \frac{1}{c_p Π}\left( L_v \dot{q}^{cl} + L_s \dot{q}^i + L_f \dot{q}^f \right) +``` + +## Numerical Considerations + +### Positivity + +All prognostic variables must remain non-negative. Limiters ensure: + +```math +ρX^{n+1} = \max(0, ρX^n + Δt \cdot \text{tendency}) +``` + +### Consistency + +The rime fraction must satisfy ``0 ≤ F^f ≤ 1``: + +```math +ρq^f ≤ ρq^i +``` + +Similarly for liquid fraction: + +```math +ρq^{wi} ≤ ρq^i +``` + +### Threshold Handling + +Small values below numerical thresholds are set to zero: + +```julia +q_min = microphysics.minimum_mass_mixing_ratio # Default: 1e-14 kg/kg +n_min = microphysics.minimum_number_mixing_ratio # Default: 1e-16 1/kg +``` + +## Code Example + +```@example p3_prognostics +using Breeze + +p3 = PredictedParticlePropertiesMicrophysics() + +# Get all prognostic field names +names = prognostic_field_names(p3) +println("Prognostic fields:") +for name in names + println(" ", name) +end +``` + +```@example p3_prognostics +# Access thresholds +println("\nNumerical thresholds:") +println(" Minimum mass mixing ratio: ", p3.minimum_mass_mixing_ratio, " kg/kg") +println(" Minimum number mixing ratio: ", p3.minimum_number_mixing_ratio, " 1/kg") +``` + +## References for This Section + +- [Morrison2015parameterization](@cite): Original prognostic variables and tendencies (Section 2) +- [MilbrandtEtAl2021](@cite): Sixth moment prognostic (``ρz^i``) for three-moment ice +- [MilbrandtEtAl2025liquidfraction](@cite): Liquid fraction prognostic (``ρq^{wi}``) +- [Morrison2025complete3moment](@cite): Complete tendency equations with all six ice variables +- [MilbrandtYau2005](@cite): Multi-moment microphysics and sedimentation diff --git a/docs/src/microphysics/p3_size_distribution.md b/docs/src/microphysics/p3_size_distribution.md new file mode 100644 index 000000000..f6b073c40 --- /dev/null +++ b/docs/src/microphysics/p3_size_distribution.md @@ -0,0 +1,333 @@ +# [Size Distribution](@id p3_size_distribution) + +P3 assumes ice particles follow a **gamma size distribution**, with parameters +determined from prognostic moments and empirical closure relations. + +## Gamma Size Distribution + +The number concentration of ice particles per unit volume, as a function of +maximum dimension ``D``, follows ([Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) Eq. 19): + +```math +N'(D) = N₀ D^μ e^{-λD} +``` + +where: +- ``N'(D)`` [m⁻⁴] is the number concentration per unit diameter +- ``N₀`` [m⁻⁵⁻μ] is the intercept parameter +- ``μ`` [-] is the shape parameter (≥ 0) +- ``λ`` [m⁻¹] is the slope parameter + +The shape parameter ``μ`` controls the distribution width: +- ``μ = 0``: Exponential (Marshall-Palmer) distribution +- ``μ > 0``: Narrower distribution with a mode at ``D = μ/λ`` + +This form is standard in cloud microphysics and is discussed in +[Milbrandt & Yau (2005)](@cite MilbrandtYau2005) for multi-moment schemes. + +## Moments of the Distribution + +The ``k``-th moment of the size distribution is: + +```math +M_k = \int_0^∞ D^k N'(D)\, dD = N₀ \int_0^∞ D^{k+μ} e^{-λD}\, dD +``` + +Using the gamma function identity ``\int_0^∞ x^{a-1} e^{-x} dx = Γ(a)``: + +```math +M_k = N₀ \frac{Γ(k + μ + 1)}{λ^{k+μ+1}} +``` + +### Key Moments + +**Number concentration** (0th moment): + +```math +N = M_0 = N₀ \frac{Γ(μ + 1)}{λ^{μ+1}} +``` + +**Mean diameter** (1st moment / 0th moment): + +```math +\bar{D} = \frac{M_1}{M_0} = \frac{μ + 1}{λ} +``` + +**Reflectivity** (6th moment) — this is the third prognostic variable in three-moment P3 +([Milbrandt et al. (2021)](@cite MilbrandtEtAl2021)): + +```math +Z ∝ M_6 = N₀ \frac{Γ(μ + 7)}{λ^{μ+7}} +``` + +## Shape-Slope (μ-λ) Relationship + +In the officail P3 code, ``μ`` is diagnosed rather than set by a +single global power law. Define the mean-volume diameter estimate (in mm) + +```math +D_{mvd} = 10^3 \left(\frac{L}{c_{gp}}\right)^{1/3}, +``` + +where ``c_{gp}`` is the coefficient in the fully rimed mass law ``m(D) = c_{gp} D^3``. +Then: + +```math +μ = +\begin{cases} +\text{clamp}\left(0.076 (0.01 λ)^{0.8} - 2,\ 0,\ 6\right), & D_{mvd} \le 0.2\,\text{mm} \\ +\text{clamp}\left(0.25 (D_{mvd} - 0.2)\, f_ρ\, Fᶠ,\ 0,\ μ_{max}\right), & D_{mvd} > 0.2\,\text{mm} +\end{cases} +``` + +with + +```math +f_ρ = \max\left(1,\ 1 + 0.00842(\bar{ρ}-400)\right), +\quad \bar{ρ} = \frac{6 c_{gp}}{π}, +\quad μ_{max} = 20. +``` + +The first branch corresponds to the Heymsfield (2003) μ–λ fit (Eq. 27 in +[Morrison2015parameterization](@cite)), written with λ in m⁻¹ (the factor 0.01 +converts to cm⁻¹). The second branch increases ``μ`` with particle size and riming +in the Fortran lookup-table generator. + +!!! note "Breeze helper closure" + Breeze implements the `P3Closure` which matches the official P3 Fortran logic. + For small particles (``D_{mvd} \le 0.2`` mm), it uses the Heymsfield (2003) power-law relation. + For large particles (``D_{mvd} > 0.2`` mm), it uses the diagnostic based on mean volume diameter + and rime density to account for riming effects. This ensures consistency with the lookup tables. + +!!! note "Three-Moment Mode" + In the officail P3 code, ``μ`` (and the bulk ice density used in rates) are obtained + from lookup table 3 (`p3_lookupTable_3.dat-v1.4`) by interpolation in the Z/Q space, + rime fraction, liquid fraction, and rime density. The analytic moment relations + provide the conceptual basis for the table but are not solved directly at runtime. + +```@example p3_psd +using Breeze.Microphysics.PredictedParticleProperties +using CairoMakie + +# Compute μ vs λ +relation = ShapeParameterRelation() +λ_values = 10 .^ range(2, 5, length=100) +μ_values = [shape_parameter(relation, log(λ)) for λ in λ_values] + +fig = Figure(size=(500, 350)) +ax = Axis(fig[1, 1], + xlabel = "Slope parameter λ [m⁻¹]", + ylabel = "Shape parameter μ", + xscale = log10, + title = "μ-λ Relationship (Morrison & Milbrandt 2015a)") + +lines!(ax, λ_values, μ_values, linewidth=2) +hlines!(ax, [relation.μmax], linestyle=:dash, color=:gray, label="μmax") + +fig +``` + +## Determining Distribution Parameters + +Given prognostic moments ``L`` (mass concentration) and ``N`` (number concentration), +plus predicted rime properties ``Fᶠ`` and ``ρᶠ``, we solve for the distribution +parameters ``(N₀, λ, μ)``. + +In the official P3 lookup tables, rime and liquid fractions are tabulated on +discrete bins (0, 1/3, 2/3, 1) and interpolated during lookup. + +### The Mass-Number Ratio + +The ratio of ice mass to number concentration depends on the distribution parameters: + +```math +\frac{L}{N} = \frac{\int_0^∞ m(D) N'(D)\, dD}{\int_0^∞ N'(D)\, dD} +``` + +For a power-law mass relationship ``m(D) = α D^β``, this simplifies to: + +```math +\frac{L}{N} = α \frac{Γ(β + μ + 1)}{λ^β Γ(μ + 1)} +``` + +However, P3 uses a **piecewise** mass-diameter relationship with four regimes +(see [Particle Properties](@ref p3_particle_properties)), so the integral must +be computed over each regime separately. + +### Lambda Solver + +Finding ``λ`` requires solving: + +```math +\log\left(\frac{L}{N}\right) = \log\left(\frac{\int_0^∞ m(D) N'(D)\, dD}{\int_0^∞ N'(D)\, dD}\right) +``` + +This is a nonlinear equation in ``λ`` (since ``μ = μ(λ)``). In the official P3 +code, ``λ`` is determined during lookup-table generation by scanning over a +fixed range (roughly 10–10⁷ m⁻¹) and selecting the value that best matches L/N +for the current ``μ`` and piecewise ``m(D)``. The runtime then interpolates ``λ`` +from the tables. The `distribution_parameters` helper in Breeze instead uses a +secant solver for direct evaluation. + +```@example p3_psd +# Solve for distribution parameters +L_ice = 1e-4 # Ice mass concentration [kg/m³] +N_ice = 1e5 # Ice number concentration [1/m³] +rime_fraction = 0.0 +rime_density = 400.0 + +params = distribution_parameters(L_ice, N_ice, rime_fraction, rime_density) + +println("Distribution parameters:") +println(" N₀ = $(round(params.N₀, sigdigits=3)) m⁻⁵⁻μ") +println(" λ = $(round(params.λ, sigdigits=3)) m⁻¹") +println(" μ = $(round(params.μ, digits=2))") +``` + +### Computing ``N₀`` + +Once ``λ`` and ``μ`` are known, the intercept is found from normalization: + +```math +N₀ = \frac{N λ^{μ+1}}{Γ(μ + 1)} +``` + +## Visualizing Size Distributions + +```@example p3_psd +using SpecialFunctions: gamma + +# Plot size distributions for different L/N ratios +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [mm]", + ylabel = "N'(D) [m⁻⁴]", + yscale = log10, + title = "Ice Size Distributions") + +D_mm = range(0.01, 5, length=200) +D_m = D_mm .* 1e-3 + +N_ice = 1e5 +for (L, label, color) in [(1e-5, "L = 10⁻⁵ kg/m³", :blue), + (1e-4, "L = 10⁻⁴ kg/m³", :green), + (1e-3, "L = 10⁻³ kg/m³", :red)] + params = distribution_parameters(L, N_ice, 0.0, 400.0) + N_D = @. params.N₀ * D_m^params.μ * exp(-params.λ * D_m) + lines!(ax, D_mm, N_D, label=label, color=color) +end + +axislegend(ax, position=:rt) +ylims!(ax, 1e3, 1e12) +fig +``` + +## Effect of Rime Fraction + +Riming changes particle mass at a given size, which affects the inferred distribution: + +```@example p3_psd +fig = Figure(size=(600, 400)) +ax = Axis(fig[1, 1], + xlabel = "Diameter D [mm]", + ylabel = "N'(D) [m⁻⁴]", + yscale = log10, + title = "Effect of Riming on Size Distribution\n(L = 10⁻⁴ kg/m³, N = 10⁵ m⁻³)") + +L_ice = 1e-4 +N_ice = 1e5 + +for (Ff, label, color) in [(0.0, "Fᶠ = 0 (unrimed)", :blue), + (0.3, "Fᶠ = 0.3", :green), + (0.6, "Fᶠ = 0.6", :orange)] + params = distribution_parameters(L_ice, N_ice, Ff, 500.0) + N_D = @. params.N₀ * D_m^params.μ * exp(-params.λ * D_m) + lines!(ax, D_mm, N_D, label=label, color=color) +end + +axislegend(ax, position=:rt) +ylims!(ax, 1e3, 1e12) +fig +``` + +## Mass Integrals with Piecewise m(D) + +The challenge in P3 is that the mass-diameter relationship is piecewise +(see [Morrison & Milbrandt (2015a)](@cite Morrison2015parameterization) Eqs. 1-4): + +```math +\int_0^∞ m(D) N'(D)\, dD = \sum_{i=1}^{4} \int_{D_{i-1}}^{D_i} a_i D^{b_i} N'(D)\, dD +``` + +Each piece has the form: + +```math +\int_{D_1}^{D_2} a D^b N₀ D^μ e^{-λD}\, dD = a N₀ \int_{D_1}^{D_2} D^{b+μ} e^{-λD}\, dD +``` + +Using incomplete gamma functions: + +```math +\int_{D_1}^{D_2} D^k e^{-λD}\, dD = \frac{1}{λ^{k+1}} \left[ Γ(k+1, λD_1) - Γ(k+1, λD_2) \right] +``` + +where ``Γ(a, x) = \int_x^∞ t^{a-1} e^{-t} dt`` is the upper incomplete gamma function. + +## Numerical Stability + +All computations are performed in **log space** for numerical stability: + +```math +\log\left(\int_{D_1}^{D_2} D^k e^{-λD}\, dD\right) = +-(k+1)\log(λ) + \log Γ(k+1) + \log(q_1 - q_2) +``` + +where ``q_i = Γ(k+1, λD_i) / Γ(k+1)`` is the regularized incomplete gamma function. + +## Three-Moment Extension + +With three-moment ice ([Milbrandt et al. (2021)](@cite MilbrandtEtAl2021), +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024), +[Morrison et al. (2025)](@cite Morrison2025complete3moment)), +the 6th moment ``Z`` provides an additional constraint. +This allows independent determination of ``μ`` rather than using the μ-λ relationship: + +```math +\frac{Z}{N} = \frac{Γ(μ + 7)}{λ^6 Γ(μ + 1)} +``` + +Combined with the L/N ratio, this gives two equations for two unknowns (``μ`` and ``λ``). +In the official P3 code, these constraints are used to build a lookup table that +returns ``μ`` (and bulk density) by interpolation; ``λ`` is then obtained from +the main table using the diagnosed ``μ``. + +The benefit of three-moment ice is improved representation of: +- **Size sorting**: Large particles fall faster and separate from small ones +- **Hail formation**: Accurate simulation of heavily rimed particles +- **Radar reflectivity**: Direct prognostic variable rather than diagnosed + +Both two-moment and three-moment solvers are implemented: + +- **Two-moment**: Use `distribution_parameters(L, N, Fᶠ, ρᶠ)` with `TwoMomentClosure` +- **Three-moment**: Use `distribution_parameters(L, N, Z, Fᶠ, ρᶠ)` with `ThreeMomentClosure` + +## Summary + +The P3 size distribution closure proceeds as: + +1. **Prognostic moments**: ``L``, ``N`` (and optionally ``Z``) are carried by the model +2. **Rime properties**: ``Fᶠ`` and ``ρᶠ`` determine the mass-diameter relationship +3. **Lambda solver**: ``λ`` is tabulated by scanning L/N in the reference Fortran (Breeze uses a secant solver in the helper) +4. **μ diagnosis**: Piecewise diagnostic for 2-moment, or lookup-table inversion for 3-moment +5. **Normalization**: Intercept ``N₀`` from number conservation + +This provides the complete size distribution needed for computing microphysical rates. + +## References for This Section + +- [Morrison2015parameterization](@cite): PSD formulation and μ-λ relationship (Sec. 2b) +- [MilbrandtYau2005](@cite): Multimoment bulk microphysics and shape parameter analysis +- [Heymsfield2003](@cite): Ice size distribution observations used for μ-λ fit +- [MilbrandtEtAl2021](@cite): Three-moment ice with Z as prognostic +- [MilbrandtEtAl2024](@cite): Updated three-moment formulation +- [Morrison2025complete3moment](@cite): Complete three-moment implementation diff --git a/examples/p3_ice_particle_explorer.jl b/examples/p3_ice_particle_explorer.jl new file mode 100644 index 000000000..fb96d46da --- /dev/null +++ b/examples/p3_ice_particle_explorer.jl @@ -0,0 +1,305 @@ +# # P3 Ice Particle Physics Explorer +# +# This example explores the rich physics of ice particles using the P3 +# (Predicted Particle Properties) scheme. Unlike traditional microphysics that +# categorizes ice into fixed species (cloud ice, snow, graupel), P3 treats ice +# as a **continuum** with properties that evolve based on the particle's history. +# +# We'll visualize how key ice integrals behave across the P3 parameter space: +# - **Slope parameter λ**: Controls mean particle size (small λ = large particles) +# - **Shape parameter μ**: Controls breadth of the size distribution +# - **Rime fraction Fᶠ**: Fraction of ice mass that is rimed (0 = pristine, 1 = graupel) +# - **Liquid fraction Fˡ**: Fraction of total mass that is liquid water on ice +# +# This exploration builds intuition for how P3 represents the full spectrum +# from delicate dendritic snowflakes to dense graupel hailstones. +# +# Reference: [MilbrandtMorrison2016](@citet) for the 3-moment P3 formulation. + +using Breeze.Microphysics.PredictedParticleProperties +using CairoMakie + +# ## The P3 Ice Size Distribution +# +# P3 uses a gamma size distribution for ice particles: +# +# ```math +# N'(D) = N_0 D^\mu \exp(-\lambda D) +# ``` +# +# where: +# - N₀ is the intercept parameter +# - μ (mu) is the shape parameter (0 = exponential, larger = peaked) +# - λ (lambda) is the slope parameter (larger = smaller mean size) +# +# The **moments** of this distribution are: +# - M₀ = total number concentration +# - M₃ ∝ total ice mass +# - M₆ ∝ radar reflectivity +# +# P3's 3-moment scheme tracks all three, giving it unprecedented accuracy. + +# ## Exploring Fall Speed Dependence on Particle Size +# +# Larger ice particles fall faster. The relationship depends on particle habit +# and riming. Let's see how the number-weighted, mass-weighted, and +# reflectivity-weighted fall speeds vary with the slope parameter λ. + +λ_values = 10 .^ range(log10(100), log10(5000), length=50) # 1/m +N₀ = 1e6 # m⁻⁴ + +V_number = Float64[] +V_mass = Float64[] +V_reflectivity = Float64[] + +for λ in λ_values + state = IceSizeDistributionState(Float64; + intercept = N₀, + shape = 0.0, + slope = λ) + + push!(V_number, evaluate(NumberWeightedFallSpeed(), state)) + push!(V_mass, evaluate(MassWeightedFallSpeed(), state)) + push!(V_reflectivity, evaluate(ReflectivityWeightedFallSpeed(), state)) +end + +# Convert λ to mean diameter for intuition: D̄ = (μ+1)/λ for gamma distribution +mean_diameter_mm = 1000 .* (0.0 + 1) ./ λ_values # μ = 0 + +fig = Figure(size=(1000, 800)) + +ax1 = Axis(fig[1, 1]; + xlabel = "Mean diameter D̄ (mm)", + ylabel = "Fall speed integral", + title = "Ice Fall Speed vs Particle Size", + xscale = log10, + yscale = log10) + +lines!(ax1, mean_diameter_mm, V_number; color=:dodgerblue, linewidth=3, label="Number-weighted Vₙ") +lines!(ax1, mean_diameter_mm, V_mass; color=:limegreen, linewidth=3, label="Mass-weighted Vₘ") +lines!(ax1, mean_diameter_mm, V_reflectivity; color=:orangered, linewidth=3, label="Reflectivity-weighted V_z") + +axislegend(ax1; position=:lt) + +# The hierarchy Vᵢ > Vₘ > Vₙ reflects that larger particles (which dominate +# higher moments) fall faster. + +# ## The Effect of Riming +# +# When ice particles collect supercooled cloud droplets, they become **rimed**. +# Rime fills in the dendritic structure, making particles denser and faster-falling. +# Let's see how the rime fraction affects fall speed. + +rime_fractions = range(0, 1, length=40) +rime_densities = [400.0, 600.0, 800.0] # kg/m³ - light, medium, heavy riming + +ax2 = Axis(fig[1, 2]; + xlabel = "Rime fraction Fᶠ", + ylabel = "Mass-weighted fall speed", + title = "Effect of Riming on Fall Speed") + +colors = [:skyblue, :royalblue, :navy] +for (i, ρ_rime) in enumerate(rime_densities) + V_rimed = Float64[] + for F_rim in rime_fractions + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0, # Fixed size distribution + rime_fraction = F_rim, + rime_density = ρ_rime) + + push!(V_rimed, evaluate(MassWeightedFallSpeed(), state)) + end + lines!(ax2, rime_fractions, V_rimed; + color=colors[i], linewidth=3, + label="ρ_rime = $(Int(ρ_rime)) kg/m³") +end + +axislegend(ax2; position=:lt) + +# Heavier riming (higher ρ_rime and F_rim) → faster fall speeds. +# This is why graupel falls faster than snow! + +# ## Ventilation: Enhanced Mass Transfer +# +# As particles fall, air flow around them enhances vapor diffusion and heat +# transfer. This **ventilation effect** is crucial for depositional growth +# and sublimation. Larger, faster-falling particles have higher ventilation. + +ax3 = Axis(fig[2, 1]; + xlabel = "Mean diameter D̄ (mm)", + ylabel = "Ventilation factor", + title = "Ventilation Enhancement", + xscale = log10, + yscale = log10) + +V_vent = Float64[] +V_vent_enhanced = Float64[] + +for λ in λ_values + state = IceSizeDistributionState(Float64; + intercept = N₀, + shape = 0.0, + slope = λ) + + push!(V_vent, evaluate(Ventilation(), state)) + push!(V_vent_enhanced, evaluate(VentilationEnhanced(), state)) +end + +lines!(ax3, mean_diameter_mm, V_vent; + color=:purple, linewidth=3, label="Basic ventilation") +lines!(ax3, mean_diameter_mm, V_vent_enhanced; + color=:magenta, linewidth=3, linestyle=:dash, label="Enhanced (D > 100 μm)") + +axislegend(ax3; position=:lt) + +# ## Meltwater Shedding: When Ice Starts to Melt +# +# Near 0°C, ice particles begin to melt. Liquid water accumulates on the +# particle surface. If there's too much liquid, it **sheds** as raindrops. +# The shedding rate depends on the liquid fraction. + +liquid_fractions = range(0, 0.5, length=40) + +ax4 = Axis(fig[2, 2]; + xlabel = "Liquid fraction Fˡ", + ylabel = "Shedding rate integral", + title = "Meltwater Shedding") + +# Different particle sizes +λ_test = [500.0, 1000.0, 2000.0] +colors = [:coral, :crimson, :darkred] +D_labels = ["Large (D̄ ≈ 2 mm)", "Medium (D̄ ≈ 1 mm)", "Small (D̄ ≈ 0.5 mm)"] + +for (i, λ) in enumerate(λ_test) + shedding = Float64[] + for F_liq in liquid_fractions + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = λ, + liquid_fraction = F_liq) + + push!(shedding, evaluate(SheddingRate(), state)) + end + lines!(ax4, liquid_fractions, shedding; + color=colors[i], linewidth=3, label=D_labels[i]) +end + +axislegend(ax4; position=:lt) + +# Larger particles (small λ) shed more water because they collect more +# meltwater before it can refreeze. + +fig + +# ## Reflectivity: What Radars See +# +# Weather radars measure the 6th moment of the drop size distribution (M₆). +# Ice particles contribute to reflectivity based on their size and density. +# Let's see how reflectivity varies with particle properties. + +fig2 = Figure(size=(1000, 400)) + +ax5 = Axis(fig2[1, 1]; + xlabel = "Mean diameter D̄ (mm)", + ylabel = "Reflectivity integral (log scale)", + title = "Ice Reflectivity vs Size", + xscale = log10, + yscale = log10) + +Z_pristine = Float64[] +Z_rimed = Float64[] + +for λ in λ_values + state_pristine = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = λ, + rime_fraction = 0.0) + + state_rimed = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = λ, + rime_fraction = 0.7, rime_density = 700.0) + + push!(Z_pristine, evaluate(Reflectivity(), state_pristine)) + push!(Z_rimed, evaluate(Reflectivity(), state_rimed)) +end + +lines!(ax5, mean_diameter_mm, Z_pristine; + color=:cyan, linewidth=3, label="Pristine ice") +lines!(ax5, mean_diameter_mm, Z_rimed; + color=:red, linewidth=3, label="Heavily rimed (graupel)") + +axislegend(ax5; position=:lt) + +# ## The Shape Parameter μ: Controlling Distribution Breadth +# +# While λ controls mean size, μ controls how spread out the distribution is. +# Higher μ → more peaked distribution (most particles near mean size). + +ax6 = Axis(fig2[1, 2]; + xlabel = "Shape parameter μ", + ylabel = "Normalized fall speed", + title = "Effect of Distribution Shape") + +μ_values = range(0, 6, length=30) +λ_fixed = 1000.0 + +V_vs_mu = Float64[] +Z_vs_mu = Float64[] + +for μ in μ_values + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = μ, slope = λ_fixed) + + push!(V_vs_mu, evaluate(MassWeightedFallSpeed(), state)) + push!(Z_vs_mu, evaluate(Reflectivity(), state)) +end + +lines!(ax6, μ_values, V_vs_mu ./ maximum(V_vs_mu); + color=:teal, linewidth=3, label="Mass-weighted velocity") +lines!(ax6, μ_values, Z_vs_mu ./ maximum(Z_vs_mu); + color=:gold, linewidth=3, label="Reflectivity") + +axislegend(ax6; position=:rt) + +fig2 + +# ## Physical Interpretation +# +# These explorations reveal the physics encoded in P3's integral tables: +# +# 1. **Fall speed hierarchy**: V_z > V_m > V_n because larger particles +# (which dominate higher moments) fall faster. This matters for +# differential sedimentation of mass vs number. +# +# 2. **Riming densifies particles**: Rime fills in the fractal structure +# of ice crystals, increasing density and fall speed. Graupel (F_rim ≈ 1) +# falls much faster than pristine snow. +# +# 3. **Ventilation scales with size**: Large, fast-falling particles have +# enhanced mass transfer with the environment. This accelerates both +# growth (in supersaturated air) and sublimation (in subsaturated air). +# +# 4. **Melting leads to shedding**: As ice melts, liquid accumulates until +# aerodynamic forces shed it as rain. This is why melting snow produces +# a characteristic radar "bright band." +# +# 5. **Reflectivity is size-dominated**: The D⁶ weighting means a few large +# particles dominate the radar signal. Rimed particles contribute more +# because they're denser. +# +# ## Coming Soon: Full P3 Dynamics +# +# When the P3 microphysics tendencies are complete, we'll extend this +# to a full parcel simulation showing: +# - Ice nucleation and initial growth +# - The Bergeron-Findeisen process (ice stealing vapor from liquid) +# - Riming: supercooled droplet collection +# - Aggregation: snowflakes sticking together +# - Melting near 0°C and the transition to rain +# +# The stationary parcel framework is perfect for isolating these processes +# and understanding how P3's predicted properties evolve in time. + +nothing #hide diff --git a/examples/stationary_parcel_model.jl b/examples/stationary_parcel_model.jl index 11be88b6b..ed6802e10 100644 --- a/examples/stationary_parcel_model.jl +++ b/examples/stationary_parcel_model.jl @@ -8,11 +8,14 @@ # - **Autoconversion**: Cloud liquid → rain (timescale τ ≈ 1000 s) # - **Rain evaporation**: Subsaturated rain → vapor # -# We compare **one-moment** (mass only) and **two-moment** (mass + number) -# microphysics schemes. For two-moment, we use the [SeifertBeheng2006](@citet) -# scheme, which derives process rates from the evolving particle size distribution. +# We compare three microphysics schemes of increasing complexity: +# - **One-moment**: Mass only, prescribed process timescales +# - **Two-moment**: Mass + number, [SeifertBeheng2006](@citet) process rates +# - **Predicted Particle Properties (P3)**: Three-moment ice with continuously predicted properties +# # Tracking droplet number concentration enables realistic representation of -# aerosol-cloud interactions. +# aerosol-cloud interactions. P3 takes this further by predicting ice particle +# properties (rime fraction, rime density) rather than using discrete categories. # # Stationary parcel models are classic tools in cloud physics, isolating microphysics # from dynamics; see [rogers1989short](@citet). @@ -45,12 +48,16 @@ TwoMomentCloudMicrophysics = BreezeCloudMicrophysicsExt.TwoMomentCloudMicrophysi function run_parcel_simulation(; microphysics, θ = 300, stop_time = 2000, Δt = 1, qᵗ = 0.020, qᶜˡ = 0, qʳ = 0, - nᶜˡ = 0, nʳ = 0) + nᶜˡ = 0, nʳ = 0, + qⁱ = 0, nⁱ = 0) model = AtmosphereModel(grid; dynamics, thermodynamic_constants=constants, microphysics) is_two_moment = microphysics isa TwoMomentCloudMicrophysics + is_p3 = microphysics isa PredictedParticlePropertiesMicrophysics - if is_two_moment + if is_p3 + set!(model; θ, qᵗ, qᶜˡ, qʳ, nʳ, qⁱ, nⁱ) + elseif is_two_moment set!(model; θ, qᵗ, qᶜˡ, nᶜˡ, qʳ, nʳ) else set!(model; θ, qᵗ, qᶜˡ, qʳ) @@ -60,30 +67,44 @@ function run_parcel_simulation(; microphysics, θ = 300, stop_time = 2000, Δt = ## Time series storage t = Float64[] - qᵛ, qᶜˡ, qʳ = Float64[], Float64[], Float64[] - nᶜˡ, nʳ = Float64[], Float64[] - T = Float64[] + qᵛ_ts, qᶜˡ_ts, qʳ_ts = Float64[], Float64[], Float64[] + nᶜˡ_ts, nʳ_ts = Float64[], Float64[] + qⁱ_ts, nⁱ_ts = Float64[], Float64[] + T_ts = Float64[] function record_time_series(sim) μ = sim.model.microphysical_fields push!(t, time(sim)) - push!(qᵛ, first(μ.qᵛ)) - push!(qᶜˡ, first(μ.qᶜˡ)) - push!(qʳ, first(μ.qʳ)) - push!(T, first(sim.model.temperature)) - if is_two_moment - push!(nᶜˡ, first(μ.nᶜˡ)) - push!(nʳ, first(μ.nʳ)) + push!(T_ts, first(sim.model.temperature)) + push!(qᵛ_ts, first(μ.qᵛ)) + + if is_p3 + # P3 stores density-weighted fields; divide by reference density + ρᵣ = first(sim.model.dynamics.reference_state.density) + push!(qᶜˡ_ts, first(μ.ρqᶜˡ) / ρᵣ) + push!(qʳ_ts, first(μ.ρqʳ) / ρᵣ) + push!(nʳ_ts, first(μ.ρnʳ) / ρᵣ) + push!(qⁱ_ts, first(μ.ρqⁱ) / ρᵣ) + push!(nⁱ_ts, first(μ.ρnⁱ) / ρᵣ) + else + push!(qᶜˡ_ts, first(μ.qᶜˡ)) + push!(qʳ_ts, first(μ.qʳ)) + if is_two_moment + push!(nᶜˡ_ts, first(μ.nᶜˡ)) + push!(nʳ_ts, first(μ.nʳ)) + end end end add_callback!(simulation, record_time_series) run!(simulation) - if is_two_moment - return (; t, qᵛ, qᶜˡ, qʳ, nᶜˡ, nʳ, T) + if is_p3 + return (; t, qᵛ=qᵛ_ts, qᶜˡ=qᶜˡ_ts, qʳ=qʳ_ts, nʳ=nʳ_ts, qⁱ=qⁱ_ts, nⁱ=nⁱ_ts, T=T_ts) + elseif is_two_moment + return (; t, qᵛ=qᵛ_ts, qᶜˡ=qᶜˡ_ts, qʳ=qʳ_ts, nᶜˡ=nᶜˡ_ts, nʳ=nʳ_ts, T=T_ts) else - return (; t, qᵛ, qᶜˡ, qʳ, T) + return (; t, qᵛ=qᵛ_ts, qᶜˡ=qᶜˡ_ts, qʳ=qʳ_ts, T=T_ts) end end nothing #hide @@ -117,6 +138,10 @@ microphysics_1m_fast = OneMomentCloudMicrophysics(; categories, precipitation_bo # And now a default two-moment scheme using Seifert and Beheng (2006) microphysics_2m = TwoMomentCloudMicrophysics(; precipitation_boundary_condition) +# Finally, the Predicted Particle Properties (P3) scheme - a three-moment scheme +# with continuously predicted ice particle properties +microphysics_p3 = PredictedParticlePropertiesMicrophysics(; precipitation_boundary_condition) + # ## Run four comparison cases # # All cases start with the same supersaturated conditions (qᵗ = 0.030). @@ -130,19 +155,28 @@ case_1m_fast = run_parcel_simulation(; microphysics = microphysics_1m_fast, qᵗ stop_time = 4000 case_2m_few = run_parcel_simulation(; microphysics = microphysics_2m, qᵗ = 0.030, nᶜˡ = 100e6, stop_time) case_2m_many = run_parcel_simulation(; microphysics = microphysics_2m, qᵗ = 0.030, nᶜˡ = 300e6, stop_time) + +## P3 case: three-moment ice scheme with predicted particle properties +## Note: P3 process rates are under development - here we demonstrate initialization +stop_time = 100 +case_p3 = run_parcel_simulation(; microphysics = microphysics_p3, qᵗ = 0.020, + qᶜˡ = 0.005, qʳ = 0.001, nʳ = 1000, + qⁱ = 0.002, nⁱ = 10000, stop_time) nothing #hide # ## Visualization # -# We compare all four cases side-by-side to highlight the key differences. +# We compare all cases side-by-side to highlight the key differences. ## Colorblind-friendly colors c_cloud = :limegreen c_rain = :orangered +c_ice = :dodgerblue c_cloud_n = :purple c_rain_n = :gold +c_ice_n = :cyan -fig = Figure(size=(1000, 700)) +fig = Figure(size=(1000, 950)) set_theme!(linewidth=2.5, fontsize=16) ## Row 1: One-moment comparison @@ -179,8 +213,23 @@ lines!(ax2_n, t, case_2m_many.nᶜˡ; color=c_cloud_n, linestyle=:dash, label="n lines!(ax2_n, t, case_2m_many.nʳ .* 1e6; color=c_rain_n, linestyle=:dash, label="nʳ × 10⁶ (nᶜˡ₀ = 300/mg)") axislegend(ax2_n; position=:rt, labelsize=11) -rowsize!(fig.layout, 1, Relative(0.05)) -rowsize!(fig.layout, 3, Relative(0.05)) +## Row 3: P3 microphysics +Label(fig[5, 1:2], "P3 microphysics: three-moment ice with predicted particle properties") +ax3_q = Axis(fig[6, 1]; xlabel="t (s)", ylabel="q (kg/kg)", title="Mass mixing ratios") +t = case_p3.t +lines!(ax3_q, t, case_p3.qᶜˡ; color=c_cloud, label="qᶜˡ (cloud)") +lines!(ax3_q, t, case_p3.qʳ; color=c_rain, label="qʳ (rain)") +lines!(ax3_q, t, case_p3.qⁱ; color=c_ice, label="qⁱ (ice)") +axislegend(ax3_q; position=:rt, labelsize=11) + +ax3_n = Axis(fig[6, 2]; xlabel="t (s)", ylabel="n (1/kg)", title="Number concentrations") +lines!(ax3_n, t, case_p3.nʳ; color=c_rain_n, label="nʳ (rain)") +lines!(ax3_n, t, case_p3.nⁱ; color=c_ice_n, label="nⁱ (ice)") +axislegend(ax3_n; position=:rt, labelsize=11) + +rowsize!(fig.layout, 1, Relative(0.04)) +rowsize!(fig.layout, 3, Relative(0.04)) +rowsize!(fig.layout, 5, Relative(0.04)) fig @@ -195,7 +244,7 @@ fig # This illustrates a key limitation: **1M schemes prescribe process rates # rather than deriving them from the microphysical state**. # -# ### Two-moment microphysics (bottom row) +# ### Two-moment microphysics (middle row) # # Initial droplet number dramatically affects precipitation timing: # @@ -215,3 +264,24 @@ fig # This sensitivity to droplet number is why **two-moment schemes are essential # for studying aerosol effects on precipitation**. More aerosols → more CCN → # more cloud droplets → smaller drops → less rain (the Twomey effect). +# +# ### P3 microphysics (bottom row) +# +# The Predicted Particle Properties (P3) scheme represents ice differently +# than traditional schemes. Instead of discrete categories (cloud ice, snow, +# graupel, hail), P3 uses a **single ice category** with continuously predicted +# properties: +# +# - **Rime fraction**: What fraction of ice mass is rimed? +# - **Rime density**: How dense is the rime layer? +# - **Liquid fraction**: Liquid water coating from partial melting +# +# P3 tracks 9 prognostic variables total: cloud liquid mass; rain mass and +# number; ice mass, number, rime mass, rime volume, reflectivity (6th moment), +# and liquid-on-ice mass. +# +# !!! note "P3 process rates are under development" +# The P3 scheme infrastructure is complete, but process rate tendencies +# (nucleation, deposition, riming, aggregation, melting) are not yet +# implemented. The constant values shown here confirm the scheme initializes +# correctly and integrates with AtmosphereModel. diff --git a/src/AtmosphereModels/AtmosphereModels.jl b/src/AtmosphereModels/AtmosphereModels.jl index 31dde7c96..030d4e296 100644 --- a/src/AtmosphereModels/AtmosphereModels.jl +++ b/src/AtmosphereModels/AtmosphereModels.jl @@ -49,6 +49,9 @@ export # Cloud effective radius ConstantRadiusParticles, + # Microphysics interface + prognostic_field_names, + # Diagnostics (re-exported from Diagnostics submodule) PotentialTemperature, VirtualPotentialTemperature, diff --git a/src/Breeze.jl b/src/Breeze.jl index 0c821c29a..54330a331 100644 --- a/src/Breeze.jl +++ b/src/Breeze.jl @@ -61,6 +61,7 @@ export PlanarIceSurface, # Microphysics + prognostic_field_names, SaturationAdjustment, MixedPhaseEquilibrium, WarmPhaseEquilibrium, @@ -72,6 +73,8 @@ export BulkMicrophysics, compute_hydrostatic_pressure!, NonEquilibriumCloudFormation, + P3Microphysics, + PredictedParticlePropertiesMicrophysics, # BoundaryConditions BulkDrag, diff --git a/src/Microphysics/Microphysics.jl b/src/Microphysics/Microphysics.jl index bfebad8ef..057a018e3 100644 --- a/src/Microphysics/Microphysics.jl +++ b/src/Microphysics/Microphysics.jl @@ -29,4 +29,14 @@ include("bulk_microphysics.jl") include("microphysics_diagnostics.jl") include("dcmip2016_kessler.jl") +##### +##### Predicted Particle Properties (P3) submodule +##### + +include("PredictedParticleProperties/PredictedParticleProperties.jl") +using .PredictedParticleProperties + +# Re-export key P3 types +export PredictedParticlePropertiesMicrophysics, P3Microphysics + end # module Microphysics diff --git a/src/Microphysics/PredictedParticleProperties/PredictedParticleProperties.jl b/src/Microphysics/PredictedParticleProperties/PredictedParticleProperties.jl new file mode 100644 index 000000000..19647fe94 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/PredictedParticleProperties.jl @@ -0,0 +1,257 @@ +""" + PredictedParticleProperties + +Predicted Particle Properties (P3) microphysics scheme implementation. + +P3 is a bulk microphysics scheme that uses a single ice category with +continuously predicted properties (rime fraction, rime density, liquid fraction) +rather than multiple discrete ice categories. + +# Key Features + +- Single ice category with predicted properties +- 3-moment ice (mass, number, reflectivity/6th moment) +- Predicted liquid fraction on ice particles +- Rime fraction and rime density evolution +- Compatible with both quadrature and lookup table evaluation + +# Complete Reference List + +This implementation is based on the following P3 papers: + +1. **Morrison & Milbrandt (2015a)** - Original P3: m(D), A(D), V(D), process rates + [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) + +2. **Morrison et al. (2015b)** - Part II: Case study validation + [Morrison et al. (2015b)](@cite Morrison2015part2) + +3. **Milbrandt & Morrison (2016)** - Part III: Multiple ice categories (NOT implemented) + [Milbrandt and Morrison (2016)](@cite MilbrandtMorrison2016) + +4. **Milbrandt et al. (2021)** - Three-moment ice: Z as prognostic, size sorting + [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) + +5. **Milbrandt et al. (2024)** - Updated triple-moment formulation + [Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) + +6. **Milbrandt et al. (2025)** - Predicted liquid fraction: shedding, refreezing + [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction) + +7. **Morrison et al. (2025)** - Complete three-moment implementation + [Morrison et al. (2025)](@cite Morrison2025complete3moment) + +# Source Code + +Based on [P3-microphysics v5.5.0](https://github.com/P3-microphysics/P3-microphysics) + +# Not Implemented + +- Multiple free ice categories from Milbrandt & Morrison (2016) +- Full process rate tendency functions (infrastructure is ready, rates are TODO) +""" +module PredictedParticleProperties + +export + # Main scheme type + PredictedParticlePropertiesMicrophysics, + P3Microphysics, + P3MicrophysicalState, + ProcessRateParameters, + + # Multi-category ice + MultiIceCategory, + multi_category_ice_field_names, + inter_category_collection, + + # Ice properties + IceProperties, + IceFallSpeed, + IceDeposition, + IceBulkProperties, + IceCollection, + IceSixthMoment, + IceLambdaLimiter, + IceRainCollection, + + # Rain and cloud droplet properties + RainProperties, + CloudDropletProperties, + + # Integral types (abstract) + AbstractP3Integral, + AbstractIceIntegral, + AbstractRainIntegral, + AbstractFallSpeedIntegral, + AbstractDepositionIntegral, + AbstractBulkPropertyIntegral, + AbstractCollectionIntegral, + AbstractSixthMomentIntegral, + AbstractLambdaLimiterIntegral, + + # Integral types (concrete) - Fall speed + NumberWeightedFallSpeed, + MassWeightedFallSpeed, + ReflectivityWeightedFallSpeed, + + # Integral types (concrete) - Deposition + Ventilation, + VentilationEnhanced, + SmallIceVentilationConstant, + SmallIceVentilationReynolds, + LargeIceVentilationConstant, + LargeIceVentilationReynolds, + + # Integral types (concrete) - Bulk properties + EffectiveRadius, + MeanDiameter, + MeanDensity, + Reflectivity, + SlopeParameter, + ShapeParameter, + SheddingRate, + + # Integral types (concrete) - Collection + AggregationNumber, + RainCollectionNumber, + + # Integral types (concrete) - Sixth moment + SixthMomentRime, + SixthMomentDeposition, + SixthMomentDeposition1, + SixthMomentMelt1, + SixthMomentMelt2, + SixthMomentAggregation, + SixthMomentShedding, + SixthMomentSublimation, + SixthMomentSublimation1, + + # Integral types (concrete) - Lambda limiter + NumberMomentLambdaLimit, + MassMomentLambdaLimit, + + # Integral types (concrete) - Rain + RainShapeParameter, + RainVelocityNumber, + RainVelocityMass, + RainEvaporation, + + # Integral types (concrete) - Ice-rain collection + IceRainMassCollection, + IceRainNumberCollection, + IceRainSixthMomentCollection, + + # Tabulated wrapper + TabulatedIntegral, + + # Interface functions + prognostic_field_names, + + # Quadrature + evaluate, + IceSizeDistributionState, + chebyshev_gauss_nodes_weights, + + # Tabulation + tabulate, + TabulationParameters, + TabulatedFunction3D, + P3IntegralEvaluator, + + # Lambda solver + IceMassPowerLaw, + P3Closure, + TwoMomentClosure, + ThreeMomentClosure, + ShapeParameterRelation, # alias for TwoMomentClosure + IceRegimeThresholds, + IceDistributionParameters, + DiameterBounds, + solve_lambda, + solve_shape_parameter, + distribution_parameters, + shape_parameter, + ice_regime_thresholds, + ice_mass, + ice_mass_coefficients, + intercept_parameter, + lambda_bounds_from_diameter, + enforce_diameter_bounds + +using DocStringExtensions: TYPEDSIGNATURES, TYPEDFIELDS +using SpecialFunctions: loggamma, gamma_inc + +using Oceananigans: Oceananigans +using Oceananigans.Architectures: CPU +using Breeze.AtmosphereModels: prognostic_field_names + +##### +##### Integral types (must be first - no dependencies) +##### + +include("integral_types.jl") + +##### +##### Ice concept containers +##### + +include("ice_fall_speed.jl") +include("ice_deposition.jl") +include("ice_bulk_properties.jl") +include("ice_collection.jl") +include("ice_sixth_moment.jl") +include("ice_lambda_limiter.jl") +include("ice_rain_collection.jl") +include("ice_properties.jl") + +##### +##### Rain and cloud properties +##### + +include("rain_properties.jl") +include("cloud_droplet_properties.jl") + +##### +##### Process rate parameters +##### + +include("process_rate_parameters.jl") + +##### +##### Main scheme type +##### + +include("p3_scheme.jl") + +##### +##### Size distribution and quadrature (depends on types above) +##### + +include("size_distribution.jl") +include("quadrature.jl") +include("tabulation.jl") + +##### +##### Lambda solver (depends on mass-diameter relationship) +##### + +include("lambda_solver.jl") + +##### +##### Process rates (Phase 1: rain, deposition, melting) +##### + +include("process_rates.jl") + +##### +##### Multi-ice category support +##### + +include("multi_ice_category.jl") + +##### +##### AtmosphereModel interface (must be last - depends on all types) +##### + +include("p3_interface.jl") + +end # module PredictedParticleProperties diff --git a/src/Microphysics/PredictedParticleProperties/cloud_droplet_properties.jl b/src/Microphysics/PredictedParticleProperties/cloud_droplet_properties.jl new file mode 100644 index 000000000..bb85e71fd --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/cloud_droplet_properties.jl @@ -0,0 +1,72 @@ +##### +##### Cloud Droplet Properties +##### +##### Cloud droplet properties for the P3 scheme. +##### + +""" + CloudDropletProperties + +Prescribed cloud droplet parameters for warm microphysics. +See [`CloudDropletProperties`](@ref) constructor for details. +""" +struct CloudDropletProperties{FT} + number_concentration :: FT + autoconversion_threshold :: FT + condensation_timescale :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct `CloudDropletProperties` with prescribed parameters. + +Cloud droplets in P3 are treated simply: their number concentration is +*prescribed* rather than predicted. This is a common simplification +appropriate for many applications where aerosol-cloud interactions +are not the focus. + +**Why prescribe Nc?** + +Predicting cloud droplet number requires treating aerosol activation +physics, which adds substantial complexity. For simulations focused +on ice processes or bulk precipitation, prescribed Nc is sufficient. + +**Typical values:** +- Continental: Nc ~ 100-300 × 10⁶ m⁻³ (more CCN, smaller droplets) +- Marine: Nc ~ 50-100 × 10⁶ m⁻³ (fewer CCN, larger droplets) + +**Autoconversion:** +Cloud droplets that grow past `autoconversion_threshold` are converted +to rain via collision-coalescence, following +[Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). + +# Keyword Arguments + +- `number_concentration`: Nc [1/m³], default 100×10⁶ (continental) +- `autoconversion_threshold`: Conversion diameter [m], default 25 μm +- `condensation_timescale`: Saturation relaxation [s], default 1.0 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +[Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). +""" +function CloudDropletProperties(FT = Oceananigans.defaults.FloatType; + number_concentration = 100e6, + autoconversion_threshold = 25e-6, + condensation_timescale = 1) + return CloudDropletProperties( + FT(number_concentration), + FT(autoconversion_threshold), + FT(condensation_timescale) + ) +end + +Base.summary(::CloudDropletProperties) = "CloudDropletProperties" + +function Base.show(io::IO, c::CloudDropletProperties) + print(io, summary(c), "(") + print(io, "nᶜˡ=", c.number_concentration, " m⁻³") + print(io, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/cloud_properties.jl b/src/Microphysics/PredictedParticleProperties/cloud_properties.jl new file mode 100644 index 000000000..bb85e71fd --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/cloud_properties.jl @@ -0,0 +1,72 @@ +##### +##### Cloud Droplet Properties +##### +##### Cloud droplet properties for the P3 scheme. +##### + +""" + CloudDropletProperties + +Prescribed cloud droplet parameters for warm microphysics. +See [`CloudDropletProperties`](@ref) constructor for details. +""" +struct CloudDropletProperties{FT} + number_concentration :: FT + autoconversion_threshold :: FT + condensation_timescale :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct `CloudDropletProperties` with prescribed parameters. + +Cloud droplets in P3 are treated simply: their number concentration is +*prescribed* rather than predicted. This is a common simplification +appropriate for many applications where aerosol-cloud interactions +are not the focus. + +**Why prescribe Nc?** + +Predicting cloud droplet number requires treating aerosol activation +physics, which adds substantial complexity. For simulations focused +on ice processes or bulk precipitation, prescribed Nc is sufficient. + +**Typical values:** +- Continental: Nc ~ 100-300 × 10⁶ m⁻³ (more CCN, smaller droplets) +- Marine: Nc ~ 50-100 × 10⁶ m⁻³ (fewer CCN, larger droplets) + +**Autoconversion:** +Cloud droplets that grow past `autoconversion_threshold` are converted +to rain via collision-coalescence, following +[Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). + +# Keyword Arguments + +- `number_concentration`: Nc [1/m³], default 100×10⁶ (continental) +- `autoconversion_threshold`: Conversion diameter [m], default 25 μm +- `condensation_timescale`: Saturation relaxation [s], default 1.0 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +[Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). +""" +function CloudDropletProperties(FT = Oceananigans.defaults.FloatType; + number_concentration = 100e6, + autoconversion_threshold = 25e-6, + condensation_timescale = 1) + return CloudDropletProperties( + FT(number_concentration), + FT(autoconversion_threshold), + FT(condensation_timescale) + ) +end + +Base.summary(::CloudDropletProperties) = "CloudDropletProperties" + +function Base.show(io::IO, c::CloudDropletProperties) + print(io, summary(c), "(") + print(io, "nᶜˡ=", c.number_concentration, " m⁻³") + print(io, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_bulk_properties.jl b/src/Microphysics/PredictedParticleProperties/ice_bulk_properties.jl new file mode 100644 index 000000000..0e2aa0d92 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_bulk_properties.jl @@ -0,0 +1,82 @@ +##### +##### Ice Bulk Properties +##### +##### Population-averaged properties computed by integrating over the +##### ice particle size distribution. +##### + +""" + IceBulkProperties + +Population-averaged ice properties and diagnostic integrals. +See [`IceBulkProperties`](@ref) constructor for details. +""" +struct IceBulkProperties{FT, EF, DM, RH, RF, LA, MU, SH} + maximum_mean_diameter :: FT + minimum_mean_diameter :: FT + effective_radius :: EF + mean_diameter :: DM + mean_density :: RH + reflectivity :: RF + slope :: LA + shape :: MU + shedding :: SH +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceBulkProperties` with parameters and quadrature-based integrals. + +These integrals compute bulk properties by averaging over the particle +size distribution. They are used for radiation, radar, and diagnostics. + +**Diagnostic integrals:** + +- `effective_radius`: Radiation-weighted radius ``r_e = ∫A·N'dD / ∫N'dD`` +- `mean_diameter`: Mass-weighted diameter ``D_m = ∫D·m·N'dD / ∫m·N'dD`` +- `mean_density`: Mass-weighted density ``ρ̄ = ∫ρ·m·N'dD / ∫m·N'dD`` +- `reflectivity`: Radar reflectivity ``Z = ∫D^6·N'dD`` + +**Distribution parameters (for λ-limiting):** + +- `slope`: Slope parameter λ from prognostic constraints +- `shape`: Shape parameter μ from empirical μ-λ relationship + +**Process integrals:** + +- `shedding`: Rate at which meltwater sheds from large particles + +# Keyword Arguments + +- `maximum_mean_diameter`: Upper Dm limit [m], default 0.02 (2 cm) +- `minimum_mean_diameter`: Lower Dm limit [m], default 1×10⁻⁵ (10 μm) + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +[Field et al. (2007)](@cite FieldEtAl2007) for μ-λ relationship. +""" +function IceBulkProperties(FT::Type{<:AbstractFloat} = Float64; + maximum_mean_diameter = 2e-2, + minimum_mean_diameter = 1e-5) + return IceBulkProperties( + FT(maximum_mean_diameter), + FT(minimum_mean_diameter), + EffectiveRadius(), + MeanDiameter(), + MeanDensity(), + Reflectivity(), + SlopeParameter(), + ShapeParameter(), + SheddingRate() + ) +end + +Base.summary(::IceBulkProperties) = "IceBulkProperties" + +function Base.show(io::IO, bp::IceBulkProperties) + print(io, summary(bp), "(") + print(io, "Dmax=", bp.maximum_mean_diameter, ", ") + print(io, "Dmin=", bp.minimum_mean_diameter, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_collection.jl b/src/Microphysics/PredictedParticleProperties/ice_collection.jl new file mode 100644 index 000000000..1ed364c06 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_collection.jl @@ -0,0 +1,66 @@ +##### +##### Ice Collection +##### +##### Collision-collection integrals for ice particles. +##### Includes aggregation (ice-ice) and rain collection (ice-rain). +##### + +""" + IceCollection + +Ice collision-coalescence efficiencies and collection integrals. +See [`IceCollection`](@ref) constructor for details. +""" +struct IceCollection{FT, AG, RW} + ice_cloud_collection_efficiency :: FT + ice_rain_collection_efficiency :: FT + aggregation :: AG + rain_collection :: RW +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceCollection` with parameters and quadrature-based integrals. + +Collection processes describe ice particles sweeping up other hydrometeors +through gravitational settling. Two main processes are parameterized: + +**Aggregation** (ice + ice → larger ice): +Ice particles collide and stick together to form larger aggregates. +This is the dominant growth mechanism for snow. The aggregation rate +depends on the differential fall speeds of particles of different sizes. + +**Rain collection** (ice + rain → rime on ice): +When ice particles collect rain drops, the liquid freezes on contact +forming rime. This is a key riming pathway along with cloud droplet +collection (handled separately in the scheme). + +# Keyword Arguments + +- `ice_cloud_collection_efficiency`: Eⁱᶜ [-], default 0.1 +- `ice_rain_collection_efficiency`: Eⁱʳ [-], default 1.0 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Sections 2d-e, +[Milbrandt and Yau (2005)](@cite MilbrandtYau2005). +""" +function IceCollection(FT::Type{<:AbstractFloat} = Float64; + ice_cloud_collection_efficiency = 0.1, + ice_rain_collection_efficiency = 1.0) + return IceCollection( + FT(ice_cloud_collection_efficiency), + FT(ice_rain_collection_efficiency), + AggregationNumber(), + RainCollectionNumber() + ) +end + +Base.summary(::IceCollection) = "IceCollection" + +function Base.show(io::IO, c::IceCollection) + print(io, summary(c), "(") + print(io, "Eⁱᶜ=", c.ice_cloud_collection_efficiency, ", ") + print(io, "Eⁱʳ=", c.ice_rain_collection_efficiency, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_deposition.jl b/src/Microphysics/PredictedParticleProperties/ice_deposition.jl new file mode 100644 index 000000000..98d8cc1cf --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_deposition.jl @@ -0,0 +1,83 @@ +##### +##### Ice Deposition +##### +##### Vapor deposition/sublimation integrals for ice particles. +##### Includes ventilation factors that account for enhanced vapor +##### transport due to particle motion through air. +##### + +""" + IceDeposition + +Vapor deposition/sublimation parameters and ventilation integrals. +See [`IceDeposition`](@ref) constructor for details. +""" +struct IceDeposition{FT, V, V1, SC, SR, LC, LR} + thermal_conductivity :: FT + vapor_diffusivity :: FT + ventilation :: V + ventilation_enhanced :: V1 + small_ice_ventilation_constant :: SC + small_ice_ventilation_reynolds :: SR + large_ice_ventilation_constant :: LC + large_ice_ventilation_reynolds :: LR +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceDeposition` with parameters and quadrature-based integrals. + +Ice growth/decay by vapor deposition/sublimation follows the diffusion equation +with ventilation enhancement. The ventilation factor ``fᵛᵉ`` accounts for +enhanced vapor transport due to particle motion through air: + +```math +fᵛᵉ = a + b \\cdot Sc^{1/3} Re^{1/2} +``` + +where ``Sc`` is the Schmidt number and ``Re`` is the Reynolds number. +[Hall and Pruppacher (1976)](@cite HallPruppacher1976) showed that falling +particles have significantly enhanced vapor exchange compared to stationary +particles. + +**Basic ventilation integrals:** +- `ventilation`: Integrated over full size spectrum +- `ventilation_enhanced`: For larger particles (D > 100 μm) + +**Size-regime ventilation** (for melting with liquid fraction): +- `small_ice_ventilation_*`: D ≤ Dcrit, meltwater → rain +- `large_ice_ventilation_*`: D > Dcrit, meltwater → liquid on ice + +# Keyword Arguments + +- `thermal_conductivity`: κ [W/(m·K)], default 0.024 (~273K) +- `vapor_diffusivity`: Dᵥ [m²/s], default 2.2×10⁻⁵ (~273K) + +# References + +[Hall and Pruppacher (1976)](@cite HallPruppacher1976), +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Eq. 34. +""" +function IceDeposition(FT::Type{<:AbstractFloat} = Float64; + thermal_conductivity = 0.024, + vapor_diffusivity = 2.2e-5) + return IceDeposition( + FT(thermal_conductivity), + FT(vapor_diffusivity), + Ventilation(), + VentilationEnhanced(), + SmallIceVentilationConstant(), + SmallIceVentilationReynolds(), + LargeIceVentilationConstant(), + LargeIceVentilationReynolds() + ) +end + +Base.summary(::IceDeposition) = "IceDeposition" + +function Base.show(io::IO, d::IceDeposition) + print(io, summary(d), "(") + print(io, "κ=", d.thermal_conductivity, ", ") + print(io, "Dᵥ=", d.vapor_diffusivity, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_fall_speed.jl b/src/Microphysics/PredictedParticleProperties/ice_fall_speed.jl new file mode 100644 index 000000000..3ef763eaa --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_fall_speed.jl @@ -0,0 +1,76 @@ +##### +##### Ice Fall Speed +##### +##### Terminal velocity integrals over the ice particle size distribution. +##### P3 computes number-, mass-, and reflectivity-weighted fall speeds. +##### + +""" + IceFallSpeed + +Ice terminal velocity power law parameters and weighted fall speed integrals. +See [`IceFallSpeed`](@ref) constructor for details. +""" +struct IceFallSpeed{FT, N, M, Z} + reference_air_density :: FT + fall_speed_coefficient :: FT + fall_speed_exponent :: FT + number_weighted :: N + mass_weighted :: M + reflectivity_weighted :: Z +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceFallSpeed` with parameters and quadrature-based integrals. + +Ice particle terminal velocity follows a power law with air density correction: + +```math +V(D) = a_V \\left(\\frac{ρ_0}{ρ}\\right)^{0.5} D^{b_V} +``` + +where ``a_V`` is `fall_speed_coefficient`, ``b_V`` is `fall_speed_exponent`, +and ``ρ_0`` is `reference_air_density`. The density correction accounts for +reduced drag at higher altitudes where air is less dense. + +Three weighted fall speeds are computed by integrating over the size distribution: + +- **Number-weighted** ``V_n``: For number flux (sedimentation of particle count) +- **Mass-weighted** ``V_m``: For mass flux (precipitation rate) +- **Reflectivity-weighted** ``V_z``: For 3-moment scheme (6th moment flux) + +# Keyword Arguments + +- `reference_air_density`: Reference ρ₀ [kg/m³], default 1.225 (sea level) +- `fall_speed_coefficient`: Coefficient aᵥ [m^{1-b}/s], default 11.72 +- `fall_speed_exponent`: Exponent bᵥ [-], default 0.41 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Eq. 20, +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) for reflectivity weighting. +""" +function IceFallSpeed(FT::Type{<:AbstractFloat} = Float64; + reference_air_density = 1.225, + fall_speed_coefficient = 11.72, + fall_speed_exponent = 0.41) + return IceFallSpeed( + FT(reference_air_density), + FT(fall_speed_coefficient), + FT(fall_speed_exponent), + NumberWeightedFallSpeed(), + MassWeightedFallSpeed(), + ReflectivityWeightedFallSpeed() + ) +end + +Base.summary(::IceFallSpeed) = "IceFallSpeed" + +function Base.show(io::IO, fs::IceFallSpeed) + print(io, summary(fs), "(") + print(io, "ρ₀=", fs.reference_air_density, ", ") + print(io, "aᵥ=", fs.fall_speed_coefficient, ", ") + print(io, "bᵥ=", fs.fall_speed_exponent, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_lambda_limiter.jl b/src/Microphysics/PredictedParticleProperties/ice_lambda_limiter.jl new file mode 100644 index 000000000..b796a2f82 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_lambda_limiter.jl @@ -0,0 +1,52 @@ +##### +##### Ice Lambda Limiter +##### +##### Integrals used to limit the slope parameter λ of the gamma +##### size distribution to physically reasonable values. +##### + +""" + IceLambdaLimiter + +Integrals for constraining λ to physical bounds. +See [`IceLambdaLimiter`](@ref) constructor for details. +""" +struct IceLambdaLimiter{S, L} + small_q :: S + large_q :: L +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceLambdaLimiter` with quadrature-based integrals. + +The slope parameter λ of the gamma size distribution can become +unrealistically large or small as prognostic moments evolve. This +happens at edges of mixed-phase regions or during rapid microphysical +adjustments. + +**Physical interpretation:** +- Very large λ → all particles tiny (mean size → 0) +- Very small λ → all particles huge (mean size → ∞) + +These integrals compute the limiting values: +- `small_q`: λ limit when q is small (prevents vanishingly tiny particles) +- `large_q`: λ limit when q is large (prevents unrealistically huge particles) + +The limiter ensures the diagnosed size distribution remains physically +sensible even when the prognostic constraints become degenerate. + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Section 2b. +""" +function IceLambdaLimiter() + return IceLambdaLimiter( + NumberMomentLambdaLimit(), + MassMomentLambdaLimit() + ) +end + +Base.summary(::IceLambdaLimiter) = "IceLambdaLimiter" +Base.show(io::IO, ::IceLambdaLimiter) = print(io, "IceLambdaLimiter(2 integrals)") diff --git a/src/Microphysics/PredictedParticleProperties/ice_properties.jl b/src/Microphysics/PredictedParticleProperties/ice_properties.jl new file mode 100644 index 000000000..175ee928d --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_properties.jl @@ -0,0 +1,96 @@ +##### +##### Ice Properties +##### +##### Container combining all ice particle property concepts. +##### + +""" + IceProperties + +Ice particle properties for P3. See [`IceProperties()`](@ref) constructor. +""" +struct IceProperties{FT, FS, DP, BP, CL, M6, LL, IR} + # Top-level parameters + minimum_rime_density :: FT + maximum_rime_density :: FT + maximum_shape_parameter :: FT + minimum_reflectivity :: FT + # Concept containers + fall_speed :: FS + deposition :: DP + bulk_properties :: BP + collection :: CL + sixth_moment :: M6 + lambda_limiter :: LL + ice_rain :: IR +end + +""" +$(TYPEDSIGNATURES) + +Construct ice particle properties with parameters and integrals for the P3 scheme. + +Ice particles in P3 span a continuum from small pristine crystals to large +heavily-rimed graupel. The particle mass ``m(D)`` follows a piecewise power +law depending on size ``D``, rime fraction ``Fᶠ``, and rime density ``ρᶠ``. + +# Physical Concepts + +This container organizes all ice-related computations: + +- **Fall speed**: Terminal velocity integrals for sedimentation + (number-weighted, mass-weighted, reflectivity-weighted) +- **Deposition**: Ventilation integrals for vapor diffusion growth +- **Bulk properties**: Population-averaged diameter, density, reflectivity +- **Collection**: Integrals for aggregation and riming rates +- **Sixth moment**: Z-tendency integrals for three-moment ice +- **Lambda limiter**: Constraints on size distribution slope + +# Keyword Arguments + +- `minimum_rime_density`: Lower bound for ρᶠ [kg/m³], default 50 +- `maximum_rime_density`: Upper bound for ρᶠ [kg/m³], default 900 (pure ice) +- `maximum_shape_parameter`: Upper limit on μ [-], default 10 +- `minimum_reflectivity`: Numerical floor for Z [m⁶/m³], default 10⁻²² + +# References + +The mass-diameter relationship is from +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +with sixth moment formulations from +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021). +""" +function IceProperties(FT::Type{<:AbstractFloat} = Float64; + minimum_rime_density = 50, + maximum_rime_density = 900, + maximum_shape_parameter = 10, + minimum_reflectivity = 1e-22) + return IceProperties( + FT(minimum_rime_density), + FT(maximum_rime_density), + FT(maximum_shape_parameter), + FT(minimum_reflectivity), + IceFallSpeed(FT), + IceDeposition(FT), + IceBulkProperties(FT), + IceCollection(FT), + IceSixthMoment(), + IceLambdaLimiter(), + IceRainCollection() + ) +end + +Base.summary(::IceProperties) = "IceProperties" + +function Base.show(io::IO, ice::IceProperties) + print(io, summary(ice), '\n') + print(io, "├── ρᶠ: [", ice.minimum_rime_density, ", ", ice.maximum_rime_density, "] kg/m³\n") + print(io, "├── μmax: ", ice.maximum_shape_parameter, "\n") + print(io, "├── ", ice.fall_speed, "\n") + print(io, "├── ", ice.deposition, "\n") + print(io, "├── ", ice.bulk_properties, "\n") + print(io, "├── ", ice.collection, "\n") + print(io, "├── ", ice.sixth_moment, "\n") + print(io, "├── ", ice.lambda_limiter, "\n") + print(io, "└── ", ice.ice_rain) +end diff --git a/src/Microphysics/PredictedParticleProperties/ice_rain_collection.jl b/src/Microphysics/PredictedParticleProperties/ice_rain_collection.jl new file mode 100644 index 000000000..b3dbd7b10 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_rain_collection.jl @@ -0,0 +1,58 @@ +##### +##### Ice-Rain Collection +##### +##### Collection integrals for ice particles collecting rain drops. +##### These are computed for multiple rain size bins in the P3 scheme. +##### + +""" + IceRainCollection + +Ice collecting rain integrals for mass, number, and sixth moment. +See [`IceRainCollection`](@ref) constructor for details. +""" +struct IceRainCollection{QR, NR, ZR} + mass :: QR + number :: NR + sixth_moment :: ZR +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceRainCollection` with quadrature-based integrals. + +When ice particles collect rain drops through gravitational sweepout, +the rain freezes on contact (riming). This transfers mass, number, and +reflectivity from rain to ice. + +**Conservation:** +- Mass: ``dq_r/dt < 0``, ``dq_i/dt > 0`` +- Number: Rain number decreases as drops are absorbed +- Sixth moment: Transferred to ice (3-moment scheme) + +The collection rate depends on the collision kernel integrating over +both size distributions. P3 uses a simplified approach with rain +binned into discrete size categories. + +# Integrals + +- `mass`: Rate of rain mass transfer to ice +- `number`: Rate of rain drop removal +- `sixth_moment`: Rate of Z transfer (3-moment) + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) for sixth moment. +""" +function IceRainCollection() + return IceRainCollection( + IceRainMassCollection(), + IceRainNumberCollection(), + IceRainSixthMomentCollection() + ) +end + +Base.summary(::IceRainCollection) = "IceRainCollection" +Base.show(io::IO, ::IceRainCollection) = print(io, "IceRainCollection(3 integrals)") diff --git a/src/Microphysics/PredictedParticleProperties/ice_sixth_moment.jl b/src/Microphysics/PredictedParticleProperties/ice_sixth_moment.jl new file mode 100644 index 000000000..1687495de --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/ice_sixth_moment.jl @@ -0,0 +1,73 @@ +##### +##### Ice Sixth Moment +##### +##### Tendencies for the 6th moment (proportional to radar reflectivity) +##### of the ice particle size distribution. +##### Used in 3-moment ice schemes. +##### + +""" + IceSixthMoment + +Sixth moment (reflectivity) tendency integrals for 3-moment ice. +See [`IceSixthMoment`](@ref) constructor for details. +""" +struct IceSixthMoment{RI, DP, D1, M1, M2, AG, SH, SB, S1} + rime :: RI + deposition :: DP + deposition1 :: D1 + melt1 :: M1 + melt2 :: M2 + shedding :: SH + aggregation :: AG + sublimation :: SB + sublimation1 :: S1 +end + +""" +$(TYPEDSIGNATURES) + +Construct `IceSixthMoment` with quadrature-based integrals. + +The sixth moment ``M_6 = ∫ D^6 N'(D) dD`` is proportional to radar +reflectivity Z. Prognosing M₆ (or equivalently Z) as a third moment +provides an independent constraint on the shape of the size distribution, +improving representation of differential fall speeds and collection. + +Each microphysical process that affects ice mass also affects M₆: + +**Growth:** +- `rime`: Riming adds mass preferentially to larger particles +- `deposition`, `deposition1`: Vapor deposition with/without ventilation + +**Melting:** +- `melt1`, `melt2`: Two terms in the melting tendency +- `shedding`: Meltwater that leaves the ice particle + +**Collection:** +- `aggregation`: Aggregation shifts mass to larger sizes, increasing Z + +**Sublimation:** +- `sublimation`, `sublimation1`: Mass loss with/without ventilation + +# References + +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) introduced 3-moment ice, +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) refined the approach. +""" +function IceSixthMoment() + return IceSixthMoment( + SixthMomentRime(), + SixthMomentDeposition(), + SixthMomentDeposition1(), + SixthMomentMelt1(), + SixthMomentMelt2(), + SixthMomentShedding(), + SixthMomentAggregation(), + SixthMomentSublimation(), + SixthMomentSublimation1() + ) +end + +Base.summary(::IceSixthMoment) = "IceSixthMoment" +Base.show(io::IO, ::IceSixthMoment) = print(io, "IceSixthMoment(9 integrals)") diff --git a/src/Microphysics/PredictedParticleProperties/integral_types.jl b/src/Microphysics/PredictedParticleProperties/integral_types.jl new file mode 100644 index 000000000..9bce9a4c0 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/integral_types.jl @@ -0,0 +1,434 @@ +##### +##### P3 Integral Types +##### +##### This file defines abstract and concrete types for the 29 ice integrals +##### plus rain integrals used in the P3 microphysics scheme. +##### +##### Each integral type can be evaluated via quadrature or tabulated for efficiency. +##### The type hierarchy groups integrals by physical concept. +##### +##### References: +##### - Fall speed integrals: Morrison & Milbrandt (2015a) Table 3 +##### - Deposition/ventilation integrals: Morrison & Milbrandt (2015a) Eqs. 30-32 +##### - Collection integrals: Morrison & Milbrandt (2015a) Eqs. 36-42 +##### - Sixth moment integrals: Milbrandt et al. (2021) Table 1, Morrison et al. (2025) +##### - Lambda limiter integrals: Morrison & Milbrandt (2015a) Section 2b +##### - Rain integrals: Morrison & Milbrandt (2015a) Eqs. 46-47 +##### + +##### +##### Abstract hierarchy +##### + +""" + AbstractP3Integral + +Abstract supertype for all P3 scheme integrals over particle size distributions. +""" +abstract type AbstractP3Integral end + +""" + AbstractIceIntegral <: AbstractP3Integral + +Abstract supertype for ice particle integrals. +""" +abstract type AbstractIceIntegral <: AbstractP3Integral end + +""" + AbstractRainIntegral <: AbstractP3Integral + +Abstract supertype for rain particle integrals. +""" +abstract type AbstractRainIntegral <: AbstractP3Integral end + +##### +##### Ice integral categories (29 total) +##### + +# Fall speed integrals (3) +abstract type AbstractFallSpeedIntegral <: AbstractIceIntegral end + +# Deposition/ventilation integrals (6) +abstract type AbstractDepositionIntegral <: AbstractIceIntegral end + +# Bulk property integrals (7) +abstract type AbstractBulkPropertyIntegral <: AbstractIceIntegral end + +# Collection integrals (2) +abstract type AbstractCollectionIntegral <: AbstractIceIntegral end + +# Sixth moment integrals (9) +abstract type AbstractSixthMomentIntegral <: AbstractIceIntegral end + +# Lambda limiter integrals (2) +abstract type AbstractLambdaLimiterIntegral <: AbstractIceIntegral end + +##### +##### Fall speed integrals (3) +##### +##### uns, ums, uzs in Fortran +##### + +""" + NumberWeightedFallSpeed <: AbstractFallSpeedIntegral + +Number-weighted mean fall speed: + +```math +V_n = \\frac{\\int_0^\\infty V(D) N'(D) \\, dD}{\\int_0^\\infty N'(D) \\, dD} +``` + +Corresponds to `uns` in P3 Fortran code. +""" +struct NumberWeightedFallSpeed <: AbstractFallSpeedIntegral end + +""" + MassWeightedFallSpeed <: AbstractFallSpeedIntegral + +Mass-weighted mean fall speed: + +```math +V_m = \\frac{\\int_0^\\infty V(D) m(D) N'(D) \\, dD}{\\int_0^\\infty m(D) N'(D) \\, dD} +``` + +Corresponds to `ums` in P3 Fortran code. +""" +struct MassWeightedFallSpeed <: AbstractFallSpeedIntegral end + +""" + ReflectivityWeightedFallSpeed <: AbstractFallSpeedIntegral + +Reflectivity-weighted (6th moment) mean fall speed for 3-moment ice: + +```math +V_z = \\frac{\\int_0^\\infty V(D) D^6 N'(D) \\, dD}{\\int_0^\\infty D^6 N'(D) \\, dD} +``` + +Corresponds to `uzs` in P3 Fortran code. +""" +struct ReflectivityWeightedFallSpeed <: AbstractFallSpeedIntegral end + +##### +##### Deposition/ventilation integrals (6) +##### +##### vdep, vdep1, vdepm1, vdepm2, vdepm3, vdepm4 in Fortran +##### + +""" + Ventilation <: AbstractDepositionIntegral + +Basic ventilation factor for vapor diffusion. +Corresponds to `vdep` in P3 Fortran code. +""" +struct Ventilation <: AbstractDepositionIntegral end + +""" + VentilationEnhanced <: AbstractDepositionIntegral + +Enhanced ventilation factor for particles > 100 μm. +Corresponds to `vdep1` in P3 Fortran code. +""" +struct VentilationEnhanced <: AbstractDepositionIntegral end + +""" + SmallIceVentilationConstant <: AbstractDepositionIntegral + +Ventilation for small ice (D ≤ D_crit), constant term. +Melted water from these particles transfers to rain. +Corresponds to `vdepm1` in P3 Fortran code. +""" +struct SmallIceVentilationConstant <: AbstractDepositionIntegral end + +""" + SmallIceVentilationReynolds <: AbstractDepositionIntegral + +Ventilation for small ice (D ≤ D_crit), Reynolds-dependent term. +Melted water from these particles transfers to rain. +Corresponds to `vdepm2` in P3 Fortran code. +""" +struct SmallIceVentilationReynolds <: AbstractDepositionIntegral end + +""" + LargeIceVentilationConstant <: AbstractDepositionIntegral + +Ventilation for large ice (D > D_crit), constant term. +Melted water from these particles accumulates as liquid on ice. +Corresponds to `vdepm3` in P3 Fortran code. +""" +struct LargeIceVentilationConstant <: AbstractDepositionIntegral end + +""" + LargeIceVentilationReynolds <: AbstractDepositionIntegral + +Ventilation for large ice (D > D_crit), Reynolds-dependent term. +Melted water from these particles accumulates as liquid on ice. +Corresponds to `vdepm4` in P3 Fortran code. +""" +struct LargeIceVentilationReynolds <: AbstractDepositionIntegral end + +##### +##### Bulk property integrals (7) +##### +##### eff, dmm, rhomm, refl, lambda_i, mu_i_save, qshed in Fortran +##### + +""" + EffectiveRadius <: AbstractBulkPropertyIntegral + +Effective radius for radiative calculations. +Corresponds to `eff` in P3 Fortran code. +""" +struct EffectiveRadius <: AbstractBulkPropertyIntegral end + +""" + MeanDiameter <: AbstractBulkPropertyIntegral + +Mass-weighted mean diameter. +Corresponds to `dmm` in P3 Fortran code. +""" +struct MeanDiameter <: AbstractBulkPropertyIntegral end + +""" + MeanDensity <: AbstractBulkPropertyIntegral + +Mass-weighted mean particle density. +Corresponds to `rhomm` in P3 Fortran code. +""" +struct MeanDensity <: AbstractBulkPropertyIntegral end + +""" + Reflectivity <: AbstractBulkPropertyIntegral + +Radar reflectivity factor (6th moment of size distribution). +Corresponds to `refl` in P3 Fortran code. +""" +struct Reflectivity <: AbstractBulkPropertyIntegral end + +""" + SlopeParameter <: AbstractBulkPropertyIntegral + +Slope parameter λ of the gamma size distribution. +Corresponds to `lambda_i` in P3 Fortran code. +""" +struct SlopeParameter <: AbstractBulkPropertyIntegral end + +""" + ShapeParameter <: AbstractBulkPropertyIntegral + +Shape parameter μ of the gamma size distribution. +Corresponds to `mu_i_save` in P3 Fortran code. +""" +struct ShapeParameter <: AbstractBulkPropertyIntegral end + +""" + SheddingRate <: AbstractBulkPropertyIntegral + +Rate of meltwater shedding from ice particles. +Corresponds to `qshed` in P3 Fortran code. +""" +struct SheddingRate <: AbstractBulkPropertyIntegral end + +##### +##### Collection integrals (2) +##### +##### nagg, nrwat in Fortran +##### + +""" + AggregationNumber <: AbstractCollectionIntegral + +Number tendency from ice-ice aggregation. +Corresponds to `nagg` in P3 Fortran code. +""" +struct AggregationNumber <: AbstractCollectionIntegral end + +""" + RainCollectionNumber <: AbstractCollectionIntegral + +Number tendency from rain collection by ice. +Corresponds to `nrwat` in P3 Fortran code. +""" +struct RainCollectionNumber <: AbstractCollectionIntegral end + +##### +##### Sixth moment integrals (9) +##### +##### m6rime, m6dep, m6dep1, m6mlt1, m6mlt2, m6agg, m6shd, m6sub, m6sub1 in Fortran +##### + +""" + SixthMomentRime <: AbstractSixthMomentIntegral + +Sixth moment tendency from riming. +Corresponds to `m6rime` in P3 Fortran code. +""" +struct SixthMomentRime <: AbstractSixthMomentIntegral end + +""" + SixthMomentDeposition <: AbstractSixthMomentIntegral + +Sixth moment tendency from vapor deposition. +Corresponds to `m6dep` in P3 Fortran code. +""" +struct SixthMomentDeposition <: AbstractSixthMomentIntegral end + +""" + SixthMomentDeposition1 <: AbstractSixthMomentIntegral + +Sixth moment tendency from vapor deposition (enhanced ventilation). +Corresponds to `m6dep1` in P3 Fortran code. +""" +struct SixthMomentDeposition1 <: AbstractSixthMomentIntegral end + +""" + SixthMomentMelt1 <: AbstractSixthMomentIntegral + +Sixth moment tendency from melting (term 1). +Corresponds to `m6mlt1` in P3 Fortran code. +""" +struct SixthMomentMelt1 <: AbstractSixthMomentIntegral end + +""" + SixthMomentMelt2 <: AbstractSixthMomentIntegral + +Sixth moment tendency from melting (term 2). +Corresponds to `m6mlt2` in P3 Fortran code. +""" +struct SixthMomentMelt2 <: AbstractSixthMomentIntegral end + +""" + SixthMomentAggregation <: AbstractSixthMomentIntegral + +Sixth moment tendency from aggregation. +Corresponds to `m6agg` in P3 Fortran code. +""" +struct SixthMomentAggregation <: AbstractSixthMomentIntegral end + +""" + SixthMomentShedding <: AbstractSixthMomentIntegral + +Sixth moment tendency from meltwater shedding. +Corresponds to `m6shd` in P3 Fortran code. +""" +struct SixthMomentShedding <: AbstractSixthMomentIntegral end + +""" + SixthMomentSublimation <: AbstractSixthMomentIntegral + +Sixth moment tendency from sublimation. +Corresponds to `m6sub` in P3 Fortran code. +""" +struct SixthMomentSublimation <: AbstractSixthMomentIntegral end + +""" + SixthMomentSublimation1 <: AbstractSixthMomentIntegral + +Sixth moment tendency from sublimation (enhanced ventilation). +Corresponds to `m6sub1` in P3 Fortran code. +""" +struct SixthMomentSublimation1 <: AbstractSixthMomentIntegral end + +##### +##### Lambda limiter integrals (2) +##### +##### i_qsmall, i_qlarge in Fortran +##### + +""" + NumberMomentLambdaLimit <: AbstractLambdaLimiterIntegral + +Number moment integral for lambda limiting: ``∫ N'(D) \\, dD``. + +Used to constrain ``λ`` when ice mass mixing ratio is small. +Corresponds to `i_qsmall` in P3 Fortran code. +""" +struct NumberMomentLambdaLimit <: AbstractLambdaLimiterIntegral end + +""" + MassMomentLambdaLimit <: AbstractLambdaLimiterIntegral + +Mass moment integral for lambda limiting: ``∫ m(D) N'(D) \\, dD``. + +Used to constrain ``λ`` when ice mass mixing ratio is large. +Corresponds to `i_qlarge` in P3 Fortran code. +""" +struct MassMomentLambdaLimit <: AbstractLambdaLimiterIntegral end + +##### +##### Rain integrals +##### + +""" + RainShapeParameter <: AbstractRainIntegral + +Shape parameter μ_r for rain gamma distribution. +""" +struct RainShapeParameter <: AbstractRainIntegral end + +""" + RainVelocityNumber <: AbstractRainIntegral + +Number-weighted rain fall speed. +""" +struct RainVelocityNumber <: AbstractRainIntegral end + +""" + RainVelocityMass <: AbstractRainIntegral + +Mass-weighted rain fall speed. +""" +struct RainVelocityMass <: AbstractRainIntegral end + +""" + RainEvaporation <: AbstractRainIntegral + +Rain evaporation rate integral. +""" +struct RainEvaporation <: AbstractRainIntegral end + +##### +##### Ice-rain collection integrals (3 per rain size bin) +##### + +""" + IceRainMassCollection <: AbstractIceIntegral + +Mass collection rate for ice collecting rain. +""" +struct IceRainMassCollection <: AbstractIceIntegral end + +""" + IceRainNumberCollection <: AbstractIceIntegral + +Number collection rate for ice collecting rain. +""" +struct IceRainNumberCollection <: AbstractIceIntegral end + +""" + IceRainSixthMomentCollection <: AbstractIceIntegral + +Sixth moment collection rate for ice collecting rain (3-moment). +""" +struct IceRainSixthMomentCollection <: AbstractIceIntegral end + +##### +##### Tabulated integral wrapper +##### + +""" + TabulatedIntegral{A} + +A tabulated (precomputed) version of an integral stored as an array. +Used for efficient lookup during simulation. + +# Fields +- `data`: Array containing tabulated integral values indexed by + normalized ice mass, rime fraction, and liquid fraction. +""" +struct TabulatedIntegral{A} + data :: A +end + +# Allow indexing into tabulated integrals +Base.getindex(t::TabulatedIntegral, args...) = getindex(t.data, args...) +Base.size(t::TabulatedIntegral) = size(t.data) diff --git a/src/Microphysics/PredictedParticleProperties/lambda_solver.jl b/src/Microphysics/PredictedParticleProperties/lambda_solver.jl new file mode 100644 index 000000000..8f512b33a --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/lambda_solver.jl @@ -0,0 +1,1189 @@ +##### +##### Lambda Solver for P3 Ice Size Distribution +##### +##### Given prognostic moments and ice properties (rime fraction, rime density), +##### solve for the gamma distribution parameters (N₀, λ, μ). +##### +##### The solver handles the piecewise mass-diameter relationship with four regimes +##### from Morrison & Milbrandt (2015a) Equations 1-5. +##### +##### Two closures are available: +##### 1. Two-moment: Uses μ-λ relationship from Field et al. (2007) +##### 2. Three-moment: Uses sixth moment Z to determine μ independently +##### + +##### +##### Mass-diameter relationship parameters +##### + +""" + IceMassPowerLaw + +Power law for ice particle mass. See [`IceMassPowerLaw()`](@ref) constructor. +""" +struct IceMassPowerLaw{FT} + coefficient :: FT + exponent :: FT + ice_density :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct power law parameters for ice particle mass: ``m(D) = α D^β``. + +For vapor-grown aggregates (regime 2 in P3), the mass-diameter relationship +follows a power law with empirically-determined coefficients. This captures +the fractal nature of ice crystal aggregates, which have effective densities +much lower than pure ice. + +# Physical Interpretation + +The exponent ``β ≈ 1.9`` (less than 3) means density decreases with size: +- Small particles: closer to solid ice density +- Large aggregates: fluffy, low effective density + +This is the key to P3's smooth transitions—as particles grow and aggregate, +their properties evolve continuously without discrete category jumps. + +# Keyword Arguments + +- `coefficient`: α in m(D) = α D^β [kg/m^β], default 0.0121 +- `exponent`: β in m(D) = α D^β [-], default 1.9 +- `ice_density`: Pure ice density [kg/m³], default 917 + +# References + +Default parameters from [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) +supplementary material, based on aircraft observations. +""" +function IceMassPowerLaw(FT = Oceananigans.defaults.FloatType; + coefficient = 0.0121, + exponent = 1.9, + ice_density = 917) + return IceMassPowerLaw(FT(coefficient), FT(exponent), FT(ice_density)) +end + +##### +##### μ-λ relationship +##### + +##### +##### Two-moment closure: μ-λ relationship +##### + +""" + TwoMomentClosure + +μ-λ closure for two-moment PSD. See [`TwoMomentClosure()`](@ref) constructor. +""" +struct TwoMomentClosure{FT} + a :: FT + b :: FT + c :: FT + μmax :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct the μ-λ relationship for gamma size distribution closure. + +With only two prognostic moments (mass and number), we need a closure +to determine the three-parameter gamma distribution (N₀, μ, λ). P3 uses +an empirical power-law relating shape parameter μ to slope parameter λ: + +```math +μ = \\text{clamp}(a λ^b - c, 0, μ_{max}) +``` + +This relationship was fitted to aircraft observations of ice particle +size distributions by [Field et al. (2007)](@cite FieldEtAl2007). + +# Physical Interpretation + +- **Small λ** (large particles): μ → 0, giving an exponential distribution +- **Large λ** (small particles): μ increases, narrowing the distribution + +The clamping to [0, μmax] ensures physical distributions with non-negative +shape parameter and prevents unrealistically narrow distributions. + +# Keyword Arguments + +- `a`: Coefficient in μ = a λ^b - c, default 0.00191 +- `b`: Exponent in μ = a λ^b - c, default 0.8 +- `c`: Offset in μ = a λ^b - c, default 2 +- `μmax`: Maximum shape parameter, default 6 + +# References + +From [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Eq. 27, +based on [Field et al. (2007)](@cite FieldEtAl2007) observations. +""" +function TwoMomentClosure(FT = Oceananigans.defaults.FloatType; + a = 0.00191, + b = 0.8, + c = 2, + μmax = 6) + return TwoMomentClosure(FT(a), FT(b), FT(c), FT(μmax)) +end + +# Backwards compatibility alias +const ShapeParameterRelation = TwoMomentClosure + +""" + P3Closure + +Updated μ-λ closure for P3, including the large-particle diagnostic. +See [`P3Closure()`](@ref) constructor. +""" +struct P3Closure{FT} + # Constants for small particle regime (Field et al. 2007) + a :: FT + b :: FT + c :: FT + μmax_small :: FT + # Constants for large particle regime + μmax_large :: FT + D_threshold :: FT # Threshold diameter [m] (0.2 mm) +end + +""" +$(TYPEDSIGNATURES) + +Construct the P3 μ-λ closure which includes a diagnostic for large rimed particles. + +This closure matches the logic in the official P3 Fortran code (lookup table generation). +It uses the Field et al. (2007) relation for small particles, but switches to +a diagnostic based on mean volume diameter (D_mvd) for large particles to account +for riming effects. + +# Logic + +1. Compute mean volume diameter ``D_{mvd} = (L / (\\frac{\\pi}{6} \\rho_g))^{1/3}`` +2. If ``D_{mvd} \\le 0.2`` mm: + Use Field et al. (2007) relation: ``\\mu = 0.076 \\lambda^{0.8} - 2`` (clamped [0, 6]) +3. If ``D_{mvd} > 0.2`` mm: + ``\\mu = 0.25 (D_{mvd} - 0.2) f_\\rho F^f`` (clamped [0, 20]) + where ``f_\\rho = \\max(1, 1 + 0.00842(\\rho_g - 400))`` + +# Keyword Arguments + +- `a`, `b`, `c`: Constants for small regime (same as TwoMomentClosure) +- `μmax_small`: Max μ for small regime (default 6) +- `μmax_large`: Max μ for large regime (default 20) +- `D_threshold`: Threshold D_mvd [m] (default 2e-4) +""" +function P3Closure(FT = Oceananigans.defaults.FloatType; + a = 0.00191, + b = 0.8, + c = 2, + μmax_small = 6, + μmax_large = 20, + D_threshold = 2e-4) + return P3Closure(FT(a), FT(b), FT(c), FT(μmax_small), FT(μmax_large), FT(D_threshold)) +end + +""" + shape_parameter(closure, logλ, L_ice, rime_fraction, rime_density, mass_params) + +Compute shape parameter μ. +""" +function shape_parameter(closure::TwoMomentClosure, logλ, args...) + λ = exp(logλ) + μ = closure.a * λ^closure.b - closure.c + return clamp(μ, zero(μ), closure.μmax) +end + +function shape_parameter(closure::P3Closure, logλ, L_ice, rime_fraction, rime_density, mass::IceMassPowerLaw) + FT = typeof(closure.a) + λ = exp(logλ) + + # 1. Compute graupel density (rho_g) + if iszero(rime_fraction) + ρ_g = mass.ice_density + else + ρ_dep = deposited_ice_density(mass, rime_fraction, rime_density) + ρ_g = graupel_density(rime_fraction, rime_density, ρ_dep) + end + + # 2. Compute D_mvd (Mean Volume Diameter) + # D_mvd = (L / ((pi/6) * rho_g))^(1/3) + val = L_ice / (FT(π)/6 * ρ_g) + # Handle L=0 case + if val <= 0 + D_mvd = zero(FT) + else + D_mvd = val^(1/3) # in meters + end + + # 3. Branch based on D_mvd + if D_mvd <= closure.D_threshold + # Small regime: Heymsfield 2003 + μ = closure.a * λ^closure.b - closure.c + μ = clamp(μ, zero(FT), closure.μmax_small) + else + # Large regime + D_mvd_mm = D_mvd * 1000 + D_thres_mm = closure.D_threshold * 1000 + + # Density adjustment factor + f_ρ = max(one(FT), one(FT) + FT(0.00842) * (ρ_g - 400)) + + # μ = 0.25 * (D_mvd_mm - 0.2) * f_rho * F^f + μ = FT(0.25) * (D_mvd_mm - D_thres_mm) * f_ρ * rime_fraction + + μ = clamp(μ, zero(FT), closure.μmax_large) + end + + return μ +end + +##### +##### Three-moment closure: Z/N constraint +##### + +""" + ThreeMomentClosure + +Three-moment closure using reflectivity. See [`ThreeMomentClosure()`](@ref) constructor. +""" +struct ThreeMomentClosure{FT} + μmin :: FT + μmax :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct a three-moment closure for gamma size distribution. + +With three prognostic moments (mass L, number N, and reflectivity Z), +the shape parameter μ can be diagnosed directly from the moment ratios, +without requiring an empirical μ-λ relationship. + +# Three-Moment Approach + +For a gamma distribution ``N'(D) = N₀ D^μ e^{-λD}``, the moments are: +- ``M_0 = N = N₀ Γ(μ+1) / λ^{μ+1}`` +- ``M_6 = Z = N₀ Γ(μ+7) / λ^{μ+7}`` + +The sixth-to-zeroth moment ratio gives: + +```math +Z/N = Γ(μ+7) / (Γ(μ+1) λ^6) +``` + +Combined with the mass constraint, this provides two equations for two +unknowns (μ, λ), eliminating the need for the empirical μ-λ closure. + +# Advantages + +- **Physical basis**: μ evolves based on actual size distribution changes +- **Better representation of size sorting**: Differential sedimentation + can narrow or broaden distributions independently of total mass/number +- **Improved hail simulation**: Crucial for representing the distinct + size distributions of large, heavily rimed particles + +# Keyword Arguments + +- `μmin`: Minimum shape parameter, default 0 (exponential distribution) +- `μmax`: Maximum shape parameter, default 20 + +# References + +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) introduced three-moment ice, +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) refined the implementation. +""" +function ThreeMomentClosure(FT = Oceananigans.defaults.FloatType; + μmin = 0, + μmax = 20) + return ThreeMomentClosure(FT(μmin), FT(μmax)) +end + +##### +##### Diameter thresholds between particle regimes +##### + +""" + regime_threshold(α, β, ρ) + +Diameter threshold from mass power law: D = (6α / πρ)^(1/(3-β)) + +Used to determine boundaries between spherical ice, aggregates, and graupel. +""" +function regime_threshold(α, β, ρ) + FT = typeof(α) + return (6 * α / (FT(π) * ρ))^(1 / (3 - β)) +end + +""" + deposited_ice_density(mass, rime_fraction, rime_density) + +Density of the vapor-deposited (unrimed) portion of ice particles. +Equation 16 in [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization). +""" +function deposited_ice_density(mass::IceMassPowerLaw, rime_fraction, rime_density) + β = mass.exponent + Fᶠ = rime_fraction + ρᶠ = rime_density + FT = typeof(β) + + # Handle unrimed case to avoid division by zero + if Fᶠ <= eps(FT) + return mass.ice_density + end + + k = (1 - Fᶠ)^(-1 / (3 - β)) + num = ρᶠ * Fᶠ + den = (β - 2) * (k - 1) / ((1 - Fᶠ) * k - 1) - (1 - Fᶠ) + return num / den +end + +""" + graupel_density(rime_fraction, rime_density, deposited_density) + +Bulk density of graupel particles (rime + deposited ice). +""" +function graupel_density(rime_fraction, rime_density, deposited_density) + return rime_fraction * rime_density + (1 - rime_fraction) * deposited_density +end + +""" + IceRegimeThresholds + +Diameter thresholds between P3 ice regimes. See [`ice_regime_thresholds`](@ref). +""" +struct IceRegimeThresholds{FT} + spherical :: FT + graupel :: FT + partial_rime :: FT + ρ_graupel :: FT +end + +""" +$(TYPEDSIGNATURES) + +Compute diameter thresholds separating the four P3 ice particle regimes. + +P3's key innovation is a piecewise mass-diameter relationship that +transitions smoothly between ice particle types: + +1. **Small spherical** (D < D_th): Dense, nearly solid ice crystals +2. **Vapor-grown aggregates** (D_th ≤ D < D_gr): Fractal aggregates, m ∝ D^β +3. **Graupel** (D_gr ≤ D < D_cr): Compact, heavily rimed particles +4. **Partially rimed** (D ≥ D_cr): Large aggregates with rimed cores + +The thresholds depend on rime fraction and rime density, so they evolve +as particles rime—no ad-hoc category conversions needed. + +# Arguments + +- `mass`: Power law parameters for vapor-grown aggregates +- `rime_fraction`: Fraction of particle mass that is rime (0 to 1) +- `rime_density`: Density of rime layer [kg/m³] + +# Returns + +[`IceRegimeThresholds`](@ref) with fields: +- `spherical`: D_th threshold [m] +- `graupel`: D_gr threshold [m] +- `partial_rime`: D_cr threshold [m] +- `ρ_graupel`: Bulk density of graupel [kg/m³] + +# References + +See [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Equations 12-14. +""" +function ice_regime_thresholds(mass::IceMassPowerLaw, rime_fraction, rime_density) + α = mass.coefficient + β = mass.exponent + ρᵢ = mass.ice_density + Fᶠ = rime_fraction + ρᶠ = rime_density + FT = typeof(α) + + D_spherical = regime_threshold(α, β, ρᵢ) + + # Compute rimed case thresholds (will be ignored if unrimed) + # Use safe values to avoid division by zero when Fᶠ = 0 + Fᶠ_safe = max(Fᶠ, eps(FT)) + ρ_dep = deposited_ice_density(mass, Fᶠ_safe, ρᶠ) + ρ_g = graupel_density(Fᶠ_safe, ρᶠ, ρ_dep) + + D_graupel = regime_threshold(α, β, ρ_g) + D_partial = regime_threshold(α, β, ρ_g * (1 - Fᶠ_safe)) + + # For unrimed ice (Fᶠ = 0), use Inf thresholds; otherwise use computed values + is_unrimed = iszero(Fᶠ) + D_graupel_out = ifelse(is_unrimed, FT(Inf), D_graupel) + D_partial_out = ifelse(is_unrimed, FT(Inf), D_partial) + ρ_g_out = ifelse(is_unrimed, ρᵢ, ρ_g) + + return IceRegimeThresholds(D_spherical, D_graupel_out, D_partial_out, ρ_g_out) +end + +""" + ice_mass_coefficients(mass, rime_fraction, rime_density, D) + +Return (a, b) for ice mass at diameter D: m(D) = a D^b. + +The relationship is piecewise across four regimes: +1. D < D_spherical: small spheres, m = (π/6)ρᵢ D³ +2. D_spherical ≤ D < D_graupel: aggregates, m = α D^β +3. D_graupel ≤ D < D_partial: graupel, m = (π/6)ρ_g D³ +4. D ≥ D_partial: partially rimed, m = α/(1-Fᶠ) D^β +""" +function ice_mass_coefficients(mass::IceMassPowerLaw, rime_fraction, rime_density, D) + FT = typeof(D) + α = mass.coefficient + β = mass.exponent + ρᵢ = mass.ice_density + Fᶠ = rime_fraction + + thresholds = ice_regime_thresholds(mass, rime_fraction, rime_density) + + # Regime 1: small spheres + a₁ = ρᵢ * FT(π) / 6 + b₁ = FT(3) + + # Regime 2: aggregates (also used for unrimed large particles) + a₂ = FT(α) + b₂ = FT(β) + + # Regime 3: graupel + a₃ = thresholds.ρ_graupel * FT(π) / 6 + b₃ = FT(3) + + # Regime 4: partially rimed (avoid division by zero) + Fᶠ_safe = min(Fᶠ, 1 - eps(FT)) + a₄ = FT(α) / (1 - Fᶠ_safe) + b₄ = FT(β) + + # Determine which regime applies (work backwards from regime 4) + is_regime_4 = D ≥ thresholds.partial_rime + is_regime_3 = D ≥ thresholds.graupel + is_regime_2 = D ≥ thresholds.spherical + + # Select coefficients: start with regime 4, override with 3, 2, 1 as conditions apply + a = ifelse(is_regime_4, a₄, a₃) + b = ifelse(is_regime_4, b₄, b₃) + + a = ifelse(is_regime_3, a, a₂) + b = ifelse(is_regime_3, b, b₂) + + a = ifelse(is_regime_2, a, a₁) + b = ifelse(is_regime_2, b, b₁) + + return (a, b) +end + +""" + ice_mass(mass, rime_fraction, rime_density, D) + +Compute ice particle mass at diameter D. +""" +function ice_mass(mass::IceMassPowerLaw, rime_fraction, rime_density, D) + (a, b) = ice_mass_coefficients(mass, rime_fraction, rime_density, D) + return a * D^b +end + +##### +##### Gamma distribution moment integrals +##### + +""" + log_gamma_moment(μ, logλ; k=0, scale=1) + +Compute log(scale × ∫₀^∞ D^k G(D) dD) where G(D) = D^μ exp(-λD). + +The integral equals Γ(k+μ+1) / λ^(k+μ+1). +""" +function log_gamma_moment(μ, logλ; k = 0, scale = 1) + FT = typeof(μ) + z = k + μ + 1 + return -z * logλ + loggamma(z) + log(FT(scale)) +end + +""" + log_gamma_inc_moment(D₁, D₂, μ, logλ; k=0, scale=1) + +Compute log(scale × ∫_{D₁}^{D₂} D^k G(D) dD) using incomplete gamma functions. +""" +function log_gamma_inc_moment(D₁, D₂, μ, logλ; k = 0, scale = 1) + FT = typeof(μ) + D₁ < D₂ || return log(zero(FT)) + + z = k + μ + 1 + λ = exp(logλ) + + (_, q₁) = gamma_inc(z, λ * D₁) + (_, q₂) = gamma_inc(z, λ * D₂) + + Δq = max(q₁ - q₂, eps(FT)) + + return -z * logλ + loggamma(z) + log(Δq) + log(FT(scale)) +end + +""" + logaddexp(a, b) + +Compute log(exp(a) + exp(b)) stably. +""" +function logaddexp(a, b) + # Compute both forms and select the stable one + result_a_larger = a + log1p(exp(b - a)) + result_b_larger = b + log1p(exp(a - b)) + return ifelse(a > b, result_a_larger, result_b_larger) +end + +""" + log_mass_moment(mass, rime_fraction, rime_density, μ, logλ; n=0) + +Compute log(∫₀^∞ Dⁿ m(D) N'(D) dD / N₀) over the piecewise mass-diameter relationship. +""" +function log_mass_moment(mass::IceMassPowerLaw, rime_fraction, rime_density, μ, logλ; n = 0) + FT = typeof(μ) + Fᶠ = rime_fraction + + thresholds = ice_regime_thresholds(mass, rime_fraction, rime_density) + α = mass.coefficient + β = mass.exponent + ρᵢ = mass.ice_density + + # Regime 1: small spherical ice [0, D_spherical) + a₁ = ρᵢ * FT(π) / 6 + log_M₁ = log_gamma_inc_moment(zero(FT), thresholds.spherical, μ, logλ; k = 3 + n, scale = a₁) + + # Compute unrimed case: aggregates [D_spherical, ∞) + log_M₂_unrimed = log_gamma_inc_moment(thresholds.spherical, FT(Inf), μ, logλ; k = β + n, scale = α) + unrimed_result = logaddexp(log_M₁, log_M₂_unrimed) + + # Compute rimed case (regimes 2-4) + # Use safe rime fraction to avoid division by zero + Fᶠ_safe = max(Fᶠ, eps(FT)) + + # Regime 2: rimed aggregates [D_spherical, D_graupel) + log_M₂ = log_gamma_inc_moment(thresholds.spherical, thresholds.graupel, μ, logλ; k = β + n, scale = α) + + # Regime 3: graupel [D_graupel, D_partial) + a₃ = thresholds.ρ_graupel * FT(π) / 6 + log_M₃ = log_gamma_inc_moment(thresholds.graupel, thresholds.partial_rime, μ, logλ; k = 3 + n, scale = a₃) + + # Regime 4: partially rimed [D_partial, ∞) + a₄ = α / (1 - Fᶠ_safe) + log_M₄ = log_gamma_inc_moment(thresholds.partial_rime, FT(Inf), μ, logλ; k = β + n, scale = a₄) + + rimed_result = logaddexp(logaddexp(log_M₁, log_M₂), logaddexp(log_M₃, log_M₄)) + + # Select result based on whether ice is rimed + return ifelse(iszero(Fᶠ), unrimed_result, rimed_result) +end + +##### +##### Lambda solver (two-moment) +##### + +""" + log_mass_number_ratio(mass, closure, rime_fraction, rime_density, logλ, L_ice) + +Compute log(L_ice / N_ice) as a function of logλ for two-moment closure. +Includes L_ice argument to support the P3Closure diagnostic. +""" +function log_mass_number_ratio(mass::IceMassPowerLaw, + closure, + rime_fraction, rime_density, logλ, L_ice) + μ = shape_parameter(closure, logλ, L_ice, rime_fraction, rime_density, mass) + log_L_over_N₀ = log_mass_moment(mass, rime_fraction, rime_density, μ, logλ) + log_N_over_N₀ = log_gamma_moment(μ, logλ) + return log_L_over_N₀ - log_N_over_N₀ +end + +##### +##### Lambda solver (three-moment) +##### + +""" + log_sixth_moment(μ, logλ) + +Compute log(M₆/N₀) = log(∫₀^∞ D⁶ N'(D) dD / N₀) for a gamma distribution. + +The sixth moment integral equals Γ(μ+7) / λ^(μ+7). +""" +function log_sixth_moment(μ, logλ) + return log_gamma_moment(μ, logλ; k = 6) +end + +""" + log_reflectivity_number_ratio(μ, logλ) + +Compute log(Z/N) for a gamma distribution. + +```math +Z/N = Γ(μ+7) / (Γ(μ+1) λ^6) +``` +""" +function log_reflectivity_number_ratio(μ, logλ) + log_Z_over_N₀ = log_sixth_moment(μ, logλ) + log_N_over_N₀ = log_gamma_moment(μ, logλ) + return log_Z_over_N₀ - log_N_over_N₀ +end + +""" + lambda_from_reflectivity(μ, Z_ice, N_ice) + +Compute λ from the Z/N ratio given a fixed shape parameter μ. + +From Z/N = Γ(μ+7) / (Γ(μ+1) λ⁶), we get: +```math +λ = \\left( \\frac{Γ(μ+7) N}{Γ(μ+1) Z} \\right)^{1/6} +``` +""" +function lambda_from_reflectivity(μ, Z_ice, N_ice) + FT = typeof(μ) + (iszero(Z_ice) || iszero(N_ice)) && return FT(Inf) + + log_ratio = loggamma(μ + 7) - loggamma(μ + 1) + log(N_ice) - log(Z_ice) + return exp(log_ratio / 6) +end + +""" + log_lambda_from_reflectivity(μ, log_Z_over_N) + +Compute log(λ) from log(Z/N) given shape parameter μ. +""" +function log_lambda_from_reflectivity(μ, log_Z_over_N) + # From Z/N = Γ(μ+7) / (Γ(μ+1) λ⁶) + # λ⁶ = Γ(μ+7) / (Γ(μ+1) × Z/N) + # log(λ) = [loggamma(μ+7) - loggamma(μ+1) - log(Z/N)] / 6 + return (loggamma(μ + 7) - loggamma(μ + 1) - log_Z_over_N) / 6 +end + +""" + mass_residual_three_moment(mass, rime_fraction, rime_density, μ, log_Z_over_N, log_L_over_N) + +Compute the mass constraint residual for three-moment solving. + +Given μ and log(Z/N), we can compute λ. Then the residual is: + computed log(L/N) - target log(L/N) + +This should be zero at the correct μ. +""" +function mass_residual_three_moment(mass::IceMassPowerLaw, + rime_fraction, rime_density, + μ, log_Z_over_N, log_L_over_N) + # Compute λ from Z/N constraint + logλ = log_lambda_from_reflectivity(μ, log_Z_over_N) + + # Compute L/N at this (μ, λ) + log_L_over_N₀ = log_mass_moment(mass, rime_fraction, rime_density, μ, logλ) + log_N_over_N₀ = log_gamma_moment(μ, logλ) + computed_log_L_over_N = log_L_over_N₀ - log_N_over_N₀ + + return computed_log_L_over_N - log_L_over_N +end + +""" + solve_shape_parameter(L_ice, N_ice, Z_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = ThreeMomentClosure(), + max_iterations = 50, + tolerance = 1e-10) + +Solve for shape parameter μ using the three-moment constraint. + +The algorithm: +1. For each candidate μ, compute λ from the Z/N constraint +2. Check if the resulting (μ, λ) satisfies the L/N constraint +3. Use bisection to find the μ that satisfies both constraints + +# Arguments +- `L_ice`: Ice mass concentration [kg/m³] +- `N_ice`: Ice number concentration [1/m³] +- `Z_ice`: Ice sixth moment / reflectivity [m⁶/m³] +- `rime_fraction`: Mass fraction of rime [-] +- `rime_density`: Density of rime [kg/m³] + +# Returns +- Shape parameter μ +""" +function solve_shape_parameter(L_ice, N_ice, Z_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = ThreeMomentClosure(), + max_iterations = 50, + tolerance = 1e-10) + FT = typeof(L_ice) + + # Handle edge cases + (iszero(N_ice) || iszero(L_ice) || iszero(Z_ice)) && return closure.μmin + + # Target ratios + log_Z_over_N = log(Z_ice) - log(N_ice) + log_L_over_N = log(L_ice) - log(N_ice) + + # Residual function + f(μ) = mass_residual_three_moment(mass, rime_fraction, rime_density, + μ, log_Z_over_N, log_L_over_N) + + # Bisection method over [μmin, μmax] + μ_lo = closure.μmin + μ_hi = closure.μmax + + f_lo = f(μ_lo) + f_hi = f(μ_hi) + + # Check if solution is in bounds + # If residuals have same sign, clamp to appropriate bound + same_sign = f_lo * f_hi > 0 + is_below = f_lo > 0 # Both residuals positive means μ is too small + + # If no sign change, return boundary value + if same_sign + return ifelse(is_below, closure.μmax, closure.μmin) + end + + # Bisection iteration + for _ in 1:max_iterations + μ_mid = (μ_lo + μ_hi) / 2 + f_mid = f(μ_mid) + + abs(f_mid) < tolerance && return μ_mid + (μ_hi - μ_lo) < tolerance * μ_mid && return μ_mid + + # Update bounds + if f_lo * f_mid < 0 + μ_hi = μ_mid + f_hi = f_mid + else + μ_lo = μ_mid + f_lo = f_mid + end + end + + return (μ_lo + μ_hi) / 2 +end + +""" + solve_lambda(L_ice, N_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = P3Closure(), + logλ_bounds = (log(10), log(1e7)), + max_iterations = 50, + tolerance = 1e-10) + +Solve for slope parameter λ given ice mass and number concentrations. + +Uses the secant method to find logλ such that the computed L/N ratio +matches the observed ratio. This is the two-moment solver using the +μ-λ closure relationship. + +# Arguments +- `L_ice`: Ice mass concentration [kg/m³] +- `N_ice`: Ice number concentration [1/m³] +- `rime_fraction`: Mass fraction of rime [-] +- `rime_density`: Density of rime [kg/m³] + +# Keyword Arguments +- `mass`: Power law parameters (default: `IceMassPowerLaw()`) +- `closure`: Two-moment closure (default: `P3Closure()`) + +# Returns +- `logλ`: Log of slope parameter +""" +function solve_lambda(L_ice, N_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = P3Closure(), + shape_relation = nothing, # deprecated, for backwards compatibility + logλ_bounds = (log(10), log(1e7)), + max_iterations = 50, + tolerance = 1e-10, + kwargs...) + + # Handle deprecated keyword + actual_closure = isnothing(shape_relation) ? closure : shape_relation + + FT = typeof(L_ice) + if L_ice <= 0 || N_ice <= 0 + # No ice mass or number: return upper bound to avoid unphysical λ = 0. + return FT(logλ_bounds[2]) + end + + target = log(L_ice) - log(N_ice) + # Pass L_ice to log_mass_number_ratio for P3 closure diagnostic + f(logλ) = log_mass_number_ratio(mass, actual_closure, rime_fraction, rime_density, logλ, L_ice) - target + + # Secant method + x₀, x₁ = FT.(logλ_bounds) + f₀, f₁ = f(x₀), f(x₁) + + for _ in 1:max_iterations + Δx = f₁ * (x₁ - x₀) / (f₁ - f₀) + x₂ = clamp(x₁ - Δx, FT(logλ_bounds[1]), FT(logλ_bounds[2])) + + abs(Δx) < tolerance * abs(x₁) && return x₂ + + x₀, f₀ = x₁, f₁ + x₁, f₁ = x₂, f(x₂) + end + + return x₁ +end + +""" + solve_lambda(L_ice, N_ice, Z_ice, rime_fraction, rime_density, μ; + mass = IceMassPowerLaw(), + logλ_bounds = (log(10), log(1e7)), + max_iterations = 50, + tolerance = 1e-10) + +Solve for slope parameter λ given a fixed shape parameter μ (three-moment). + +For three-moment ice, μ is determined from the Z/N constraint, so this +function finds λ that satisfies the L/N constraint at that μ. + +# Arguments +- `L_ice`: Ice mass concentration [kg/m³] +- `N_ice`: Ice number concentration [1/m³] +- `Z_ice`: Ice sixth moment [m⁶/m³] (used for initial guess) +- `rime_fraction`: Mass fraction of rime [-] +- `rime_density`: Density of rime [kg/m³] +- `μ`: Shape parameter (determined from three-moment solver) + +# Returns +- `logλ`: Log of slope parameter +""" +function solve_lambda(L_ice, N_ice, Z_ice, rime_fraction, rime_density, μ; + mass = IceMassPowerLaw(), + logλ_bounds = (log(10), log(1e7)), + max_iterations = 50, + tolerance = 1e-10, + kwargs...) + + FT = typeof(L_ice) + if L_ice <= 0 || N_ice <= 0 + # No ice mass or number: return upper bound to avoid unphysical λ = 0. + return FT(logλ_bounds[2]) + end + + target = log(L_ice) - log(N_ice) + + function f(logλ) + log_L_over_N₀ = log_mass_moment(mass, rime_fraction, rime_density, μ, logλ) + log_N_over_N₀ = log_gamma_moment(μ, logλ) + return (log_L_over_N₀ - log_N_over_N₀) - target + end + + # Use Z/N constraint for initial guess if Z is available + if !iszero(Z_ice) + logλ_guess = log_lambda_from_reflectivity(μ, log(Z_ice) - log(N_ice)) + logλ_guess = clamp(logλ_guess, FT(logλ_bounds[1]), FT(logλ_bounds[2])) + else + logλ_guess = (FT(logλ_bounds[1]) + FT(logλ_bounds[2])) / 2 + end + + # Secant method starting from Z/N guess + x₀ = FT(logλ_bounds[1]) + x₁ = logλ_guess + f₀, f₁ = f(x₀), f(x₁) + + for _ in 1:max_iterations + denom = f₁ - f₀ + abs(denom) < eps(FT) && return x₁ + + Δx = f₁ * (x₁ - x₀) / denom + x₂ = clamp(x₁ - Δx, FT(logλ_bounds[1]), FT(logλ_bounds[2])) + + abs(Δx) < tolerance * abs(x₁) && return x₂ + + x₀, f₀ = x₁, f₁ + x₁, f₁ = x₂, f(x₂) + end + + return x₁ +end + +""" + intercept_parameter(N_ice, μ, logλ) + +Compute N₀ from the normalization: N = N₀ × ∫ D^μ exp(-λD) dD. +""" +function intercept_parameter(N_ice, μ, logλ) + log_N_over_N₀ = log_gamma_moment(μ, logλ) + return N_ice / exp(log_N_over_N₀) +end + +""" + log_intercept_parameter(N_ice, μ, logλ) + +Compute log(N₀) from normalization. +""" +function log_intercept_parameter(N_ice, μ, logλ) + return log(N_ice) - log_gamma_moment(μ, logλ) +end + +""" + DiameterBounds + +Physical bounds on ice particle diameters for the lambda solver. +See [`DiameterBounds()`](@ref) constructor. +""" +struct DiameterBounds{FT} + D_min :: FT + D_max :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct diameter bounds for the lambda solver. + +The P3 scheme constrains the size distribution such that the mean diameter +remains within physical limits. This prevents unphysical distributions with +extremely small or large particles. + +For a gamma distribution N'(D) = N₀ D^μ exp(-λD), the mean diameter is: + D_mean = (μ + 1) / λ + +To enforce D_min ≤ D_mean ≤ D_max: + (μ + 1) / D_max ≤ λ ≤ (μ + 1) / D_min + +# Keyword Arguments + +- `D_min`: Minimum mean diameter [m], default 2 μm +- `D_max`: Maximum mean diameter [m], default 40 mm + +# Example + +```julia +bounds = DiameterBounds(; D_min=5e-6, D_max=20e-3) # 5 μm to 20 mm +``` +""" +function DiameterBounds(FT = Float64; D_min = FT(2e-6), D_max = FT(40e-3)) + return DiameterBounds(FT(D_min), FT(D_max)) +end + +""" + lambda_bounds_from_diameter(μ, bounds::DiameterBounds) + +Compute λ bounds from diameter bounds for a given shape parameter μ. + +For D_mean = (μ + 1) / λ: +- λ_min = (μ + 1) / D_max +- λ_max = (μ + 1) / D_min + +Returns (λ_min, λ_max). +""" +@inline function lambda_bounds_from_diameter(μ, bounds::DiameterBounds) + FT = typeof(μ) + λ_min = (μ + 1) / bounds.D_max + λ_max = (μ + 1) / bounds.D_min + return (λ_min, λ_max) +end + +""" + enforce_diameter_bounds(λ, μ, bounds::DiameterBounds) + +Clamp λ to ensure the mean diameter stays within physical bounds. + +Returns the clamped λ value. +""" +@inline function enforce_diameter_bounds(λ, μ, bounds::DiameterBounds) + (λ_min, λ_max) = lambda_bounds_from_diameter(μ, bounds) + return clamp(λ, λ_min, λ_max) +end + +""" + IceDistributionParameters + +Result of [`distribution_parameters`](@ref). Fields: `N₀`, `λ`, `μ`. +""" +struct IceDistributionParameters{FT} + N₀ :: FT + λ :: FT + μ :: FT +end + +""" +$(TYPEDSIGNATURES) + +Solve for gamma size distribution parameters from two prognostic moments (L, N). + +This is the two-moment closure for P3: given the prognostic ice mass ``L`` and +number ``N`` concentrations, plus the predicted rime properties, compute +the complete gamma distribution: + +```math +N'(D) = N₀ D^μ e^{-λD} +``` + +The solution proceeds in three steps: + +1. **Solve for λ**: Secant method finds the slope parameter satisfying + the L/N ratio constraint with piecewise m(D) +2. **Compute μ**: Shape parameter from μ-λ relationship +3. **Compute N₀**: Intercept from number normalization + +# Arguments + +- `L_ice`: Ice mass concentration [kg/m³] +- `N_ice`: Ice number concentration [1/m³] +- `rime_fraction`: Mass fraction of rime [-] (0 = unrimed, 1 = fully rimed) +- `rime_density`: Density of the rime layer [kg/m³] + +# Keyword Arguments + +- `mass`: Power law parameters (default: `IceMassPowerLaw()`) +- `closure`: Two-moment closure (default: `P3Closure()`) + +# Returns + +[`IceDistributionParameters`](@ref) with fields `N₀`, `λ`, `μ`. + +# Example + +```julia +using Breeze.Microphysics.PredictedParticleProperties + +# Typical ice cloud conditions +L_ice = 1e-4 # 0.1 g/m³ +N_ice = 1e5 # 100,000 particles/m³ + +params = distribution_parameters(L_ice, N_ice, 0.0, 400.0) +# IceDistributionParameters(N₀=..., λ=..., μ=...) +``` + +# References + +See [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Section 2b. +""" +function distribution_parameters(L_ice, N_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = P3Closure(), + diameter_bounds = nothing, + shape_relation = nothing, # deprecated + kwargs...) + FT = typeof(L_ice) + + # Handle deprecated keyword + actual_closure = isnothing(shape_relation) ? closure : shape_relation + + logλ = solve_lambda(L_ice, N_ice, rime_fraction, rime_density; mass, closure=actual_closure, kwargs...) + λ = exp(logλ) + μ = shape_parameter(actual_closure, logλ, L_ice, rime_fraction, rime_density, mass) + + # Enforce diameter bounds if provided + if !isnothing(diameter_bounds) + λ = enforce_diameter_bounds(λ, μ, diameter_bounds) + end + + N₀ = intercept_parameter(N_ice, μ, log(λ)) + + return IceDistributionParameters(N₀, λ, μ) +end + +""" +$(TYPEDSIGNATURES) + +Solve for gamma size distribution parameters from three prognostic moments (L, N, Z). + +This is the three-moment solver for P3: given the prognostic ice mass ``L``, +number ``N``, and sixth moment ``Z`` concentrations, compute the complete +gamma distribution without needing an empirical μ-λ closure: + +```math +N'(D) = N₀ D^μ e^{-λD} +``` + +The solution uses: +1. **Z/N constraint**: Determines λ as a function of μ +2. **L/N constraint**: Used to solve for the correct μ +3. **Normalization**: N₀ from the number integral + +# Advantages of Three-Moment + +- Shape parameter μ evolves physically based on actual size distribution +- Better representation of size sorting during sedimentation +- Improved simulation of hail and large, heavily rimed particles +- No need for empirical μ-λ parameterization + +# Arguments + +- `L_ice`: Ice mass concentration [kg/m³] +- `N_ice`: Ice number concentration [1/m³] +- `Z_ice`: Ice sixth moment / reflectivity [m⁶/m³] +- `rime_fraction`: Mass fraction of rime [-] +- `rime_density`: Density of the rime layer [kg/m³] + +# Keyword Arguments + +- `mass`: Power law parameters (default: `IceMassPowerLaw()`) +- `closure`: Three-moment closure (default: `ThreeMomentClosure()`) + +# Returns + +[`IceDistributionParameters`](@ref) with fields `N₀`, `λ`, `μ`. + +# Example + +```julia +using Breeze.Microphysics.PredictedParticleProperties + +# Ice with reflectivity constraint +L_ice = 1e-4 # 0.1 g/m³ +N_ice = 1e5 # 100,000 particles/m³ +Z_ice = 1e-12 # Sixth moment [m⁶/m³] + +params = distribution_parameters(L_ice, N_ice, Z_ice, 0.0, 400.0) +# IceDistributionParameters(N₀=..., λ=..., μ=...) +``` + +# References + +[Milbrandt et al. (2021)](@cite MilbrandtEtAl2021) introduced three-moment ice, +[Milbrandt et al. (2024)](@cite MilbrandtEtAl2024) refined the approach. +""" +function distribution_parameters(L_ice, N_ice, Z_ice, rime_fraction, rime_density; + mass = IceMassPowerLaw(), + closure = ThreeMomentClosure(), + diameter_bounds = nothing, + kwargs...) + + FT = typeof(L_ice) + + # Handle edge cases + if iszero(N_ice) || iszero(L_ice) + return IceDistributionParameters(zero(FT), zero(FT), zero(FT)) + end + + # If Z is zero or negative, fall back to two-moment with μ at lower bound + if Z_ice ≤ 0 + μ = closure.μmin + logλ = solve_lambda(L_ice, N_ice, Z_ice, rime_fraction, rime_density, μ; mass, kwargs...) + λ = exp(logλ) + + # Enforce diameter bounds if provided + if !isnothing(diameter_bounds) + λ = enforce_diameter_bounds(λ, μ, diameter_bounds) + end + + N₀ = intercept_parameter(N_ice, μ, log(λ)) + return IceDistributionParameters(N₀, λ, μ) + end + + # Solve for μ using three-moment constraint + μ = solve_shape_parameter(L_ice, N_ice, Z_ice, rime_fraction, rime_density; mass, closure, kwargs...) + + # Solve for λ at this μ + logλ = solve_lambda(L_ice, N_ice, Z_ice, rime_fraction, rime_density, μ; mass, kwargs...) + λ = exp(logλ) + + # Enforce diameter bounds if provided + if !isnothing(diameter_bounds) + λ = enforce_diameter_bounds(λ, μ, diameter_bounds) + end + + # Compute N₀ from normalization + N₀ = intercept_parameter(N_ice, μ, log(λ)) + + return IceDistributionParameters(N₀, λ, μ) +end diff --git a/src/Microphysics/PredictedParticleProperties/multi_ice_category.jl b/src/Microphysics/PredictedParticleProperties/multi_ice_category.jl new file mode 100644 index 000000000..fe3e77ec2 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/multi_ice_category.jl @@ -0,0 +1,177 @@ +##### +##### Multi-Ice Category Support +##### +##### Extension of P3 to support multiple free ice categories +##### per Milbrandt and Morrison (2016). +##### + +export MultiIceCategory + +""" + MultiIceCategory{N, ICE} + +Container for multiple ice categories in P3-MC (multi-category P3). + +Following [Milbrandt and Morrison (2016)](@cite MilbrandtMorrison2016), each +ice category evolves independently with its own rime fraction, rime density, +liquid fraction, and 3-moment ice state. Categories interact through +ice-ice collection (aggregation between categories). + +# Type Parameters +- `N`: Number of ice categories (compile-time constant) +- `ICE`: Type of individual ice property containers + +# Fields +- `categories`: NTuple of N `IceProperties` instances, one per category + +# Prognostic Fields per Category + +For a simulation with N ice categories, the prognostic fields are: + +| Field | Description | +|-------|-------------| +| `ρqⁱ_n` | Ice mass for category n | +| `ρnⁱ_n` | Ice number for category n | +| `ρqᶠ_n` | Rime mass for category n | +| `ρbᶠ_n` | Rime volume for category n | +| `ρzⁱ_n` | Ice 6th moment for category n | +| `ρqʷⁱ_n` | Liquid on ice for category n | + +where n = 1, 2, ..., N. + +# References + +[Milbrandt and Morrison (2016)](@cite MilbrandtMorrison2016) Part III: +Multiple ice categories. +""" +struct MultiIceCategory{N, ICE} + categories :: NTuple{N, ICE} +end + +""" +$(TYPEDSIGNATURES) + +Construct a multi-category ice container with N identical ice categories. + +# Arguments +- `n_categories`: Number of ice categories (default 2) +- `FT`: Floating point type (default Float64) + +# Example + +```julia +multi_ice = MultiIceCategory(3, Float64) # 3 ice categories +``` +""" +function MultiIceCategory(n_categories::Int = 2, FT::Type{<:AbstractFloat} = Float64) + categories = ntuple(_ -> IceProperties(FT), n_categories) + return MultiIceCategory(categories) +end + +Base.length(::MultiIceCategory{N}) where N = N +Base.getindex(mic::MultiIceCategory, i::Int) = mic.categories[i] + +Base.summary(::MultiIceCategory{N}) where N = "MultiIceCategory{$N}" + +function Base.show(io::IO, mic::MultiIceCategory{N}) where N + print(io, summary(mic), " with ", N, " ice categories") +end + +##### +##### Prognostic field names for multi-category ice +##### + +""" +$(TYPEDSIGNATURES) + +Return prognostic field names for multi-category ice. + +Generates suffixed field names for each category: `:ρqⁱ_1`, `:ρqⁱ_2`, etc. +""" +function multi_category_ice_field_names(n_categories::Int) + names = Symbol[] + for i in 1:n_categories + push!(names, Symbol("ρqⁱ_$i")) # Ice mass + push!(names, Symbol("ρnⁱ_$i")) # Ice number + push!(names, Symbol("ρqᶠ_$i")) # Rime mass + push!(names, Symbol("ρbᶠ_$i")) # Rime volume + push!(names, Symbol("ρzⁱ_$i")) # Sixth moment + push!(names, Symbol("ρqʷⁱ_$i")) # Liquid on ice + end + return Tuple(names) +end + +multi_category_ice_field_names(::MultiIceCategory{N}) where N = multi_category_ice_field_names(N) + +##### +##### Inter-category interactions +##### + +""" + inter_category_collection(p3, cat1_state, cat2_state, T) + +Compute ice-ice collection rate between two ice categories. + +Following [Milbrandt and Morrison (2016)](@cite MilbrandtMorrison2016), +particles from different categories can collide and aggregate when +they have similar properties. The collection efficiency depends on +temperature (following aggregation efficiency) and the relative +velocities of particles in each category. + +# Arguments +- `p3`: P3 microphysics scheme +- `cat1_state`: State of first ice category (NamedTuple with qⁱ, nⁱ, Fᶠ, etc.) +- `cat2_state`: State of second ice category +- `T`: Temperature [K] + +# Returns +- NamedTuple with mass and number transfer rates between categories +""" +@inline function inter_category_collection(p3, cat1_state, cat2_state, T) + FT = typeof(T) + prp = p3.process_rates + + # Temperature-dependent collection efficiency (same as aggregation) + T_low = prp.aggregation_efficiency_temperature_low + T_high = prp.aggregation_efficiency_temperature_high + E_max = prp.aggregation_efficiency_max + + # Linear interpolation of efficiency with temperature + T_clamped = clamp(T, T_low, T_high) + E = FT(0.1) + (E_max - FT(0.1)) * (T_clamped - T_low) / (T_high - T_low) + + # Collection requires particles from both categories + qⁱ₁ = clamp_positive(cat1_state.qⁱ) + qⁱ₂ = clamp_positive(cat2_state.qⁱ) + nⁱ₁ = clamp_positive(cat1_state.nⁱ) + nⁱ₂ = clamp_positive(cat2_state.nⁱ) + + # Simplified collection rate (proportional to product of concentrations) + # Full implementation would compute differential fall speeds and collection kernel + τ_agg = prp.aggregation_timescale + + # Mass transfer: proportional to product of ice contents + q_product = sqrt(qⁱ₁ * qⁱ₂) + mass_rate = E * q_product / τ_agg + + # Number transfer: smaller particles absorbed by larger category + # Transfer number from category with smaller mean mass + m̄₁ = safe_divide(qⁱ₁, nⁱ₁, FT(1e-12)) + m̄₂ = safe_divide(qⁱ₂, nⁱ₂, FT(1e-12)) + + # Category 1 absorbs 2 if m̄₁ > m̄₂ + cat1_larger = m̄₁ > m̄₂ + + # Number rate (always reduces total number) + n_product = sqrt(nⁱ₁ * nⁱ₂) + number_rate = E * n_product / τ_agg + + return ( + mass_rate = mass_rate, + number_rate = number_rate, + mass_to_cat1 = ifelse(cat1_larger, mass_rate, zero(FT)), + mass_to_cat2 = ifelse(cat1_larger, zero(FT), mass_rate), + number_loss_cat1 = ifelse(cat1_larger, zero(FT), number_rate), + number_loss_cat2 = ifelse(cat1_larger, number_rate, zero(FT)) + ) +end diff --git a/src/Microphysics/PredictedParticleProperties/p3_interface.jl b/src/Microphysics/PredictedParticleProperties/p3_interface.jl new file mode 100644 index 000000000..fc5a68e1d --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/p3_interface.jl @@ -0,0 +1,532 @@ +##### +##### Microphysics interface implementation for P3 +##### +##### These functions integrate the P3 scheme with AtmosphereModel, +##### allowing it to be used as a drop-in microphysics scheme. +##### +##### This file follows the MicrophysicalState abstraction pattern: +##### - P3MicrophysicalState encapsulates local microphysical variables +##### - Gridless microphysical_state(p3, ρ, μ, 𝒰) builds the state +##### - State-based microphysical_tendency(p3, name, ρ, ℳ, 𝒰, constants) computes tendencies +##### + +using Oceananigans: CenterField +using DocStringExtensions: TYPEDSIGNATURES + +using Breeze.AtmosphereModels: AtmosphereModels as AM +using Breeze.AtmosphereModels: AbstractMicrophysicalState + +using Breeze.Thermodynamics: MoistureMassFractions + +const P3 = PredictedParticlePropertiesMicrophysics + +##### +##### P3MicrophysicalState +##### + +""" + P3MicrophysicalState{FT} <: AbstractMicrophysicalState{FT} + +Microphysical state for P3 (Predicted Particle Properties) microphysics. + +Contains the local mixing ratios and number concentrations needed to compute +tendencies for cloud liquid, rain, ice, rime, and predicted liquid fraction. + +# Fields +$(TYPEDFIELDS) +""" +struct P3MicrophysicalState{FT} <: AbstractMicrophysicalState{FT} + "Cloud liquid mixing ratio [kg/kg]" + qᶜˡ :: FT + "Rain mixing ratio [kg/kg]" + qʳ :: FT + "Rain number concentration [1/kg]" + nʳ :: FT + "Ice mixing ratio [kg/kg]" + qⁱ :: FT + "Ice number concentration [1/kg]" + nⁱ :: FT + "Rime mass mixing ratio [kg/kg]" + qᶠ :: FT + "Rime volume [m³/kg]" + bᶠ :: FT + "Ice sixth moment [m⁶/kg]" + zⁱ :: FT + "Liquid water on ice mixing ratio [kg/kg]" + qʷⁱ :: FT +end + +##### +##### Prognostic field names +##### + +""" +$(TYPEDSIGNATURES) + +Return prognostic field names for the P3 scheme. + +P3 v5.5 with 3-moment ice and predicted liquid fraction has 9 prognostic fields: +- Cloud: ρqᶜˡ (number is prescribed, not prognostic) +- Rain: ρqʳ, ρnʳ +- Ice: ρqⁱ, ρnⁱ, ρqᶠ, ρbᶠ, ρzⁱ, ρqʷⁱ +""" +function AM.prognostic_field_names(::P3) + # Cloud number is prescribed (not prognostic) in this implementation + cloud_names = (:ρqᶜˡ,) + rain_names = (:ρqʳ, :ρnʳ) + ice_names = (:ρqⁱ, :ρnⁱ, :ρqᶠ, :ρbᶠ, :ρzⁱ, :ρqʷⁱ) + + return tuple(cloud_names..., rain_names..., ice_names...) +end + +##### +##### Specific humidity +##### + +""" +$(TYPEDSIGNATURES) + +Return the vapor specific humidity field for P3 microphysics. + +For P3, vapor is diagnosed from total moisture minus all condensates: +qᵛ = qᵗ - qᶜˡ - qʳ - qⁱ - qʷⁱ +""" +function AM.specific_humidity(::P3, model) + # P3 stores vapor diagnostically + return model.microphysical_fields.qᵛ +end + +##### +##### Materialize microphysical fields +##### + +""" +$(TYPEDSIGNATURES) + +Create prognostic and diagnostic fields for P3 microphysics. + +The P3 scheme requires the following fields on `grid`: + +**Prognostic (density-weighted):** +- `ρqᶜˡ`: Cloud liquid mass density +- `ρqʳ`, `ρnʳ`: Rain mass and number densities +- `ρqⁱ`, `ρnⁱ`: Ice mass and number densities +- `ρqᶠ`, `ρbᶠ`: Rime mass and volume densities +- `ρzⁱ`: Ice sixth moment (reflectivity) density +- `ρqʷⁱ`: Liquid water on ice mass density + +**Diagnostic:** +- `qᵛ`: Vapor specific humidity (computed from total moisture) +""" +function AM.materialize_microphysical_fields(::P3, grid, bcs) + # Create all prognostic fields + ρqᶜˡ = CenterField(grid) # Cloud liquid + ρqʳ = CenterField(grid) # Rain mass + ρnʳ = CenterField(grid) # Rain number + ρqⁱ = CenterField(grid) # Ice mass + ρnⁱ = CenterField(grid) # Ice number + ρqᶠ = CenterField(grid) # Rime mass + ρbᶠ = CenterField(grid) # Rime volume + ρzⁱ = CenterField(grid) # Ice 6th moment + ρqʷⁱ = CenterField(grid) # Liquid on ice + + # Diagnostic field for vapor + qᵛ = CenterField(grid) + + return (; ρqᶜˡ, ρqʳ, ρnʳ, ρqⁱ, ρnⁱ, ρqᶠ, ρbᶠ, ρzⁱ, ρqʷⁱ, qᵛ) +end + +##### +##### Gridless MicrophysicalState construction +##### +# +# P3 is a non-equilibrium scheme: all condensate comes from prognostic fields μ. + +""" +$(TYPEDSIGNATURES) + +Build a [`P3MicrophysicalState`](@ref) from density-weighted prognostic variables. + +P3 is a non-equilibrium scheme, so all cloud and precipitation variables come +from the prognostic fields `μ`, not from the thermodynamic state `𝒰`. +""" +@inline function AM.microphysical_state(::P3, ρ, μ, 𝒰) + qᶜˡ = μ.ρqᶜˡ / ρ + qʳ = μ.ρqʳ / ρ + nʳ = μ.ρnʳ / ρ + qⁱ = μ.ρqⁱ / ρ + nⁱ = μ.ρnⁱ / ρ + qᶠ = μ.ρqᶠ / ρ + bᶠ = μ.ρbᶠ / ρ + zⁱ = μ.ρzⁱ / ρ + qʷⁱ = μ.ρqʷⁱ / ρ + return P3MicrophysicalState(qᶜˡ, qʳ, nʳ, qⁱ, nⁱ, qᶠ, bᶠ, zⁱ, qʷⁱ) +end + +##### +##### Update microphysical auxiliary fields +##### + +""" +$(TYPEDSIGNATURES) + +Update diagnostic microphysical fields after state update. + +For P3, we compute vapor as the residual: qᵛ = qᵗ - qᶜˡ - qʳ - qⁱ - qʷⁱ +""" +@inline function AM.update_microphysical_auxiliaries!(μ, i, j, k, grid, ::P3, ℳ::P3MicrophysicalState, ρ, 𝒰, constants) + # Get total moisture from thermodynamic state + q = 𝒰.moisture_mass_fractions + qᵗ = q.vapor + q.liquid + q.ice + + # Vapor is residual (total - all condensates) + qᵛ = max(0, qᵗ - ℳ.qᶜˡ - ℳ.qʳ - ℳ.qⁱ - ℳ.qʷⁱ) + + @inbounds μ.qᵛ[i, j, k] = qᵛ + return nothing +end + +##### +##### Moisture fractions (state-based) +##### + +""" +$(TYPEDSIGNATURES) + +Compute moisture mass fractions from P3 microphysical state. + +Returns `MoistureMassFractions` with vapor, liquid (cloud + rain + liquid on ice), +and ice components. +""" +@inline function AM.moisture_fractions(::P3, ℳ::P3MicrophysicalState, qᵗ) + # Total liquid = cloud + rain + liquid on ice + qˡ = ℳ.qᶜˡ + ℳ.qʳ + ℳ.qʷⁱ + + # Ice (frozen fraction) + qⁱ = ℳ.qⁱ + + # Vapor is residual (ensuring non-negative) + qᵛ = max(0, qᵗ - qˡ - qⁱ) + + return MoistureMassFractions(qᵛ, qˡ, qⁱ) +end + +##### +##### Microphysical velocities (sedimentation) +##### + +""" +$(TYPEDSIGNATURES) + +Return terminal velocity for precipitating species. + +P3 has separate fall speeds for rain and ice particles. +Returns a NamedTuple with `(u=0, v=0, w=-vₜ)` where `vₜ` is the terminal velocity. + +For mass fields (ρqʳ, ρqⁱ, ρqᶠ, ρqʷⁱ), uses mass-weighted velocity. +For number fields (ρnʳ, ρnⁱ), uses number-weighted velocity. +For reflectivity (ρzⁱ), uses reflectivity-weighted velocity. +""" +@inline AM.microphysical_velocities(p3::P3, μ, name) = nothing # Default: no sedimentation + +# Rain mass: mass-weighted fall speed +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρqʳ}) + return RainMassSedimentationVelocity(p3, μ) +end + +# Rain number: number-weighted fall speed +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρnʳ}) + return RainNumberSedimentationVelocity(p3, μ) +end + +# Ice mass: mass-weighted fall speed +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρqⁱ}) + return IceMassSedimentationVelocity(p3, μ) +end + +# Ice number: number-weighted fall speed +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρnⁱ}) + return IceNumberSedimentationVelocity(p3, μ) +end + +# Rime mass: same as ice mass (rime falls with ice) +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρqᶠ}) + return IceMassSedimentationVelocity(p3, μ) +end + +# Rime volume: same as ice mass +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρbᶠ}) + return IceMassSedimentationVelocity(p3, μ) +end + +# Ice reflectivity: reflectivity-weighted fall speed +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρzⁱ}) + return IceReflectivitySedimentationVelocity(p3, μ) +end + +# Liquid on ice: same as ice mass +@inline function AM.microphysical_velocities(p3::P3, μ, ::Val{:ρqʷⁱ}) + return IceMassSedimentationVelocity(p3, μ) +end + +##### +##### Sedimentation velocity types +##### +##### These are callable structs that compute terminal velocities at (i, j, k). +##### + +""" +Callable struct for rain mass sedimentation velocity. +""" +struct RainMassSedimentationVelocity{P, M} + p3 :: P + microphysical_fields :: M +end + +@inline function (v::RainMassSedimentationVelocity)(i, j, k, grid, ρ) + FT = eltype(grid) + μ = v.microphysical_fields + p3 = v.p3 + + @inbounds begin + qʳ = μ.ρqʳ[i, j, k] / ρ + nʳ = μ.ρnʳ[i, j, k] / ρ + end + + vₜ = rain_terminal_velocity_mass_weighted(p3, qʳ, nʳ, ρ) + + return (u = zero(FT), v = zero(FT), w = -vₜ) +end + +""" +Callable struct for rain number sedimentation velocity. +""" +struct RainNumberSedimentationVelocity{P, M} + p3 :: P + microphysical_fields :: M +end + +@inline function (v::RainNumberSedimentationVelocity)(i, j, k, grid, ρ) + FT = eltype(grid) + μ = v.microphysical_fields + p3 = v.p3 + + @inbounds begin + qʳ = μ.ρqʳ[i, j, k] / ρ + nʳ = μ.ρnʳ[i, j, k] / ρ + end + + vₜ = rain_terminal_velocity_number_weighted(p3, qʳ, nʳ, ρ) + + return (u = zero(FT), v = zero(FT), w = -vₜ) +end + +""" +Callable struct for ice mass sedimentation velocity. +""" +struct IceMassSedimentationVelocity{P, M} + p3 :: P + microphysical_fields :: M +end + +@inline function (v::IceMassSedimentationVelocity)(i, j, k, grid, ρ) + FT = eltype(grid) + μ = v.microphysical_fields + p3 = v.p3 + + @inbounds begin + qⁱ = μ.ρqⁱ[i, j, k] / ρ + nⁱ = μ.ρnⁱ[i, j, k] / ρ + qᶠ = μ.ρqᶠ[i, j, k] / ρ + bᶠ = μ.ρbᶠ[i, j, k] / ρ + end + + Fᶠ = safe_divide(qᶠ, qⁱ, zero(FT)) + ρᶠ = safe_divide(qᶠ, bᶠ, FT(400)) + + vₜ = ice_terminal_velocity_mass_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ) + + return (u = zero(FT), v = zero(FT), w = -vₜ) +end + +""" +Callable struct for ice number sedimentation velocity. +""" +struct IceNumberSedimentationVelocity{P, M} + p3 :: P + microphysical_fields :: M +end + +@inline function (v::IceNumberSedimentationVelocity)(i, j, k, grid, ρ) + FT = eltype(grid) + μ = v.microphysical_fields + p3 = v.p3 + + @inbounds begin + qⁱ = μ.ρqⁱ[i, j, k] / ρ + nⁱ = μ.ρnⁱ[i, j, k] / ρ + qᶠ = μ.ρqᶠ[i, j, k] / ρ + bᶠ = μ.ρbᶠ[i, j, k] / ρ + end + + Fᶠ = safe_divide(qᶠ, qⁱ, zero(FT)) + ρᶠ = safe_divide(qᶠ, bᶠ, FT(400)) + + vₜ = ice_terminal_velocity_number_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ) + + return (u = zero(FT), v = zero(FT), w = -vₜ) +end + +""" +Callable struct for ice reflectivity sedimentation velocity. +""" +struct IceReflectivitySedimentationVelocity{P, M} + p3 :: P + microphysical_fields :: M +end + +@inline function (v::IceReflectivitySedimentationVelocity)(i, j, k, grid, ρ) + FT = eltype(grid) + μ = v.microphysical_fields + p3 = v.p3 + + @inbounds begin + qⁱ = μ.ρqⁱ[i, j, k] / ρ + nⁱ = μ.ρnⁱ[i, j, k] / ρ + zⁱ = μ.ρzⁱ[i, j, k] / ρ + qᶠ = μ.ρqᶠ[i, j, k] / ρ + bᶠ = μ.ρbᶠ[i, j, k] / ρ + end + + Fᶠ = safe_divide(qᶠ, qⁱ, zero(FT)) + ρᶠ = safe_divide(qᶠ, bᶠ, FT(400)) + + vₜ = ice_terminal_velocity_reflectivity_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ) + + return (u = zero(FT), v = zero(FT), w = -vₜ) +end + +##### +##### Microphysical tendencies (state-based) +##### +# +# The new interface uses state-based tendencies: microphysical_tendency(p3, name, ρ, ℳ, 𝒰, constants) +# where ℳ is the P3MicrophysicalState. + +# Helper to compute P3 rates and extract ice properties from ℳ +@inline function p3_rates_and_properties(p3, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + FT = typeof(ρ) + + # Compute all process rates from microphysical state ℳ and thermodynamic state 𝒰 + rates = compute_p3_process_rates(p3, ρ, ℳ, 𝒰, constants) + + Fᶠ = safe_divide(ℳ.qᶠ, ℳ.qⁱ, zero(FT)) + ρᶠ = safe_divide(ℳ.qᶠ, ℳ.bᶠ, FT(400)) + + return rates, ℳ.qⁱ, ℳ.nⁱ, ℳ.zⁱ, Fᶠ, ρᶠ +end + +""" +Cloud liquid tendency: loses mass to autoconversion, accretion, and riming. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρqᶜˡ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρqᶜˡ(rates, ρ) +end + +""" +Rain mass tendency: gains from autoconversion, accretion, melting, shedding; loses to evaporation, riming. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρqʳ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρqʳ(rates, ρ) +end + +""" +Rain number tendency: gains from autoconversion, melting, shedding; loses to self-collection, riming. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρnʳ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, qⁱ, nⁱ, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρnʳ(rates, ρ, nⁱ, qⁱ) +end + +""" +Ice mass tendency: gains from deposition, riming, refreezing; loses to melting. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρqⁱ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρqⁱ(rates, ρ) +end + +""" +Ice number tendency: loses from melting and aggregation. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρnⁱ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρnⁱ(rates, ρ) +end + +""" +Rime mass tendency: gains from cloud/rain riming, refreezing; loses proportionally with melting. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρqᶠ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, Fᶠ, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρqᶠ(rates, ρ, Fᶠ) +end + +""" +Rime volume tendency: gains from new rime; loses with melting. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρbᶠ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, Fᶠ, ρᶠ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρbᶠ(rates, ρ, Fᶠ, ρᶠ) +end + +""" +Ice sixth moment tendency: changes with deposition, melting, riming, and nucleation. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρzⁱ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, qⁱ, nⁱ, zⁱ, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρzⁱ(rates, ρ, qⁱ, nⁱ, zⁱ) +end + +""" +Liquid on ice tendency: loses from shedding and refreezing. +""" +@inline function AM.microphysical_tendency(p3::P3, ::Val{:ρqʷⁱ}, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) + rates, _, _, _, _, _ = p3_rates_and_properties(p3, ρ, ℳ, 𝒰, constants) + return tendency_ρqʷⁱ(rates, ρ) +end + +# Fallback for any unhandled field names - return zero tendency +@inline AM.microphysical_tendency(::P3, name, ρ, ℳ::P3MicrophysicalState, 𝒰, constants) = zero(ρ) + +##### +##### Thermodynamic state adjustment +##### + +""" +$(TYPEDSIGNATURES) + +Apply saturation adjustment for P3. + +P3 is a non-equilibrium scheme - cloud formation and dissipation are handled +by explicit process rates, not instantaneous saturation adjustment. +Therefore, this function returns the state unchanged. +""" +@inline AM.maybe_adjust_thermodynamic_state(𝒰, ::P3, qᵗ, constants) = 𝒰 + +##### +##### Model update +##### + +""" +$(TYPEDSIGNATURES) + +Apply P3 model update during state update phase. + +Currently does nothing - this is where substepping or implicit updates would go. +""" +function AM.microphysics_model_update!(::P3, model) + return nothing +end diff --git a/src/Microphysics/PredictedParticleProperties/p3_scheme.jl b/src/Microphysics/PredictedParticleProperties/p3_scheme.jl new file mode 100644 index 000000000..0884294af --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/p3_scheme.jl @@ -0,0 +1,150 @@ +##### +##### Predicted Particle Properties (P3) Microphysics Scheme +##### +##### Main type combining ice, rain, and cloud properties. +##### + +""" + PredictedParticlePropertiesMicrophysics + +The Predicted Particle Properties (P3) microphysics scheme. See the constructor +[`PredictedParticlePropertiesMicrophysics()`](@ref) for usage and documentation. +""" +struct PredictedParticlePropertiesMicrophysics{FT, ICE, RAIN, CLOUD, PRP, BC} + # Shared physical constants + water_density :: FT + # Top-level thresholds + minimum_mass_mixing_ratio :: FT + minimum_number_mixing_ratio :: FT + # Property containers + ice :: ICE + rain :: RAIN + cloud :: CLOUD + # Process rate parameters + process_rates :: PRP + # Boundary condition + precipitation_boundary_condition :: BC +end + +""" +$(TYPEDSIGNATURES) + +Construct the Predicted Particle Properties (P3) microphysics scheme. + +P3 is a bulk microphysics scheme that uses a **single ice category** with +continuously predicted properties, rather than discrete categories like +cloud ice, snow, graupel, and hail. As ice particles grow and rime, their +properties evolve smoothly without artificial category conversions. + +# Physical Concept + +Traditional schemes force growing ice particles through discrete transitions: + + cloud ice → snow → graupel → hail + +Each transition requires ad-hoc conversion parameters. P3 instead tracks: + +- **Rime fraction** ``Fᶠ``: What fraction of mass is rime? +- **Rime density** ``ρᶠ``: How dense is the rime layer? +- **Liquid fraction** ``Fˡ``: Liquid water coating from partial melting + +From these, particle characteristics (mass, fall speed, collection efficiency) +are diagnosed continuously. + +# Three-Moment Ice + +P3 v5.5 carries three prognostic moments for ice particles: +1. **Mass** (``qⁱ``): Total ice mass +2. **Number** (``nⁱ``): Ice particle number concentration +3. **Reflectivity** (``zⁱ``): Sixth moment of size distribution + +The third moment improves representation of precipitation-sized particles +and enables better simulation of radar reflectivity. + +# Prognostic Variables + +The scheme tracks 9 prognostic densities: + +| Variable | Description | +|----------|-------------| +| ``ρqᶜˡ`` | Cloud liquid mass | +| ``ρqʳ``, ``ρnʳ`` | Rain mass and number | +| ``ρqⁱ``, ``ρnⁱ`` | Ice mass and number | +| ``ρqᶠ``, ``ρbᶠ`` | Rime mass and volume | +| ``ρzⁱ`` | Ice 6th moment (reflectivity) | +| ``ρqʷⁱ`` | Liquid water on ice | + +# Keyword Arguments + +- `water_density`: Liquid water density [kg/m³] (default 1000) +- `precipitation_boundary_condition`: Boundary condition for surface precipitation + (default `nothing` = open boundary, precipitation exits domain) + +# Tabulation + +For faster process rate calculations, use [`tabulate`](@ref) to pre-compute +lookup tables: + +```julia +using Oceananigans +p3 = PredictedParticlePropertiesMicrophysics() +p3_fast = tabulate(p3, CPU()) # Pre-compute lookup tables +``` + +# Example + +```julia +using Breeze + +# Create P3 scheme with default parameters +microphysics = PredictedParticlePropertiesMicrophysics() + +# Get prognostic field names for model setup +fields = prognostic_field_names(microphysics) +``` + +# References + +This implementation follows P3 v5.5 from the +[P3-microphysics repository](https://github.com/P3-microphysics/P3-microphysics). + +Key papers describing P3: +- [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization): Original scheme +- [Milbrandt et al. (2021)](@cite MilbrandtEtAl2021): Three-moment ice +- [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction): Predicted liquid fraction +- [Morrison et al. (2025)](@cite Morrison2025complete3moment): Complete implementation + +See also the [P3 documentation](@ref p3_overview) for detailed physics. +""" +function PredictedParticlePropertiesMicrophysics(FT::Type{<:AbstractFloat} = Float64; + water_density = 1000, + precipitation_boundary_condition = nothing) + return PredictedParticlePropertiesMicrophysics( + FT(water_density), + FT(1e-14), # minimum_mass_mixing_ratio [kg/kg] + FT(1e-16), # minimum_number_mixing_ratio [1/kg] + IceProperties(FT), + RainProperties(FT), + CloudDropletProperties(FT), + ProcessRateParameters(FT), + precipitation_boundary_condition + ) +end + +# Shorthand alias +const P3Microphysics = PredictedParticlePropertiesMicrophysics + +Base.summary(::PredictedParticlePropertiesMicrophysics) = "PredictedParticlePropertiesMicrophysics" + +function Base.show(io::IO, p3::PredictedParticlePropertiesMicrophysics) + print(io, summary(p3), '\n') + print(io, "├── ρʷ: ", p3.water_density, " kg/m³\n") + print(io, "├── qmin: ", p3.minimum_mass_mixing_ratio, " kg/kg\n") + print(io, "├── ice: ", summary(p3.ice), "\n") + print(io, "├── rain: ", summary(p3.rain), "\n") + print(io, "├── cloud: ", summary(p3.cloud), "\n") + print(io, "└── process_rates: ", summary(p3.process_rates)) +end + +# Note: prognostic_field_names is implemented in p3_interface.jl to extend +# AtmosphereModels.prognostic_field_names diff --git a/src/Microphysics/PredictedParticleProperties/process_rate_parameters.jl b/src/Microphysics/PredictedParticleProperties/process_rate_parameters.jl new file mode 100644 index 000000000..96fb7bbf3 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/process_rate_parameters.jl @@ -0,0 +1,323 @@ +##### +##### Process Rate Parameters +##### +##### Container for all P3 microphysical process rate parameters. +##### These parameters control timescales, efficiencies, and thresholds. +##### + +export ProcessRateParameters + +""" + ProcessRateParameters + +Parameters for P3 microphysical process rates. +See [`ProcessRateParameters()`](@ref) constructor for usage. +""" +struct ProcessRateParameters{FT} + # Physical constants + liquid_water_density :: FT # ρʷ [kg/m³] + pure_ice_density :: FT # ρⁱ [kg/m³] + reference_air_density :: FT # ρ₀ [kg/m³] for fall speed correction + nucleated_ice_mass :: FT # mᵢ₀ [kg], mass of newly nucleated ice crystal + freezing_temperature :: FT # T₀ [K] + + # Rain autoconversion (Khairoutdinov-Kogan 2000) + autoconversion_coefficient :: FT # k₁ [s⁻¹] + autoconversion_exponent_cloud :: FT # α [-] + autoconversion_exponent_droplet :: FT # β [-] + autoconversion_threshold :: FT # qᶜˡ threshold [kg/kg] + autoconversion_reference_concentration :: FT # Nc reference [1/m³] + + # Rain accretion (Khairoutdinov-Kogan 2000) + accretion_coefficient :: FT # k₂ [s⁻¹] + accretion_exponent :: FT # α [-] + + # Rain self-collection (Seifert-Beheng 2001) + self_collection_coefficient :: FT # k_rr [-] + + # Evaporation/sublimation timescales + rain_evaporation_timescale :: FT # τ_evap [s] + ice_deposition_timescale :: FT # τ_dep [s] + + # Melting + ice_melting_timescale :: FT # τ_melt [s] + + # Ice aggregation + aggregation_efficiency_max :: FT # Eᵢᵢ_max [-] + aggregation_timescale :: FT # τ_agg [s] + aggregation_efficiency_temperature_low :: FT # T below which E=0.1 [K] + aggregation_efficiency_temperature_high :: FT # T above which E=max [K] + aggregation_reference_concentration :: FT # n_ref [1/kg] + + # Cloud riming + cloud_ice_collection_efficiency :: FT # Eᶜⁱ [-] + cloud_riming_timescale :: FT # τ_rim [s] + + # Rain riming + rain_ice_collection_efficiency :: FT # Eʳⁱ [-] + rain_riming_timescale :: FT # τ_rim [s] + + # Rime density bounds + minimum_rime_density :: FT # ρ_rim_min [kg/m³] + maximum_rime_density :: FT # ρ_rim_max [kg/m³] + + # Shedding + shedding_timescale :: FT # τ_shed [s] + maximum_liquid_fraction :: FT # qʷⁱ_max_frac [-] + shed_drop_mass :: FT # m_shed [kg] + + # Refreezing + refreezing_timescale :: FT # τ_frz [s] + + # Deposition nucleation (Cooper 1986) + nucleation_temperature_threshold :: FT # T below which nucleation occurs [K] + nucleation_supersaturation_threshold :: FT # Sⁱ threshold [-] + nucleation_maximum_concentration :: FT # N_max [1/m³] + nucleation_timescale :: FT # τ_nuc [s] + + # Immersion freezing (Bigg 1953) + immersion_freezing_temperature_max :: FT # T_max [K] + immersion_freezing_coefficient :: FT # aimm [-] + immersion_freezing_timescale_cloud :: FT # base timescale for cloud [s] + immersion_freezing_timescale_rain :: FT # base timescale for rain [s] + + # Rime splintering (Hallett-Mossop) + splintering_temperature_low :: FT # T_low [K] + splintering_temperature_high :: FT # T_high [K] + splintering_temperature_peak :: FT # T_peak [K] + splintering_temperature_width :: FT # width [K] + splintering_rate :: FT # splinters per kg rime + + # Rain terminal velocity (power law v = a D^b) + rain_fall_speed_coefficient :: FT # a [m^(1-b)/s] + rain_fall_speed_exponent :: FT # b [-] + rain_diameter_min :: FT # D_min [m] + rain_diameter_max :: FT # D_max [m] + rain_velocity_min :: FT # v_min [m/s] + rain_velocity_max :: FT # v_max [m/s] + + # Ice terminal velocity + ice_fall_speed_coefficient_unrimed :: FT # a for aggregates + ice_fall_speed_exponent_unrimed :: FT # b for aggregates + ice_fall_speed_coefficient_rimed :: FT # a for graupel + ice_fall_speed_exponent_rimed :: FT # b for graupel + ice_small_particle_coefficient :: FT # Stokes regime coefficient + ice_diameter_threshold :: FT # D_threshold [m] + ice_diameter_min :: FT # D_min [m] + ice_diameter_max :: FT # D_max [m] + ice_velocity_min :: FT # v_min [m/s] + ice_velocity_max :: FT # v_max [m/s] + ice_effective_density_unrimed :: FT # ρ_eff for aggregates [kg/m³] + + # Ratio factors for weighted velocities + velocity_ratio_number_to_mass :: FT # Vₙ/Vₘ + velocity_ratio_reflectivity_to_mass :: FT # Vᵤ/Vₘ + + # Initial rain drop mass (for autoconversion number tendency) + initial_rain_drop_mass :: FT # m_rain_init [kg] +end + +""" +$(TYPEDSIGNATURES) + +Construct process rate parameters with default values from P3 literature. + +These parameters control the rates of all microphysical processes: +autoconversion, accretion, aggregation, riming, melting, evaporation, +deposition, nucleation, and sedimentation. + +# Default Sources + +- Autoconversion/accretion: Khairoutdinov and Kogan (2000) +- Self-collection: Seifert and Beheng (2001) +- Aggregation: Morrison and Milbrandt (2015) +- Nucleation: Cooper (1986) +- Freezing: Bigg (1953) +- Splintering: Hallett and Mossop (1974) +- Fall speeds: Mitchell (1996), Seifert and Beheng (2006) + +# Example + +```julia +params = ProcessRateParameters(Float64) +``` + +All parameters are keyword arguments with physically-based defaults. +""" +function ProcessRateParameters(FT::Type{<:AbstractFloat} = Float64; + # Physical constants + liquid_water_density = 1000, + pure_ice_density = 917, + reference_air_density = 1.225, + nucleated_ice_mass = 1e-12, + freezing_temperature = 273.15, + + # Rain autoconversion + autoconversion_coefficient = 2.47e-2, + autoconversion_exponent_cloud = 2.47, + autoconversion_exponent_droplet = -1.79, + autoconversion_threshold = 1e-4, + autoconversion_reference_concentration = 1e8, + + # Rain accretion + accretion_coefficient = 67.0, + accretion_exponent = 1.15, + + # Rain self-collection + self_collection_coefficient = 4.33, + + # Timescales + rain_evaporation_timescale = 10.0, + ice_deposition_timescale = 10.0, + ice_melting_timescale = 60.0, + + # Ice aggregation + aggregation_efficiency_max = 1.0, + aggregation_timescale = 600.0, + aggregation_efficiency_temperature_low = 253.15, + aggregation_efficiency_temperature_high = 268.15, + aggregation_reference_concentration = 1e4, + + # Cloud riming + cloud_ice_collection_efficiency = 1.0, + cloud_riming_timescale = 300.0, + + # Rain riming + rain_ice_collection_efficiency = 1.0, + rain_riming_timescale = 200.0, + + # Rime density + minimum_rime_density = 50.0, + maximum_rime_density = 900.0, + + # Shedding + shedding_timescale = 60.0, + maximum_liquid_fraction = 0.3, + shed_drop_mass = 5.2e-7, + + # Refreezing + refreezing_timescale = 30.0, + + # Deposition nucleation + nucleation_temperature_threshold = 258.15, + nucleation_supersaturation_threshold = 0.05, + nucleation_maximum_concentration = 100e3, + nucleation_timescale = 60.0, + + # Immersion freezing + immersion_freezing_temperature_max = 269.15, + immersion_freezing_coefficient = 0.66, + immersion_freezing_timescale_cloud = 1000.0, + immersion_freezing_timescale_rain = 300.0, + + # Rime splintering + splintering_temperature_low = 265.15, + splintering_temperature_high = 270.15, + splintering_temperature_peak = 268.15, + splintering_temperature_width = 2.5, + splintering_rate = 3.5e8, + + # Rain terminal velocity + rain_fall_speed_coefficient = 842.0, + rain_fall_speed_exponent = 0.8, + rain_diameter_min = 1e-4, + rain_diameter_max = 5e-3, + rain_velocity_min = 0.1, + rain_velocity_max = 15.0, + + # Ice terminal velocity + ice_fall_speed_coefficient_unrimed = 11.7, + ice_fall_speed_exponent_unrimed = 0.41, + ice_fall_speed_coefficient_rimed = 19.3, + ice_fall_speed_exponent_rimed = 0.37, + ice_small_particle_coefficient = 700.0, + ice_diameter_threshold = 100e-6, + ice_diameter_min = 1e-5, + ice_diameter_max = 0.02, + ice_velocity_min = 0.01, + ice_velocity_max = 8.0, + ice_effective_density_unrimed = 100.0, + + # Velocity ratios + velocity_ratio_number_to_mass = 0.6, + velocity_ratio_reflectivity_to_mass = 1.2, + + # Initial rain drop + initial_rain_drop_mass = 5e-10) + + return ProcessRateParameters( + FT(liquid_water_density), + FT(pure_ice_density), + FT(reference_air_density), + FT(nucleated_ice_mass), + FT(freezing_temperature), + FT(autoconversion_coefficient), + FT(autoconversion_exponent_cloud), + FT(autoconversion_exponent_droplet), + FT(autoconversion_threshold), + FT(autoconversion_reference_concentration), + FT(accretion_coefficient), + FT(accretion_exponent), + FT(self_collection_coefficient), + FT(rain_evaporation_timescale), + FT(ice_deposition_timescale), + FT(ice_melting_timescale), + FT(aggregation_efficiency_max), + FT(aggregation_timescale), + FT(aggregation_efficiency_temperature_low), + FT(aggregation_efficiency_temperature_high), + FT(aggregation_reference_concentration), + FT(cloud_ice_collection_efficiency), + FT(cloud_riming_timescale), + FT(rain_ice_collection_efficiency), + FT(rain_riming_timescale), + FT(minimum_rime_density), + FT(maximum_rime_density), + FT(shedding_timescale), + FT(maximum_liquid_fraction), + FT(shed_drop_mass), + FT(refreezing_timescale), + FT(nucleation_temperature_threshold), + FT(nucleation_supersaturation_threshold), + FT(nucleation_maximum_concentration), + FT(nucleation_timescale), + FT(immersion_freezing_temperature_max), + FT(immersion_freezing_coefficient), + FT(immersion_freezing_timescale_cloud), + FT(immersion_freezing_timescale_rain), + FT(splintering_temperature_low), + FT(splintering_temperature_high), + FT(splintering_temperature_peak), + FT(splintering_temperature_width), + FT(splintering_rate), + FT(rain_fall_speed_coefficient), + FT(rain_fall_speed_exponent), + FT(rain_diameter_min), + FT(rain_diameter_max), + FT(rain_velocity_min), + FT(rain_velocity_max), + FT(ice_fall_speed_coefficient_unrimed), + FT(ice_fall_speed_exponent_unrimed), + FT(ice_fall_speed_coefficient_rimed), + FT(ice_fall_speed_exponent_rimed), + FT(ice_small_particle_coefficient), + FT(ice_diameter_threshold), + FT(ice_diameter_min), + FT(ice_diameter_max), + FT(ice_velocity_min), + FT(ice_velocity_max), + FT(ice_effective_density_unrimed), + FT(velocity_ratio_number_to_mass), + FT(velocity_ratio_reflectivity_to_mass), + FT(initial_rain_drop_mass) + ) +end + +Base.summary(::ProcessRateParameters) = "ProcessRateParameters" + +function Base.show(io::IO, p::ProcessRateParameters) + print(io, summary(p), "(") + print(io, "T₀=", p.freezing_temperature, "K, ") + print(io, "ρʷ=", p.liquid_water_density, "kg/m³, ") + print(io, "τ_melt=", p.ice_melting_timescale, "s)") +end diff --git a/src/Microphysics/PredictedParticleProperties/process_rates.jl b/src/Microphysics/PredictedParticleProperties/process_rates.jl new file mode 100644 index 000000000..7c1e25c74 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/process_rates.jl @@ -0,0 +1,2145 @@ +##### +##### P3 Process Rates +##### +##### Microphysical process rate calculations for the P3 scheme. +##### All rate functions take the P3 scheme as first positional argument +##### to access parameters. No keyword arguments (GPU compatibility). +##### +##### Notation follows docs/src/appendix/notation.md +##### + +using Oceananigans: Oceananigans + +using Breeze.Thermodynamics: temperature, + saturation_specific_humidity, + saturation_vapor_pressure, + PlanarLiquidSurface, + PlanarIceSurface + +##### +##### Utility functions +##### + +""" + clamp_positive(x) + +Return max(0, x) for numerical stability. +""" +@inline clamp_positive(x) = max(0, x) + +""" + safe_divide(a, b, default) + +Safe division returning `default` when b ≈ 0. +All arguments must be positional (GPU kernel compatibility). +""" +@inline function safe_divide(a, b, default) + FT = typeof(a) + ε = eps(FT) + return ifelse(abs(b) < ε, default, a / b) +end + +# Convenience overload for common case +@inline safe_divide(a, b) = safe_divide(a, b, zero(a)) + +##### +##### Rain processes +##### + +""" + rain_autoconversion_rate(p3, qᶜˡ, Nᶜ) + +Compute rain autoconversion rate following [Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). + +Cloud droplets larger than a threshold undergo collision-coalescence to form rain. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `Nᶜ`: Cloud droplet number concentration [1/m³] + +# Returns +- Rate of cloud → rain conversion [kg/kg/s] +""" +@inline function rain_autoconversion_rate(p3, qᶜˡ, Nᶜ) + FT = typeof(qᶜˡ) + prp = p3.process_rates + + # No autoconversion below threshold + qᶜˡ_eff = clamp_positive(qᶜˡ - prp.autoconversion_threshold) + + # Scale droplet concentration + Nᶜ_scaled = Nᶜ / prp.autoconversion_reference_concentration + Nᶜ_scaled = max(Nᶜ_scaled, FT(0.01)) + + # Khairoutdinov-Kogan (2000): ∂qʳ/∂t = k₁ × qᶜˡ^α × (Nᶜ/Nᶜ_ref)^β + k₁ = prp.autoconversion_coefficient + α = prp.autoconversion_exponent_cloud + β = prp.autoconversion_exponent_droplet + + return k₁ * qᶜˡ_eff^α * Nᶜ_scaled^β +end + +""" + rain_accretion_rate(p3, qᶜˡ, qʳ) + +Compute rain accretion rate following [Khairoutdinov and Kogan (2000)](@cite KhairoutdinovKogan2000). + +Falling rain drops collect cloud droplets via gravitational sweep-out. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `qʳ`: Rain mass fraction [kg/kg] + +# Returns +- Rate of cloud → rain conversion [kg/kg/s] +""" +@inline function rain_accretion_rate(p3, qᶜˡ, qʳ) + prp = p3.process_rates + + qᶜˡ_eff = clamp_positive(qᶜˡ) + qʳ_eff = clamp_positive(qʳ) + + # KK2000: ∂qʳ/∂t = k₂ × (qᶜˡ × qʳ)^α + k₂ = prp.accretion_coefficient + α = prp.accretion_exponent + + return k₂ * (qᶜˡ_eff * qʳ_eff)^α +end + +""" + rain_self_collection_rate(p3, qʳ, nʳ, ρ) + +Compute rain self-collection rate (number tendency only). + +Large rain drops collect smaller ones, reducing number but conserving mass. +Follows [Seifert and Beheng (2001)](@cite SeifertBeheng2001). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `ρ`: Air density [kg/m³] + +# Returns +- Rate of rain number reduction [1/kg/s] +""" +@inline function rain_self_collection_rate(p3, qʳ, nʳ, ρ) + prp = p3.process_rates + + qʳ_eff = clamp_positive(qʳ) + nʳ_eff = clamp_positive(nʳ) + + # ∂nʳ/∂t = -k_rr × ρ × qʳ × nʳ + k_rr = prp.self_collection_coefficient + + return -k_rr * ρ * qʳ_eff * nʳ_eff +end + +""" + rain_evaporation_rate(p3, qʳ, nʳ, qᵛ, qᵛ⁺ˡ, T, ρ) + +Compute rain evaporation rate using ventilation-enhanced diffusion. + +Rain drops evaporate when the ambient air is subsaturated (qᵛ < qᵛ⁺ˡ). +The evaporation rate is enhanced by ventilation (air flow around falling drops): + +```math +\\frac{dm}{dt} = \\frac{4πD f_v (S - 1)}{\\frac{L_v}{K_a T}(\\frac{L_v}{R_v T} - 1) + \\frac{R_v T}{e_s D_v}} +``` + +where D is the drop diameter and f_v is the ventilation factor. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺ˡ`: Saturation vapor mass fraction over liquid [kg/kg] +- `T`: Temperature [K] +- `ρ`: Air density [kg/m³] + +# Returns +- Rate of rain → vapor conversion [kg/kg/s] (negative = evaporation) +""" +@inline function rain_evaporation_rate(p3, qʳ, nʳ, qᵛ, qᵛ⁺ˡ, T, ρ) + FT = typeof(qʳ) + prp = p3.process_rates + + qʳ_eff = clamp_positive(qʳ) + nʳ_eff = clamp_positive(nʳ) + + # Only evaporate in subsaturated conditions + S = qᵛ / max(qᵛ⁺ˡ, FT(1e-10)) + is_subsaturated = S < 1 + + # Thermodynamic constants + R_v = FT(461.5) # Gas constant for water vapor [J/kg/K] + L_v = FT(2.5e6) # Latent heat of vaporization [J/kg] + K_a = FT(2.5e-2) # Thermal conductivity of air [W/m/K] + D_v = FT(2.5e-5) # Diffusivity of water vapor [m²/s] + + # Saturation vapor pressure derived from qᵛ⁺ˡ + # From ideal gas law: ρ_v⁺ = e_s / (R_v × T) + # And ρ_v⁺ ≈ ρ × qᵛ⁺ˡ for small qᵛ⁺ˡ + e_s = ρ * qᵛ⁺ˡ * R_v * T + + # Mean drop properties + m_mean = safe_divide(qʳ_eff, nʳ_eff, FT(1e-12)) + ρ_water = p3.water_density + D_mean = cbrt(6 * m_mean / (FT(π) * ρ_water)) + + # Terminal velocity for rain drops (power law) + V = FT(130) * D_mean^FT(0.5) # Simplified Gunn-Kinzer + + # Ventilation factor + ν = FT(1.5e-5) + Re_term = sqrt(V * D_mean / ν) + f_v = FT(0.78) + FT(0.31) * Re_term # Different coefficients for drops + + # Thermodynamic resistance + A = L_v / (K_a * T) * (L_v / (R_v * T) - 1) + B = R_v * T / (e_s * D_v) + thermodynamic_factor = A + B + + # Evaporation rate per drop (negative for evaporation) + dm_dt = FT(4π) * (D_mean / 2) * f_v * (S - 1) / thermodynamic_factor + + # Total rate + evap_rate = nʳ_eff * dm_dt + + # Cannot evaporate more than available + τ_evap = prp.rain_evaporation_timescale + max_evap = -qʳ_eff / τ_evap + + evap_rate = max(evap_rate, max_evap) + + return ifelse(is_subsaturated, evap_rate, zero(FT)) +end + +# Backward compatibility: simplified version without T, ρ +@inline function rain_evaporation_rate(p3, qʳ, qᵛ, qᵛ⁺ˡ) + FT = typeof(qʳ) + prp = p3.process_rates + + qʳ_eff = clamp_positive(qʳ) + τ_evap = prp.rain_evaporation_timescale + + # Subsaturation + S = qᵛ - qᵛ⁺ˡ + + # Only evaporate in subsaturated conditions + S_sub = min(S, zero(FT)) + + # Relaxation toward saturation + evap_rate = S_sub / τ_evap + + # Cannot evaporate more than available + max_evap = -qʳ_eff / τ_evap + + return max(evap_rate, max_evap) +end + +##### +##### Ice deposition and sublimation +##### + +""" + ice_deposition_rate(p3, qⁱ, qᵛ, qᵛ⁺ⁱ) + +Compute ice deposition/sublimation rate. + +Ice grows by vapor deposition when supersaturated with respect to ice, +and sublimates when subsaturated. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺ⁱ`: Saturation vapor mass fraction over ice [kg/kg] + +# Returns +- Rate of vapor → ice conversion [kg/kg/s] (positive = deposition) +""" +@inline function ice_deposition_rate(p3, qⁱ, qᵛ, qᵛ⁺ⁱ) + FT = typeof(qⁱ) + prp = p3.process_rates + + qⁱ_eff = clamp_positive(qⁱ) + τ_dep = prp.ice_deposition_timescale + + # Supersaturation with respect to ice + Sⁱ = qᵛ - qᵛ⁺ⁱ + + # Relaxation toward saturation + dep_rate = Sⁱ / τ_dep + + # Limit sublimation to available ice + is_sublimation = Sⁱ < 0 + max_sublim = -qⁱ_eff / τ_dep + + return ifelse(is_sublimation, max(dep_rate, max_sublim), dep_rate) +end + +""" + ventilation_enhanced_deposition(p3, qⁱ, nⁱ, qᵛ, qᵛ⁺ⁱ, Fᶠ, ρᶠ, T, P) + +Compute ventilation-enhanced ice deposition/sublimation rate. + +Following Morrison & Milbrandt (2015a) Eq. 30, the deposition rate is: + +```math +\\frac{dm}{dt} = \\frac{4πC f_v (S_i - 1)}{\\frac{L_s}{K_a T}(\\frac{L_s}{R_v T} - 1) + \\frac{R_v T}{e_{si} D_v}} +``` + +where f_v is the ventilation factor and C is the capacitance. + +The bulk rate integrates over the size distribution: + +```math +\\frac{dq^i}{dt} = ∫ \\frac{dm}{dt}(D) N'(D) dD +``` + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺ⁱ`: Saturation vapor mass fraction over ice [kg/kg] +- `Fᶠ`: Rime fraction [-] +- `ρᶠ`: Rime density [kg/m³] +- `T`: Temperature [K] +- `P`: Pressure [Pa] + +# Returns +- Rate of vapor → ice conversion [kg/kg/s] (positive = deposition) +""" +@inline function ventilation_enhanced_deposition(p3, qⁱ, nⁱ, qᵛ, qᵛ⁺ⁱ, Fᶠ, ρᶠ, T, P) + FT = typeof(qⁱ) + prp = p3.process_rates + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = clamp_positive(nⁱ) + + # Thermodynamic constants + R_v = FT(461.5) # Gas constant for water vapor [J/kg/K] + R_d = FT(287.0) # Gas constant for dry air [J/kg/K] + L_s = FT(2.835e6) # Latent heat of sublimation [J/kg] + K_a = FT(2.5e-2) # Thermal conductivity of air [W/m/K] + D_v = FT(2.5e-5) # Diffusivity of water vapor [m²/s] + + # Saturation vapor pressure over ice + # Derived from qᵛ⁺ⁱ: qᵛ⁺ⁱ = ε × e_si / (P - (1-ε) × e_si) + # Rearranging: e_si = P × qᵛ⁺ⁱ / (ε + qᵛ⁺ⁱ × (1 - ε)) + ε = R_d / R_v + e_si = P * qᵛ⁺ⁱ / (ε + qᵛ⁺ⁱ * (1 - ε)) + + # Supersaturation ratio with respect to ice + S_i = qᵛ / max(qᵛ⁺ⁱ, FT(1e-10)) + + # Mean particle mass + m_mean = safe_divide(qⁱ_eff, nⁱ_eff, FT(1e-12)) + + # Effective density depends on riming + ρⁱ = prp.pure_ice_density + ρ_eff_unrimed = prp.ice_effective_density_unrimed + ρ_eff = (1 - Fᶠ) * ρ_eff_unrimed + Fᶠ * ρᶠ + + # Mean diameter + D_mean = cbrt(6 * m_mean / (FT(π) * ρ_eff)) + + # Capacitance (regime-dependent) + D_threshold = prp.ice_diameter_threshold + C = ifelse(D_mean < D_threshold, D_mean / 2, FT(0.48) * D_mean) + + # Ventilation factor: f_v = a + b × Re^(1/2) × Sc^(1/3) + # Simplified: f_v ≈ 0.65 + 0.44 × √(V × D / ν) + ν = FT(1.5e-5) # kinematic viscosity [m²/s] + # Estimate terminal velocity (simplified power law) + V = FT(11.72) * D_mean^FT(0.41) + Re_term = sqrt(V * D_mean / ν) + f_v = FT(0.65) + FT(0.44) * Re_term + + # Denominator: thermodynamic resistance terms + # A = L_s/(K_a × T) × (L_s/(R_v × T) - 1) + # B = R_v × T / (e_si × D_v) + A = L_s / (K_a * T) * (L_s / (R_v * T) - 1) + B = R_v * T / (e_si * D_v) + thermodynamic_factor = A + B + + # Deposition rate per particle (Eq. 30 from MM15a) + dm_dt = FT(4π) * C * f_v * (S_i - 1) / thermodynamic_factor + + # Total rate + dep_rate = nⁱ_eff * dm_dt + + # Limit sublimation to available ice + τ_dep = prp.ice_deposition_timescale + is_sublimation = S_i < 1 + max_sublim = -qⁱ_eff / τ_dep + + return ifelse(is_sublimation, max(dep_rate, max_sublim), dep_rate) +end + +# Backward compatibility: version without T, P uses simplified form +@inline function ventilation_enhanced_deposition(p3, qⁱ, nⁱ, qᵛ, qᵛ⁺ⁱ, Fᶠ, ρᶠ) + FT = typeof(qⁱ) + # Use default T = 250 K, P = 50000 Pa for backward compatibility + return ventilation_enhanced_deposition(p3, qⁱ, nⁱ, qᵛ, qᵛ⁺ⁱ, Fᶠ, ρᶠ, FT(250), FT(50000)) +end + +##### +##### Melting +##### + +""" + ice_melting_rate(p3, qⁱ, nⁱ, T, qᵛ, qᵛ⁺, Fᶠ, ρᶠ) + +Compute ice melting rate using the heat balance equation from +Morrison & Milbrandt (2015a) Eq. 44. + +The melting rate is determined by the heat flux to the particle: + +```math +\\frac{dm}{dt} = -\\frac{4πC}{L_f} × [K_a(T-T_0) + L_v D_v(ρ_v - ρ_{vs})] × f_v +``` + +where: +- C is the capacitance +- L_f is the latent heat of fusion +- K_a is thermal conductivity of air +- T_0 is the freezing temperature +- L_v is latent heat of vaporization +- D_v is diffusivity of water vapor +- ρ_v, ρ_vs are vapor density and saturation vapor density +- f_v is the ventilation factor + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `T`: Temperature [K] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺`: Saturation vapor mass fraction over liquid [kg/kg] +- `Fᶠ`: Rime fraction [-] +- `ρᶠ`: Rime density [kg/m³] + +# Returns +- Rate of ice → rain conversion [kg/kg/s] +""" +@inline function ice_melting_rate(p3, qⁱ, nⁱ, T, qᵛ, qᵛ⁺, Fᶠ, ρᶠ) + FT = typeof(qⁱ) + prp = p3.process_rates + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = clamp_positive(nⁱ) + + T₀ = prp.freezing_temperature + + # Only melt above freezing + ΔT = T - T₀ + is_melting = ΔT > 0 + + # Thermodynamic constants + L_f = FT(3.34e5) # Latent heat of fusion [J/kg] + L_v = FT(2.5e6) # Latent heat of vaporization [J/kg] + K_a = FT(2.5e-2) # Thermal conductivity of air [W/m/K] + D_v = FT(2.5e-5) # Diffusivity of water vapor [m²/s] + R_v = FT(461.5) # Gas constant for water vapor [J/kg/K] + + # Vapor density terms + # At T₀, ρ_vs corresponds to saturation at melting point + e_s0 = FT(611) # Saturation vapor pressure at 273.15 K [Pa] + P_atm = FT(1e5) # Reference pressure [Pa] + ρ_vs = e_s0 / (R_v * T₀) # Saturation vapor density at T₀ + + # Ambient vapor density (from mixing ratio) + ρ_air = P_atm / (FT(287) * T) # Approximate air density + ρ_v = qᵛ * ρ_air + + # Mean particle properties + m_mean = safe_divide(qⁱ_eff, nⁱ_eff, FT(1e-12)) + + # Effective density + ρⁱ = prp.pure_ice_density + ρ_eff_unrimed = prp.ice_effective_density_unrimed + ρ_eff = (1 - Fᶠ) * ρ_eff_unrimed + Fᶠ * ρᶠ + + # Mean diameter + D_mean = cbrt(6 * m_mean / (FT(π) * ρ_eff)) + + # Capacitance + D_threshold = prp.ice_diameter_threshold + C = ifelse(D_mean < D_threshold, D_mean / 2, FT(0.48) * D_mean) + + # Ventilation factor + ν = FT(1.5e-5) + V = FT(11.72) * D_mean^FT(0.41) + Re_term = sqrt(V * D_mean / ν) + f_v = FT(0.65) + FT(0.44) * Re_term + + # Heat flux terms (Eq. 44 from MM15a) + # Sensible heat: K_a × (T - T₀) + Q_sensible = K_a * ΔT + + # Latent heat: L_v × D_v × (ρ_v - ρ_vs) + # When subsaturated, this is negative and opposes melting + Q_latent = L_v * D_v * (ρ_v - ρ_vs) + + # Total heat flux + Q_total = Q_sensible + Q_latent + + # Melting rate per particle (negative dm/dt → positive melt rate) + dm_dt_melt = FT(4π) * C * f_v * Q_total / L_f + + # Clamp to positive (only melting, not refreezing here) + dm_dt_melt = clamp_positive(dm_dt_melt) + + # Total rate + melt_rate = nⁱ_eff * dm_dt_melt + + # Limit to available ice + τ_melt = prp.ice_melting_timescale + max_melt = qⁱ_eff / τ_melt + + melt_rate = min(melt_rate, max_melt) + + return ifelse(is_melting, melt_rate, zero(FT)) +end + +# Backward compatibility: simplified version +@inline function ice_melting_rate(p3, qⁱ, T) + FT = typeof(qⁱ) + prp = p3.process_rates + + qⁱ_eff = clamp_positive(qⁱ) + T₀ = prp.freezing_temperature + τ_melt = prp.ice_melting_timescale + + # Temperature excess above freezing + ΔT = T - T₀ + ΔT_pos = clamp_positive(ΔT) + + # Melting rate proportional to temperature excess (normalized to 1K) + rate_factor = ΔT_pos + + return qⁱ_eff * rate_factor / τ_melt +end + +""" + ice_melting_rates(p3, qⁱ, nⁱ, qʷⁱ, T, qᵛ, qᵛ⁺, Fᶠ, ρᶠ) + +Compute partitioned ice melting rates following Milbrandt et al. (2025). + +Above freezing, ice particles melt. The meltwater is partitioned: +- **Partial melting** (large particles): Meltwater stays on ice as liquid coating (qʷⁱ) +- **Complete melting** (small particles): Meltwater sheds directly to rain + +The partitioning is based on a maximum liquid fraction capacity. Once the +particle reaches this capacity, additional meltwater sheds to rain. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `qʷⁱ`: Liquid water on ice [kg/kg] +- `T`: Temperature [K] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺`: Saturation vapor mass fraction over liquid [kg/kg] +- `Fᶠ`: Rime fraction [-] +- `ρᶠ`: Rime density [kg/m³] + +# Returns +- NamedTuple with `partial_melting` and `complete_melting` rates [kg/kg/s] +""" +@inline function ice_melting_rates(p3, qⁱ, nⁱ, qʷⁱ, T, qᵛ, qᵛ⁺, Fᶠ, ρᶠ) + FT = typeof(qⁱ) + prp = p3.process_rates + + # Get total melting rate + total_melt = ice_melting_rate(p3, qⁱ, nⁱ, T, qᵛ, qᵛ⁺, Fᶠ, ρᶠ) + + # Maximum liquid fraction capacity (from Milbrandt et al. 2025) + # Spongy ice can hold about 14% liquid by mass + max_liquid_fraction = prp.maximum_liquid_fraction + + # Total ice mass (ice + liquid coating) + qⁱ_total = qⁱ + qʷⁱ + qⁱ_total_safe = max(qⁱ_total, FT(1e-20)) + + # Current liquid fraction + current_liquid_fraction = qʷⁱ / qⁱ_total_safe + + # Partition melting based on liquid fraction capacity + # If below capacity: melting goes to liquid coating + # If at/above capacity: melting sheds to rain + fraction_to_coating = clamp_positive(max_liquid_fraction - current_liquid_fraction) / max_liquid_fraction + + # Limit to [0, 1] + fraction_to_coating = clamp(fraction_to_coating, FT(0), FT(1)) + + partial = total_melt * fraction_to_coating + complete = total_melt * (1 - fraction_to_coating) + + return (partial_melting = partial, complete_melting = complete) +end + +""" + ice_melting_number_rate(qⁱ, nⁱ, qⁱ_melt_rate) + +Compute ice number tendency from melting. + +Number of melted particles equals number of rain drops produced. + +# Arguments +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `qⁱ_melt_rate`: Ice mass melting rate [kg/kg/s] + +# Returns +- Rate of ice number reduction [1/kg/s] +""" +@inline function ice_melting_number_rate(qⁱ, nⁱ, qⁱ_melt_rate) + FT = typeof(qⁱ) + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = clamp_positive(nⁱ) + + # ∂nⁱ/∂t = (nⁱ/qⁱ) × ∂qⁱ_melt/∂t + ratio = safe_divide(nⁱ_eff, qⁱ_eff, zero(FT)) + + return -ratio * qⁱ_melt_rate +end + +##### +##### Ice nucleation (deposition and immersion freezing) +##### + +""" + deposition_nucleation_rate(p3, T, qᵛ, qᵛ⁺ⁱ, nⁱ, ρ) + +Compute ice nucleation rate from deposition/condensation freezing. + +New ice crystals nucleate when temperature is below a threshold and the air +is supersaturated with respect to ice. Uses [Cooper (1986)](@cite Cooper1986). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `T`: Temperature [K] +- `qᵛ`: Vapor mass fraction [kg/kg] +- `qᵛ⁺ⁱ`: Saturation vapor mass fraction over ice [kg/kg] +- `nⁱ`: Current ice number concentration [1/kg] +- `ρ`: Air density [kg/m³] + +# Returns +- Tuple (Q_nuc, N_nuc): mass rate [kg/kg/s] and number rate [1/kg/s] +""" +@inline function deposition_nucleation_rate(p3, T, qᵛ, qᵛ⁺ⁱ, nⁱ, ρ) + FT = typeof(T) + prp = p3.process_rates + + T_threshold = prp.nucleation_temperature_threshold + Sⁱ_threshold = prp.nucleation_supersaturation_threshold + N_max = prp.nucleation_maximum_concentration + τ_nuc = prp.nucleation_timescale + T₀ = prp.freezing_temperature + mᵢ₀ = prp.nucleated_ice_mass + + # Ice supersaturation + Sⁱ = (qᵛ - qᵛ⁺ⁱ) / max(qᵛ⁺ⁱ, FT(1e-10)) + + # Conditions for nucleation + nucleation_active = (T < T_threshold) && (Sⁱ > Sⁱ_threshold) + + # Cooper (1986): N_ice = 0.005 × exp(0.304 × (T₀ - T)) + ΔT = T₀ - T + N_cooper = FT(0.005) * exp(FT(0.304) * ΔT) * FT(1000) / ρ + + # Limit to maximum and subtract existing ice + N_equilibrium = min(N_cooper, N_max / ρ) + + # Nucleation rate: relaxation toward equilibrium + N_nuc = clamp_positive(N_equilibrium - nⁱ) / τ_nuc + + # Mass nucleation rate + Q_nuc = N_nuc * mᵢ₀ + + # Zero out if conditions not met + N_nuc = ifelse(nucleation_active && N_nuc > FT(1e-20), N_nuc, zero(FT)) + Q_nuc = ifelse(nucleation_active && Q_nuc > FT(1e-30), Q_nuc, zero(FT)) + + return Q_nuc, N_nuc +end + +""" + immersion_freezing_cloud_rate(p3, qᶜˡ, Nᶜ, T) + +Compute immersion freezing rate of cloud droplets. + +Cloud droplets freeze when temperature is below a threshold. Uses +[Bigg (1953)](@cite Bigg1953) stochastic freezing parameterization. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `Nᶜ`: Cloud droplet number concentration [1/m³] +- `T`: Temperature [K] + +# Returns +- Tuple (Q_frz, N_frz): mass rate [kg/kg/s] and number rate [1/kg/s] +""" +@inline function immersion_freezing_cloud_rate(p3, qᶜˡ, Nᶜ, T) + FT = typeof(qᶜˡ) + prp = p3.process_rates + + T_max = prp.immersion_freezing_temperature_max + aimm = prp.immersion_freezing_coefficient + τ_base = prp.immersion_freezing_timescale_cloud + T₀ = prp.freezing_temperature + + qᶜˡ_eff = clamp_positive(qᶜˡ) + + # Conditions for freezing + freezing_active = (T < T_max) && (qᶜˡ_eff > FT(1e-8)) + + # Bigg (1953): J = exp(aimm × (T₀ - T)) + ΔT = T₀ - T + J = exp(aimm * ΔT) + + # Timescale decreases as J increases + τ_frz = τ_base / max(J, FT(1)) + + # Freezing rate + N_frz = ifelse(freezing_active, Nᶜ / τ_frz, zero(FT)) + Q_frz = ifelse(freezing_active, qᶜˡ_eff / τ_frz, zero(FT)) + + return Q_frz, N_frz +end + +""" + immersion_freezing_rain_rate(p3, qʳ, nʳ, T) + +Compute immersion freezing rate of rain drops. + +Rain drops freeze when temperature is below a threshold. Uses +[Bigg (1953)](@cite Bigg1953) stochastic freezing parameterization. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `T`: Temperature [K] + +# Returns +- Tuple (Q_frz, N_frz): mass rate [kg/kg/s] and number rate [1/kg/s] +""" +@inline function immersion_freezing_rain_rate(p3, qʳ, nʳ, T) + FT = typeof(qʳ) + prp = p3.process_rates + + T_max = prp.immersion_freezing_temperature_max + aimm = prp.immersion_freezing_coefficient + τ_base = prp.immersion_freezing_timescale_rain + T₀ = prp.freezing_temperature + + qʳ_eff = clamp_positive(qʳ) + nʳ_eff = clamp_positive(nʳ) + + # Conditions for freezing + freezing_active = (T < T_max) && (qʳ_eff > FT(1e-8)) + + # Bigg (1953) + ΔT = T₀ - T + J = exp(aimm * ΔT) + + # Rain freezes faster due to larger volume + τ_frz = τ_base / max(J, FT(1)) + + # Freezing rate + N_frz = ifelse(freezing_active, nʳ_eff / τ_frz, zero(FT)) + Q_frz = ifelse(freezing_active, qʳ_eff / τ_frz, zero(FT)) + + return Q_frz, N_frz +end + +""" + contact_freezing_rate(p3, qᶜˡ, Nᶜ, T, N_IN) + +Compute contact freezing nucleation rate. + +Contact freezing occurs when ice nuclei (IN) collide with supercooled droplets. +This is often a more efficient ice nucleation mechanism than deposition +at temperatures warmer than -15°C. + +The rate is proportional to: +- IN concentration (N_IN) +- Cloud droplet surface area (∝ D² × N_cloud) +- Collection efficiency (Brownian + phoretic) + +Following [Meyers et al. (1992)](@cite MeyerEtAl1992icenucleation): + +```math +\\frac{dN^i}{dt} = 4π D_c^2 N_c N_{IN} D_{IN} (1 + 0.4 Re^{0.5} Sc^{0.33}) +``` + +where D_IN is the IN diffusivity and the parenthetical term is the +phoretic enhancement. + +# Arguments +- `p3`: P3 microphysics scheme +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `Nᶜ`: Cloud droplet number concentration [1/m³] +- `T`: Temperature [K] +- `N_IN`: Ice nuclei concentration [1/m³] (optional, defaults to Meyers parameterization) + +# Returns +- Tuple (Q_frz, N_frz): mass rate [kg/kg/s] and number rate [1/kg/s] +""" +@inline function contact_freezing_rate(p3, qᶜˡ, Nᶜ, T, N_IN) + FT = typeof(qᶜˡ) + prp = p3.process_rates + + T₀ = prp.freezing_temperature + T_max = FT(268) # Contact freezing inactive above -5°C + + qᶜˡ_eff = clamp_positive(qᶜˡ) + + # Conditions for contact freezing + freezing_active = (T < T₀) && (T < T_max) && (qᶜˡ_eff > FT(1e-8)) + + # Cloud droplet properties + ρ_water = p3.water_density + # Mean cloud droplet diameter (from cloud properties) + m_drop = qᶜˡ_eff / max(Nᶜ, FT(1e6)) + D_c = cbrt(6 * m_drop / (FT(π) * ρ_water)) + D_c = clamp(D_c, FT(5e-6), FT(50e-6)) + + # IN diffusivity (approximately Brownian for submicron particles) + # D_IN ~ k_B T / (3 π μ D_IN_particle) ~ 2e-11 m²/s for 0.5 μm particles + D_IN = FT(2e-11) + + # Contact kernel: K = 4π D_c² D_IN × ventilation_factor + # Simplified ventilation factor for cloud droplets (small Re) + vent_factor = FT(1.2) + + K_contact = FT(4π) * D_c^2 * D_IN * vent_factor + + # Freezing rate + N_frz = K_contact * Nᶜ * N_IN + + # Mass rate: each frozen droplet becomes ice of same mass + Q_frz = m_drop * N_frz + + # Apply conditions + N_frz = ifelse(freezing_active, N_frz, zero(FT)) + Q_frz = ifelse(freezing_active, Q_frz, zero(FT)) + + return Q_frz, N_frz +end + +# Version with Meyers IN parameterization +@inline function contact_freezing_rate(p3, qᶜˡ, Nᶜ, T) + FT = typeof(qᶜˡ) + prp = p3.process_rates + T₀ = prp.freezing_temperature + + # Meyers et al. (1992) IN parameterization (contact nuclei) + # N_IN = exp(-2.80 - 0.262 × (T₀ - T)) per liter + ΔT = T₀ - T + ΔT_clamped = clamp(ΔT, FT(0), FT(40)) + N_IN = exp(FT(-2.80) - FT(0.262) * ΔT_clamped) * FT(1000) # per m³ + + return contact_freezing_rate(p3, qᶜˡ, Nᶜ, T, N_IN) +end + +##### +##### Rime splintering (Hallett-Mossop secondary ice production) +##### + +""" + rime_splintering_rate(p3, cloud_riming, rain_riming, T) + +Compute secondary ice production from rime splintering (Hallett-Mossop effect). + +When rimed ice particles accrete supercooled drops, ice splinters are +ejected. This occurs only in a narrow temperature range around -5°C. +See [Hallett and Mossop (1974)](@cite HallettMossop1974). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `cloud_riming`: Cloud droplet riming rate [kg/kg/s] +- `rain_riming`: Rain riming rate [kg/kg/s] +- `T`: Temperature [K] + +# Returns +- Tuple (Q_spl, N_spl): ice mass rate [kg/kg/s] and number rate [1/kg/s] +""" +@inline function rime_splintering_rate(p3, cloud_riming, rain_riming, T) + FT = typeof(T) + prp = p3.process_rates + + T_low = prp.splintering_temperature_low + T_high = prp.splintering_temperature_high + T_peak = prp.splintering_temperature_peak + T_width = prp.splintering_temperature_width + c_splinter = prp.splintering_rate + mᵢ₀ = prp.nucleated_ice_mass + + # Hallett-Mossop temperature window + in_HM_window = (T > T_low) && (T < T_high) + + # Efficiency peaks at T_peak, tapers to zero at boundaries + efficiency = exp(-((T - T_peak) / T_width)^2) + + # Total riming rate + total_riming = clamp_positive(cloud_riming + rain_riming) + + # Number of splinters produced + N_spl = ifelse(in_HM_window, + efficiency * c_splinter * total_riming, + zero(FT)) + + # Mass of splinters + Q_spl = N_spl * mᵢ₀ + + return Q_spl, N_spl +end + +##### +##### Phase 2: Ice aggregation +##### + +""" + ice_aggregation_rate(p3, qⁱ, nⁱ, T, Fᶠ, ρᶠ) + +Compute ice self-collection (aggregation) rate using proper collision kernel. + +Ice particles collide and stick together, reducing number concentration +without changing total mass. The collision kernel is: + +```math +K(D_1, D_2) = E_{ii} × \\frac{π}{4}(D_1 + D_2)^2 × |V_1 - V_2| +``` + +The number tendency is: + +```math +\\frac{dn^i}{dt} = -\\frac{1}{2} ∫∫ K(D_1, D_2) N'(D_1) N'(D_2) dD_1 dD_2 +``` + +The sticking efficiency E_ii increases with temperature (more sticky near 0°C). +See [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `T`: Temperature [K] +- `Fᶠ`: Rime fraction [-] +- `ρᶠ`: Rime density [kg/m³] + +# Returns +- Rate of ice number reduction [1/kg/s] +""" +@inline function ice_aggregation_rate(p3, qⁱ, nⁱ, T, Fᶠ, ρᶠ) + FT = typeof(qⁱ) + prp = p3.process_rates + + Eᵢᵢ_max = prp.aggregation_efficiency_max + T_low = prp.aggregation_efficiency_temperature_low + T_high = prp.aggregation_efficiency_temperature_high + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = clamp_positive(nⁱ) + + # Thresholds + qⁱ_threshold = FT(1e-8) + nⁱ_threshold = FT(1e2) + + aggregation_active = (qⁱ_eff > qⁱ_threshold) && (nⁱ_eff > nⁱ_threshold) + + # Temperature-dependent sticking efficiency (linear ramp) + # Cold ice is less sticky, near-melting ice is very sticky + Eᵢᵢ_cold = FT(0.1) + Eᵢᵢ = ifelse(T < T_low, Eᵢᵢ_cold, + ifelse(T > T_high, Eᵢᵢ_max, + Eᵢᵢ_cold + (T - T_low) / (T_high - T_low) * (Eᵢᵢ_max - Eᵢᵢ_cold))) + + # Mean particle properties + m_mean = safe_divide(qⁱ_eff, nⁱ_eff, FT(1e-12)) + + # Effective density + ρⁱ = prp.pure_ice_density + ρ_eff_unrimed = prp.ice_effective_density_unrimed + ρ_eff = (1 - Fᶠ) * ρ_eff_unrimed + Fᶠ * ρᶠ + + # Mean diameter + D_mean = cbrt(6 * m_mean / (FT(π) * ρ_eff)) + + # Mean terminal velocity (regime-dependent approximation) + a_V_unrimed = FT(11.72) + b_V_unrimed = FT(0.41) + a_V_rimed = FT(19.3) + b_V_rimed = FT(0.37) + a_V = (1 - Fᶠ) * a_V_unrimed + Fᶠ * a_V_rimed + b_V = (1 - Fᶠ) * b_V_unrimed + Fᶠ * b_V_rimed + V_mean = a_V * D_mean^b_V + + # Mean projected area (regime-dependent) + γ = FT(0.2285) + σ = FT(1.88) + A_aggregate = γ * D_mean^σ + A_sphere = FT(π) / 4 * D_mean^2 + A_mean = (1 - Fᶠ) * A_aggregate + Fᶠ * A_sphere + + # Self-collection kernel approximation: + # K ≈ E_ii × A_mean × ΔV, where ΔV ≈ 0.5 × V_mean for self-collection + ΔV = FT(0.5) * V_mean + K_mean = Eᵢᵢ * A_mean * ΔV + + # Number tendency: dn/dt = -0.5 × K × n² + rate = -FT(0.5) * K_mean * nⁱ_eff^2 + + return ifelse(aggregation_active, rate, zero(FT)) +end + +# Backward compatibility: simplified version without rime properties +@inline function ice_aggregation_rate(p3, qⁱ, nⁱ, T) + FT = typeof(qⁱ) + return ice_aggregation_rate(p3, qⁱ, nⁱ, T, zero(FT), FT(400)) +end + +##### +##### Phase 2: Riming (cloud and rain collection by ice) +##### + +""" + cloud_riming_rate(p3, qᶜˡ, qⁱ, T) + +Compute cloud droplet collection (riming) by ice particles. + +Cloud droplets are swept up by falling ice particles and freeze onto them. +This increases ice mass and rime mass. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `qⁱ`: Ice mass fraction [kg/kg] +- `T`: Temperature [K] + +# Returns +- Rate of cloud → ice conversion [kg/kg/s] (also equals rime mass gain rate) +""" +@inline function cloud_riming_rate(p3, qᶜˡ, qⁱ, T) + FT = typeof(qᶜˡ) + prp = p3.process_rates + + Eᶜⁱ = prp.cloud_ice_collection_efficiency + τ_rim = prp.cloud_riming_timescale + T₀ = prp.freezing_temperature + + qᶜˡ_eff = clamp_positive(qᶜˡ) + qⁱ_eff = clamp_positive(qⁱ) + + # Thresholds + q_threshold = FT(1e-8) + + # Only rime below freezing + below_freezing = T < T₀ + + # ∂qᶜˡ/∂t = -Eᶜⁱ × qᶜˡ × qⁱ / τ_rim + rate = ifelse(below_freezing && qᶜˡ_eff > q_threshold && qⁱ_eff > q_threshold, + Eᶜⁱ * qᶜˡ_eff * qⁱ_eff / τ_rim, + zero(FT)) + + return rate +end + +""" + cloud_riming_number_rate(qᶜˡ, Nᶜ, riming_rate) + +Compute cloud droplet number sink from riming. + +# Arguments +- `qᶜˡ`: Cloud liquid mass fraction [kg/kg] +- `Nᶜ`: Cloud droplet number concentration [1/m³] +- `riming_rate`: Cloud riming mass rate [kg/kg/s] + +# Returns +- Rate of cloud number reduction [1/m³/s] +""" +@inline function cloud_riming_number_rate(qᶜˡ, Nᶜ, riming_rate) + FT = typeof(qᶜˡ) + + ratio = safe_divide(Nᶜ, qᶜˡ, zero(FT)) + + return -ratio * riming_rate +end + +""" + rain_riming_rate(p3, qʳ, qⁱ, T) + +Compute rain collection (riming) by ice particles. + +Rain drops are swept up by falling ice particles and freeze onto them. +This increases ice mass and rime mass. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `qⁱ`: Ice mass fraction [kg/kg] +- `T`: Temperature [K] + +# Returns +- Rate of rain → ice conversion [kg/kg/s] (also equals rime mass gain rate) +""" +@inline function rain_riming_rate(p3, qʳ, qⁱ, T) + FT = typeof(qʳ) + prp = p3.process_rates + + Eʳⁱ = prp.rain_ice_collection_efficiency + τ_rim = prp.rain_riming_timescale + T₀ = prp.freezing_temperature + + qʳ_eff = clamp_positive(qʳ) + qⁱ_eff = clamp_positive(qⁱ) + + # Thresholds + q_threshold = FT(1e-8) + + # Only rime below freezing + below_freezing = T < T₀ + + rate = ifelse(below_freezing && qʳ_eff > q_threshold && qⁱ_eff > q_threshold, + Eʳⁱ * qʳ_eff * qⁱ_eff / τ_rim, + zero(FT)) + + return rate +end + +""" + rain_riming_number_rate(qʳ, nʳ, riming_rate) + +Compute rain number sink from riming. + +# Arguments +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `riming_rate`: Rain riming mass rate [kg/kg/s] + +# Returns +- Rate of rain number reduction [1/kg/s] +""" +@inline function rain_riming_number_rate(qʳ, nʳ, riming_rate) + FT = typeof(qʳ) + + ratio = safe_divide(nʳ, qʳ, zero(FT)) + + return -ratio * riming_rate +end + +""" + rime_density_cober_list(p3, T, vᵢ, D_drop, D_ice, lwc) + +Compute rime density using the full Cober & List (1993) parameterization. + +The rime density depends on the impact conditions: + +```math +ρ_f = ρ_0 × exp(a × K^b) +``` + +where K is a dimensionless impact parameter that depends on: +- Impact velocity (v_i) +- Cloud droplet diameter (D_drop) +- Surface temperature + +For wet growth conditions (T > -3°C, high LWC), rime density approaches +the density of liquid water (soaking). + +# Arguments +- `p3`: P3 microphysics scheme +- `T`: Temperature [K] +- `vᵢ`: Ice particle fall speed [m/s] +- `D_drop`: Median cloud droplet diameter [m] (default 20 μm) +- `D_ice`: Ice particle diameter [m] (for Reynolds number) +- `lwc`: Liquid water content [kg/m³] (for wet growth check) + +# Returns +- Rime density [kg/m³] + +# References +[Cober and List (1993)](@cite CoberList1993) +""" +@inline function rime_density_cober_list(p3, T, vᵢ, D_drop, D_ice, lwc) + FT = typeof(T) + prp = p3.process_rates + + ρ_rim_min = prp.minimum_rime_density + ρ_rim_max = prp.maximum_rime_density + T₀ = prp.freezing_temperature + ρ_water = p3.water_density + + # Temperature in Celsius + Tc = T - T₀ + + # Clamp temperature to supercooled range + Tc_clamped = clamp(Tc, FT(-40), FT(0)) + + # Impact velocity (approximately fall speed minus droplet fall speed) + v_impact = max(vᵢ, FT(0.1)) + + # Droplet Stokes number (St = ρ_w × D_drop² × v_impact / (18 × μ × D_ice)) + # Simplified: use dimensionless impact parameter K + μ = FT(1.8e-5) # Dynamic viscosity of air [Pa·s] + K = ρ_water * D_drop^2 * v_impact / (18 * μ * max(D_ice, FT(1e-5))) + + # Cober & List (1993) empirical fit for dry growth regime + # ρ_f = 110 + 290 × (1 - exp(-1.25 × K^0.75)) + # This asymptotes to ~400 kg/m³ for high K (dense rime/graupel) + # and to ~110 kg/m³ for low K (fluffy rime) + K_clamped = clamp(K, FT(0.01), FT(100)) + ρ_dry = FT(110) + FT(290) * (1 - exp(-FT(1.25) * K_clamped^FT(0.75))) + + # Temperature correction: slightly denser rime near 0°C + T_factor = 1 + FT(0.1) * (Tc_clamped + FT(40)) / FT(40) + ρ_dry = ρ_dry * T_factor + + # Wet growth regime: when T > -10°C and high LWC + # Rime density approaches water density (spongy graupel) + is_wet_growth = (Tc > FT(-10)) && (lwc > FT(0.5e-3)) + wet_fraction = clamp((Tc + FT(10)) / FT(10), zero(FT), one(FT)) + ρ_wet = ρ_dry * (1 - wet_fraction) + ρ_water * FT(0.8) * wet_fraction + + ρᶠ = ifelse(is_wet_growth, ρ_wet, ρ_dry) + + return clamp(ρᶠ, ρ_rim_min, ρ_rim_max) +end + +# Simplified version for backward compatibility +@inline function rime_density(p3, T, vᵢ) + FT = typeof(T) + prp = p3.process_rates + + ρ_rim_min = prp.minimum_rime_density + ρ_rim_max = prp.maximum_rime_density + T₀ = prp.freezing_temperature + + # Default droplet and ice properties + D_drop = FT(20e-6) # 20 μm cloud droplets + D_ice = FT(1e-3) # 1 mm ice particle + lwc = FT(0.3e-3) # 0.3 g/m³ typical LWC + + return rime_density_cober_list(p3, T, vᵢ, D_drop, D_ice, lwc) +end + +##### +##### Phase 2: Shedding and Refreezing (liquid fraction dynamics) +##### + +""" + shedding_rate(p3, qʷⁱ, qⁱ, T) + +Compute liquid shedding rate from ice particles. + +When ice particles carry too much liquid coating (from partial melting +or warm riming), excess liquid is shed as rain drops. +See [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʷⁱ`: Liquid water on ice [kg/kg] +- `qⁱ`: Ice mass fraction [kg/kg] +- `T`: Temperature [K] + +# Returns +- Rate of liquid → rain shedding [kg/kg/s] +""" +@inline function shedding_rate(p3, qʷⁱ, qⁱ, T) + FT = typeof(qʷⁱ) + prp = p3.process_rates + + τ_shed = prp.shedding_timescale + qʷⁱ_max_frac = prp.maximum_liquid_fraction + T₀ = prp.freezing_temperature + + qʷⁱ_eff = clamp_positive(qʷⁱ) + qⁱ_eff = clamp_positive(qⁱ) + + # Total particle mass + qᵗᵒᵗ = qⁱ_eff + qʷⁱ_eff + + # Maximum liquid that can be retained + qʷⁱ_max = qʷⁱ_max_frac * qᵗᵒᵗ + + # Excess liquid sheds + qʷⁱ_excess = clamp_positive(qʷⁱ_eff - qʷⁱ_max) + + # Enhanced shedding above freezing + T_factor = ifelse(T > T₀, FT(3), FT(1)) + + return T_factor * qʷⁱ_excess / τ_shed +end + +""" + shedding_number_rate(p3, shed_rate) + +Compute rain number source from shedding. + +Shed liquid forms rain drops of approximately 1 mm diameter. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `shed_rate`: Liquid shedding mass rate [kg/kg/s] + +# Returns +- Rate of rain number increase [1/kg/s] +""" +@inline function shedding_number_rate(p3, shed_rate) + m_shed = p3.process_rates.shed_drop_mass + + return shed_rate / m_shed +end + +""" + refreezing_rate(p3, qʷⁱ, T) + +Compute refreezing rate of liquid on ice particles. + +Below freezing, liquid coating on ice particles refreezes, +transferring mass from liquid-on-ice to ice+rime. +See [Milbrandt et al. (2025)](@cite MilbrandtEtAl2025liquidfraction). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʷⁱ`: Liquid water on ice [kg/kg] +- `T`: Temperature [K] + +# Returns +- Rate of liquid → ice refreezing [kg/kg/s] +""" +@inline function refreezing_rate(p3, qʷⁱ, T) + FT = typeof(qʷⁱ) + prp = p3.process_rates + + τ_frz = prp.refreezing_timescale + T₀ = prp.freezing_temperature + + qʷⁱ_eff = clamp_positive(qʷⁱ) + + # Only refreeze below freezing + below_freezing = T < T₀ + + # Faster refreezing at colder temperatures + ΔT = clamp_positive(T₀ - T) + T_factor = FT(1) + FT(0.1) * ΔT + + rate = ifelse(below_freezing && qʷⁱ_eff > FT(1e-10), + T_factor * qʷⁱ_eff / τ_frz, + zero(FT)) + + return rate +end + +##### +##### Combined P3 tendency calculation +##### + +""" + P3ProcessRates + +Container for computed P3 process rates. +Includes Phase 1 (rain, deposition, melting), Phase 2 (aggregation, riming, shedding, nucleation). + +Following Milbrandt et al. (2025), melting is partitioned: +- `partial_melting`: Meltwater stays on ice as liquid coating (large particles) +- `complete_melting`: Meltwater sheds to rain (small particles) +""" +struct P3ProcessRates{FT} + # Phase 1: Rain tendencies + autoconversion :: FT # Cloud → rain mass [kg/kg/s] + accretion :: FT # Cloud → rain mass (via rain sweep-out) [kg/kg/s] + rain_evaporation :: FT # Rain → vapor mass [kg/kg/s] + rain_self_collection :: FT # Rain number reduction [1/kg/s] + + # Phase 1: Ice tendencies + deposition :: FT # Vapor → ice mass [kg/kg/s] + partial_melting :: FT # Ice → liquid coating (stays on ice) [kg/kg/s] + complete_melting :: FT # Ice → rain mass (sheds) [kg/kg/s] + melting_number :: FT # Ice number reduction from melting [1/kg/s] + + # Phase 2: Ice aggregation + aggregation :: FT # Ice number reduction from self-collection [1/kg/s] + + # Phase 2: Riming + cloud_riming :: FT # Cloud → ice via riming [kg/kg/s] + cloud_riming_number :: FT # Cloud number reduction [1/kg/s] + rain_riming :: FT # Rain → ice via riming [kg/kg/s] + rain_riming_number :: FT # Rain number reduction [1/kg/s] + rime_density_new :: FT # Density of new rime [kg/m³] + + # Phase 2: Shedding and refreezing + shedding :: FT # Liquid on ice → rain [kg/kg/s] + shedding_number :: FT # Rain number from shedding [1/kg/s] + refreezing :: FT # Liquid on ice → rime [kg/kg/s] + + # Ice nucleation (deposition + immersion freezing) + nucleation_mass :: FT # New ice mass from deposition nucleation [kg/kg/s] + nucleation_number :: FT # New ice number from deposition nucleation [1/kg/s] + cloud_freezing_mass :: FT # Cloud → ice mass from immersion freezing [kg/kg/s] + cloud_freezing_number :: FT # Cloud number to ice number [1/kg/s] + rain_freezing_mass :: FT # Rain → ice mass from immersion freezing [kg/kg/s] + rain_freezing_number :: FT # Rain number to ice number [1/kg/s] + + # Rime splintering (Hallett-Mossop) + splintering_mass :: FT # New ice mass from splintering [kg/kg/s] + splintering_number :: FT # New ice number from splintering [1/kg/s] +end + +""" + compute_p3_process_rates(p3, ρ, ℳ, 𝒰, constants) + +Compute all P3 process rates (Phase 1 and Phase 2) from a microphysical state. + +This is the gridless version that accepts a `P3MicrophysicalState` directly, +suitable for use in GPU kernels where grid indexing is handled externally. + +# Arguments +- `p3`: P3 microphysics scheme +- `ρ`: Air density [kg/m³] +- `ℳ`: P3MicrophysicalState containing all mixing ratios +- `𝒰`: Thermodynamic state +- `constants`: Thermodynamic constants + +# Returns +- `P3ProcessRates` containing all computed rates +""" +@inline function compute_p3_process_rates(p3, ρ, ℳ, 𝒰, constants) + FT = typeof(ρ) + prp = p3.process_rates + T₀ = prp.freezing_temperature + + # Extract from microphysical state (already specific, not density-weighted) + qᶜˡ = ℳ.qᶜˡ + qʳ = ℳ.qʳ + nʳ = ℳ.nʳ + qⁱ = ℳ.qⁱ + nⁱ = ℳ.nⁱ + qᶠ = ℳ.qᶠ + bᶠ = ℳ.bᶠ + qʷⁱ = ℳ.qʷⁱ + + # Rime properties + Fᶠ = safe_divide(qᶠ, qⁱ, zero(FT)) + ρᶠ = safe_divide(qᶠ, bᶠ, FT(400)) + + # Thermodynamic state + T = temperature(𝒰, constants) + qᵛ = 𝒰.moisture_mass_fractions.vapor + + # Saturation vapor mixing ratios using Breeze thermodynamics + qᵛ⁺ˡ = saturation_specific_humidity(T, ρ, constants, PlanarLiquidSurface()) + qᵛ⁺ⁱ = saturation_specific_humidity(T, ρ, constants, PlanarIceSurface()) + + # Cloud droplet number concentration + Nᶜ = p3.cloud.number_concentration + + # ========================================================================= + # Phase 1: Rain processes + # ========================================================================= + autoconv = rain_autoconversion_rate(p3, qᶜˡ, Nᶜ) + accr = rain_accretion_rate(p3, qᶜˡ, qʳ) + rain_evap = rain_evaporation_rate(p3, qʳ, qᵛ, qᵛ⁺ˡ) + rain_self = rain_self_collection_rate(p3, qʳ, nʳ, ρ) + + # ========================================================================= + # Phase 1: Ice deposition/sublimation and melting + # ========================================================================= + dep = ice_deposition_rate(p3, qⁱ, qᵛ, qᵛ⁺ⁱ) + + # Partitioned melting: partial stays on ice, complete goes to rain + melt_rates = ice_melting_rates(p3, qⁱ, nⁱ, qʷⁱ, T, qᵛ, qᵛ⁺ˡ, Fᶠ, ρᶠ) + partial_melt = melt_rates.partial_melting + complete_melt = melt_rates.complete_melting + total_melt = partial_melt + complete_melt + melt_n = ice_melting_number_rate(qⁱ, nⁱ, total_melt) + + # ========================================================================= + # Phase 2: Ice aggregation + # ========================================================================= + agg = ice_aggregation_rate(p3, qⁱ, nⁱ, T) + + # ========================================================================= + # Phase 2: Riming + # ========================================================================= + cloud_rim = cloud_riming_rate(p3, qᶜˡ, qⁱ, T) + cloud_rim_n = cloud_riming_number_rate(qᶜˡ, Nᶜ, cloud_rim) + + rain_rim = rain_riming_rate(p3, qʳ, qⁱ, T) + rain_rim_n = rain_riming_number_rate(qʳ, nʳ, rain_rim) + + # Rime density for new rime + vᵢ = FT(1) # Placeholder fall speed [m/s] + ρᶠ_new = rime_density(p3, T, vᵢ) + + # ========================================================================= + # Phase 2: Shedding and refreezing + # ========================================================================= + shed = shedding_rate(p3, qʷⁱ, qⁱ, T) + shed_n = shedding_number_rate(p3, shed) + refrz = refreezing_rate(p3, qʷⁱ, T) + + # ========================================================================= + # Ice nucleation (deposition nucleation and immersion freezing) + # ========================================================================= + nuc_q, nuc_n = deposition_nucleation_rate(p3, T, qᵛ, qᵛ⁺ⁱ, nⁱ, ρ) + cloud_frz_q, cloud_frz_n = immersion_freezing_cloud_rate(p3, qᶜˡ, Nᶜ, T) + rain_frz_q, rain_frz_n = immersion_freezing_rain_rate(p3, qʳ, nʳ, T) + + # ========================================================================= + # Rime splintering (Hallett-Mossop secondary ice production) + # ========================================================================= + spl_q, spl_n = rime_splintering_rate(p3, cloud_rim, rain_rim, T) + + return P3ProcessRates( + # Phase 1: Rain + autoconv, accr, rain_evap, rain_self, + # Phase 1: Ice + dep, partial_melt, complete_melt, melt_n, + # Phase 2: Aggregation + agg, + # Phase 2: Riming + cloud_rim, cloud_rim_n, rain_rim, rain_rim_n, ρᶠ_new, + # Phase 2: Shedding and refreezing + shed, shed_n, refrz, + # Ice nucleation + nuc_q, nuc_n, cloud_frz_q, cloud_frz_n, rain_frz_q, rain_frz_n, + # Rime splintering + spl_q, spl_n + ) +end + +##### +##### Individual field tendencies +##### +##### These functions combine process rates into tendencies for each prognostic field. +##### Phase 1 processes: autoconversion, accretion, evaporation, deposition, melting +##### Phase 2 processes: aggregation, riming, shedding, refreezing +##### + +""" + tendency_ρqᶜˡ(rates) + +Compute cloud liquid mass tendency from P3 process rates. + +Cloud liquid is consumed by: +- Autoconversion (Phase 1) +- Accretion by rain (Phase 1) +- Riming by ice (Phase 2) +- Immersion freezing (Phase 2) +""" +@inline function tendency_ρqᶜˡ(rates::P3ProcessRates, ρ) + # Phase 1: autoconversion and accretion + # Phase 2: cloud riming by ice, immersion freezing + loss = rates.autoconversion + rates.accretion + rates.cloud_riming + rates.cloud_freezing_mass + return -ρ * loss +end + +""" + tendency_ρqʳ(rates) + +Compute rain mass tendency from P3 process rates. + +Rain gains from: +- Autoconversion (Phase 1) +- Accretion (Phase 1) +- Complete melting (Phase 1) - meltwater that sheds from ice +- Shedding (Phase 2) - liquid coating shed from ice + +Rain loses from: +- Evaporation (Phase 1) +- Riming (Phase 2) +- Immersion freezing (Phase 2) +""" +@inline function tendency_ρqʳ(rates::P3ProcessRates, ρ) + # Phase 1: gains from autoconv, accr, complete_melt; loses from evap + # Phase 2: gains from shedding; loses from riming and freezing + # Note: partial_melting stays on ice as liquid coating, only complete_melting goes to rain + gain = rates.autoconversion + rates.accretion + rates.complete_melting + rates.shedding + loss = -rates.rain_evaporation + rates.rain_riming + rates.rain_freezing_mass # evap is negative + return ρ * (gain - loss) +end + +""" + tendency_ρnʳ(rates, ρ, qᶜˡ, Nc, m_drop) + +Compute rain number tendency from P3 process rates. + +Rain number gains from: +- Autoconversion (Phase 1) +- Complete melting (Phase 1) - new rain drops from melted ice +- Shedding (Phase 2) + +Rain number loses from: +- Self-collection (Phase 1) +- Riming (Phase 2) +- Immersion freezing (Phase 2) +""" +@inline function tendency_ρnʳ(rates::P3ProcessRates, ρ, nⁱ, qⁱ; + m_rain_init = 5e-10) # Initial rain drop mass [kg] + FT = typeof(ρ) + + # Phase 1: New drops from autoconversion + n_from_autoconv = rates.autoconversion / m_rain_init + + # Phase 1: New drops from complete melting (conserve number) + # Only complete_melting produces new rain drops; partial_melting stays on ice + n_from_melt = safe_divide(nⁱ * rates.complete_melting, qⁱ, zero(FT)) + + # Phase 1: Self-collection reduces number (already negative) + # Phase 2: Shedding creates new drops + # Phase 2: Riming removes rain drops (already negative) + + return ρ * (n_from_autoconv + n_from_melt + + rates.rain_self_collection + + rates.shedding_number + + rates.rain_riming_number) +end + +""" + tendency_ρqⁱ(rates) + +Compute ice mass tendency from P3 process rates. + +Ice gains from: +- Deposition (Phase 1) +- Cloud riming (Phase 2) +- Rain riming (Phase 2) +- Refreezing (Phase 2) +- Deposition nucleation (Phase 2) +- Immersion freezing of cloud/rain (Phase 2) +- Rime splintering (Phase 2) + +Ice loses from: +- Partial melting (Phase 1) - becomes liquid coating +- Complete melting (Phase 1) - sheds to rain +""" +@inline function tendency_ρqⁱ(rates::P3ProcessRates, ρ) + # Phase 1: deposition, melting (both partial and complete reduce ice mass) + # Phase 2: riming (cloud + rain), refreezing, nucleation, freezing, splintering + gain = rates.deposition + rates.cloud_riming + rates.rain_riming + rates.refreezing + + rates.nucleation_mass + rates.cloud_freezing_mass + rates.rain_freezing_mass + + rates.splintering_mass + # Total melting reduces ice mass (partial stays as liquid coating, complete sheds) + loss = rates.partial_melting + rates.complete_melting + return ρ * (gain - loss) +end + +""" + tendency_ρnⁱ(rates) + +Compute ice number tendency from P3 process rates. + +Ice number gains from: +- Deposition nucleation (Phase 2) +- Immersion freezing of cloud/rain (Phase 2) +- Rime splintering (Phase 2) + +Ice number loses from: +- Melting (Phase 1) +- Aggregation (Phase 2) +""" +@inline function tendency_ρnⁱ(rates::P3ProcessRates, ρ) + # Gains from nucleation, freezing, splintering + gain = rates.nucleation_number + rates.cloud_freezing_number + + rates.rain_freezing_number + rates.splintering_number + # melting_number and aggregation are already negative (represent losses) + loss_rates = rates.melting_number + rates.aggregation + return ρ * (gain + loss_rates) +end + +""" + tendency_ρqᶠ(rates) + +Compute rime mass tendency from P3 process rates. + +Rime mass gains from: +- Cloud riming (Phase 2) +- Rain riming (Phase 2) +- Refreezing (Phase 2) +- Immersion freezing (frozen cloud/rain becomes rimed ice) (Phase 2) + +Rime mass loses from: +- Melting (proportional to rime fraction) (Phase 1) +""" +@inline function tendency_ρqᶠ(rates::P3ProcessRates, ρ, Fᶠ) + # Phase 2: gains from riming, refreezing, and freezing + # Frozen cloud/rain becomes fully rimed ice (100% rime fraction for new frozen particles) + gain = rates.cloud_riming + rates.rain_riming + rates.refreezing + + rates.cloud_freezing_mass + rates.rain_freezing_mass + # Phase 1: melts proportionally with ice mass + loss = Fᶠ * rates.melting + return ρ * (gain - loss) +end + +""" + tendency_ρbᶠ(rates, Fᶠ, ρᶠ) + +Compute rime volume tendency from P3 process rates. + +Rime volume changes with rime mass: ∂bᶠ/∂t = ∂qᶠ/∂t / ρ_rime +""" +@inline function tendency_ρbᶠ(rates::P3ProcessRates, ρ, Fᶠ, ρᶠ) + FT = typeof(ρ) + + ρᶠ_safe = max(ρᶠ, FT(100)) + ρ_rim_new_safe = max(rates.rime_density_new, FT(100)) + + # Phase 2: Volume gain from new rime (cloud + rain riming + refreezing) + # Use density of new rime for fresh rime, current density for refreezing + volume_gain = (rates.cloud_riming + rates.rain_riming) / ρ_rim_new_safe + + rates.refreezing / ρᶠ_safe + + # Phase 1: Volume loss from melting (proportional to rime fraction) + volume_loss = Fᶠ * rates.melting / ρᶠ_safe + + return ρ * (volume_gain - volume_loss) +end + +""" + tendency_ρzⁱ(rates, ρ, qⁱ, nⁱ, zⁱ) + +Compute ice sixth moment tendency from P3 process rates. + +The sixth moment (reflectivity) changes with: +- Deposition (growth) (Phase 1) +- Melting (loss) (Phase 1) +- Riming (growth) (Phase 2) +- Nucleation (growth) (Phase 2) +- Aggregation (redistribution) (Phase 2) + +This simplified version uses proportional scaling (Z/q ratio). +For more accurate 3-moment treatment, use the version that accepts +the p3 scheme to access tabulated sixth moment integrals. +""" +@inline function tendency_ρzⁱ(rates::P3ProcessRates, ρ, qⁱ, nⁱ, zⁱ) + FT = typeof(ρ) + + # Simplified: Z changes proportionally to mass changes + # More accurate version would use full integral formulation + ratio = safe_divide(zⁱ, qⁱ, zero(FT)) + + # Net mass change for ice + # Total melting (partial + complete) reduces ice mass + total_melting = rates.partial_melting + rates.complete_melting + mass_change = rates.deposition - total_melting + + rates.cloud_riming + rates.rain_riming + rates.refreezing + + return ρ * ratio * mass_change +end + +""" + tendency_ρzⁱ(rates, ρ, qⁱ, nⁱ, zⁱ, Fᶠ, Fˡ, p3) + +Compute ice sixth moment tendency using tabulated integrals when available. + +Following Milbrandt et al. (2021, 2024), the sixth moment tendency is +computed by integrating the contribution of each process over the +size distribution, properly accounting for how different processes +affect particles of different sizes. + +When tabulated integrals are available via `tabulate(p3, arch)`, uses +pre-computed lookup tables. Otherwise, falls back to proportional scaling. + +# Arguments +- `rates`: P3ProcessRates containing mass tendencies +- `ρ`: Air density [kg/m³] +- `qⁱ`: Ice mass mixing ratio [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `zⁱ`: Ice sixth moment [m⁶/kg] +- `Fᶠ`: Rime fraction [-] +- `Fˡ`: Liquid fraction [-] +- `p3`: P3 microphysics scheme (for accessing tabulated integrals) + +# Returns +- Tendency of density-weighted sixth moment [kg/m³ × m⁶/kg / s] +""" +@inline function tendency_ρzⁱ(rates::P3ProcessRates, ρ, qⁱ, nⁱ, zⁱ, Fᶠ, Fˡ, p3) + FT = typeof(ρ) + + # Mean ice particle mass for table lookup + m̄ = safe_divide(qⁱ, nⁱ, FT(1e-20)) + log_mean_mass = log10(max(m̄, FT(1e-20))) + + # Try to use tabulated sixth moment integrals + z_tendency = _tabulated_z_tendency( + p3.ice.sixth_moment, log_mean_mass, Fᶠ, Fˡ, rates, ρ, qⁱ, nⁱ, zⁱ + ) + + return z_tendency +end + +# Tabulated version: use TabulatedFunction3D lookups for each process +@inline function _tabulated_z_tendency(sixth::IceSixthMoment{<:TabulatedFunction3D}, log_m, Fᶠ, Fˡ, rates, ρ, qⁱ, nⁱ, zⁱ) + FT = typeof(ρ) + + # Look up normalized Z contribution for each process + z_dep = sixth.deposition(log_m, Fᶠ, Fˡ) + z_melt = sixth.melt1(log_m, Fᶠ, Fˡ) + sixth.melt2(log_m, Fᶠ, Fˡ) + z_rime = sixth.rime(log_m, Fᶠ, Fˡ) + z_agg = sixth.aggregation(log_m, Fᶠ, Fˡ) + z_shed = sixth.shedding(log_m, Fᶠ, Fˡ) + z_sub = sixth.sublimation(log_m, Fᶠ, Fˡ) + sixth.sublimation1(log_m, Fᶠ, Fˡ) + + # Total melting + total_melting = rates.partial_melting + rates.complete_melting + + # Compute Z tendency from tabulated integrals + # Each integral gives the normalized Z rate per unit mass rate + z_rate = z_dep * rates.deposition + + z_rime * (rates.cloud_riming + rates.rain_riming) + + z_agg * rates.aggregation * safe_divide(qⁱ, nⁱ, FT(1e-12)) + # agg is number rate + z_shed * rates.shedding - + z_melt * total_melting + + # Sublimation (when deposition is negative) + is_sublimating = rates.deposition < 0 + z_rate = z_rate + ifelse(is_sublimating, z_sub * abs(rates.deposition), zero(FT)) + + return ρ * z_rate +end + +# Fallback: use proportional scaling when integrals are not tabulated +@inline function _tabulated_z_tendency(::Any, log_m, Fᶠ, Fˡ, rates, ρ, qⁱ, nⁱ, zⁱ) + # Fall back to the simple proportional scaling + FT = typeof(ρ) + ratio = safe_divide(zⁱ, qⁱ, zero(FT)) + total_melting = rates.partial_melting + rates.complete_melting + mass_change = rates.deposition - total_melting + + rates.cloud_riming + rates.rain_riming + rates.refreezing + return ρ * ratio * mass_change +end + +""" + tendency_ρqʷⁱ(rates) + +Compute liquid on ice tendency from P3 process rates. + +Liquid on ice: +- Gains from partial melting above freezing (meltwater stays on ice) +- Loses from shedding (Phase 2) - liquid sheds to rain +- Loses from refreezing (Phase 2) - liquid refreezes to ice + +Following Milbrandt et al. (2025), partial melting adds to the liquid coating +while complete melting sheds directly to rain. +""" +@inline function tendency_ρqʷⁱ(rates::P3ProcessRates, ρ) + # Gains from partial melting (meltwater stays on ice as liquid coating) + # Loses from shedding (liquid sheds to rain) and refreezing (liquid refreezes) + gain = rates.partial_melting + loss = rates.shedding + rates.refreezing + return ρ * (gain - loss) +end + +##### +##### Fallback methods for Nothing rates +##### +##### These are safety fallbacks that return zero tendency when rates +##### have not been computed (e.g., during incremental development). +##### + +@inline tendency_ρqᶜˡ(::Nothing, ρ) = zero(ρ) +@inline tendency_ρqʳ(::Nothing, ρ) = zero(ρ) +@inline tendency_ρnʳ(::Nothing, ρ, nⁱ, qⁱ; kwargs...) = zero(ρ) +@inline tendency_ρqⁱ(::Nothing, ρ) = zero(ρ) +@inline tendency_ρnⁱ(::Nothing, ρ) = zero(ρ) +@inline tendency_ρqᶠ(::Nothing, ρ, Fᶠ) = zero(ρ) +@inline tendency_ρbᶠ(::Nothing, ρ, Fᶠ, ρᶠ) = zero(ρ) +@inline tendency_ρzⁱ(::Nothing, ρ, qⁱ, nⁱ, zⁱ) = zero(ρ) +@inline tendency_ρqʷⁱ(::Nothing, ρ) = zero(ρ) + +##### +##### Phase 3: Terminal velocities +##### +##### Terminal velocity calculations for rain and ice sedimentation. +##### Uses power-law relationships with air density correction. +##### + +""" + rain_terminal_velocity_mass_weighted(p3, qʳ, nʳ, ρ) + +Compute mass-weighted terminal velocity for rain. + +Uses the power-law relationship v(D) = a × D^b × √(ρ₀/ρ). +See [Seifert and Beheng (2006)](@cite SeifertBeheng2006). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `ρ`: Air density [kg/m³] + +# Returns +- Mass-weighted fall speed [m/s] (positive downward) +""" +@inline function rain_terminal_velocity_mass_weighted(p3, qʳ, nʳ, ρ) + FT = typeof(qʳ) + prp = p3.process_rates + + a = prp.rain_fall_speed_coefficient + b = prp.rain_fall_speed_exponent + ρ₀ = prp.reference_air_density + ρʷ = prp.liquid_water_density + D_min = prp.rain_diameter_min + D_max = prp.rain_diameter_max + v_min = prp.rain_velocity_min + v_max = prp.rain_velocity_max + + qʳ_eff = clamp_positive(qʳ) + nʳ_eff = max(nʳ, FT(1)) + + # Mean rain drop mass + m̄ = qʳ_eff / nʳ_eff + + # Mass-weighted mean diameter: m = (π/6) ρʷ D³ + D̄ₘ = cbrt(6 * m̄ / (FT(π) * ρʷ)) + + # Density correction factor + ρ_correction = sqrt(ρ₀ / ρ) + + # Clamp diameter to physical range + D̄ₘ_clamped = clamp(D̄ₘ, D_min, D_max) + + # Terminal velocity + vₜ = a * D̄ₘ_clamped^b * ρ_correction + + return clamp(vₜ, v_min, v_max) +end + +""" + rain_terminal_velocity_number_weighted(p3, qʳ, nʳ, ρ) + +Compute number-weighted terminal velocity for rain. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qʳ`: Rain mass fraction [kg/kg] +- `nʳ`: Rain number concentration [1/kg] +- `ρ`: Air density [kg/m³] + +# Returns +- Number-weighted fall speed [m/s] (positive downward) +""" +@inline function rain_terminal_velocity_number_weighted(p3, qʳ, nʳ, ρ) + FT = typeof(qʳ) + prp = p3.process_rates + + # Number-weighted velocity is smaller than mass-weighted + ratio = prp.velocity_ratio_number_to_mass + vₘ = rain_terminal_velocity_mass_weighted(p3, qʳ, nʳ, ρ) + + return ratio * vₘ +end + +""" + ice_terminal_velocity_mass_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ; Fˡ=zero(typeof(qⁱ))) + +Compute mass-weighted terminal velocity for ice. + +When tabulated integrals are available (via `tabulate(p3, arch)`), uses +pre-computed lookup tables for accurate size-distribution integration. +Otherwise, uses regime-dependent fall speeds following [Mitchell (1996)](@cite Mitchell1996powerlaws) +and [Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization). + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `Fᶠ`: Rime mass fraction (qᶠ/qⁱ) +- `ρᶠ`: Rime density [kg/m³] +- `ρ`: Air density [kg/m³] +- `Fˡ`: Liquid fraction (optional, for tabulated lookup) + +# Returns +- Mass-weighted fall speed [m/s] (positive downward) +""" +@inline function ice_terminal_velocity_mass_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ; Fˡ=zero(typeof(qⁱ))) + FT = typeof(qⁱ) + prp = p3.process_rates + fs = p3.ice.fall_speed + + ρ₀ = fs.reference_air_density + v_min = prp.ice_velocity_min + v_max = prp.ice_velocity_max + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = max(nⁱ, FT(1)) + + # Mean ice particle mass + m̄ = qⁱ_eff / nⁱ_eff + + # Density correction factor (applied to all fall speeds) + ρ_correction = sqrt(ρ₀ / ρ) + + # Try to use tabulated fall speed if available + vₜ = _tabulated_mass_weighted_fall_speed(fs.mass_weighted, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + + return clamp(vₜ, v_min, v_max) +end + +# Tabulated version: use TabulatedFunction3D lookup +@inline function _tabulated_mass_weighted_fall_speed(table::TabulatedFunction3D, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + FT = typeof(m̄) + # Compute log mean mass (guarding against log(0)) + log_mean_mass = log10(max(m̄, FT(1e-20))) + # Look up normalized velocity from table + vₜ_norm = table(log_mean_mass, Fᶠ, Fˡ) + return vₜ_norm * ρ_correction +end + +# Fallback: use analytical approximation when not tabulated +@inline function _tabulated_mass_weighted_fall_speed(::Any, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + FT = typeof(m̄) + + ρ_eff_unrimed = prp.ice_effective_density_unrimed + D_threshold = prp.ice_diameter_threshold + D_min = prp.ice_diameter_min + D_max = prp.ice_diameter_max + ρᶠ_min = prp.minimum_rime_density + ρᶠ_max = prp.maximum_rime_density + + a_unrimed = prp.ice_fall_speed_coefficient_unrimed + b_unrimed = prp.ice_fall_speed_exponent_unrimed + a_rimed = prp.ice_fall_speed_coefficient_rimed + b_rimed = prp.ice_fall_speed_exponent_rimed + c_small = prp.ice_small_particle_coefficient + + # Effective density depends on riming + Fᶠ_clamped = clamp(Fᶠ, FT(0), FT(1)) + ρᶠ_clamped = clamp(prp.ice_effective_density_unrimed, ρᶠ_min, ρᶠ_max) # Use parameter value + ρ_eff = ρ_eff_unrimed + Fᶠ_clamped * (ρᶠ_clamped - ρ_eff_unrimed) + + # Effective diameter + D̄ₘ = cbrt(6 * m̄ / (FT(π) * ρ_eff)) + D_clamped = clamp(D̄ₘ, D_min, D_max) + + # Coefficients interpolated based on riming + a = a_unrimed + Fᶠ_clamped * (a_rimed - a_unrimed) + b = b_unrimed + Fᶠ_clamped * (b_rimed - b_unrimed) + + # Terminal velocity (large particle regime) + vₜ_large = a * D_clamped^b * ρ_correction + + # Small particle (Stokes) regime + vₜ_small = c_small * D_clamped^2 * ρ_correction + + # Blend between regimes + return ifelse(D_clamped < D_threshold, vₜ_small, vₜ_large) +end + +""" + ice_terminal_velocity_number_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ) + +Compute number-weighted terminal velocity for ice. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `Fᶠ`: Rime mass fraction (qᶠ/qⁱ) +- `ρᶠ`: Rime density [kg/m³] +- `ρ`: Air density [kg/m³] + +# Returns +- Number-weighted fall speed [m/s] (positive downward) +""" +@inline function ice_terminal_velocity_number_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ; Fˡ=zero(typeof(qⁱ))) + FT = typeof(qⁱ) + prp = p3.process_rates + fs = p3.ice.fall_speed + + ρ₀ = fs.reference_air_density + v_min = prp.ice_velocity_min + v_max = prp.ice_velocity_max + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = max(nⁱ, FT(1)) + m̄ = qⁱ_eff / nⁱ_eff + ρ_correction = sqrt(ρ₀ / ρ) + + # Try to use tabulated fall speed if available + vₜ = _tabulated_number_weighted_fall_speed(fs.number_weighted, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + + return clamp(vₜ, v_min, v_max) +end + +# Tabulated version: use TabulatedFunction3D lookup +@inline function _tabulated_number_weighted_fall_speed(table::TabulatedFunction3D, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + FT = typeof(m̄) + log_mean_mass = log10(max(m̄, FT(1e-20))) + vₜ_norm = table(log_mean_mass, Fᶠ, Fˡ) + return vₜ_norm * ρ_correction +end + +# Fallback: use ratio to mass-weighted velocity +@inline function _tabulated_number_weighted_fall_speed(::Any, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + ratio = prp.velocity_ratio_number_to_mass + vₘ = _tabulated_mass_weighted_fall_speed(nothing, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + return ratio * vₘ +end + +""" + ice_terminal_velocity_reflectivity_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ; Fˡ=0) + +Compute reflectivity-weighted (Z-weighted) terminal velocity for ice. + +Needed for the sixth moment (reflectivity) sedimentation in 3-moment P3. +When tabulated integrals are available, uses pre-computed lookup tables. + +# Arguments +- `p3`: P3 microphysics scheme (provides parameters) +- `qⁱ`: Ice mass fraction [kg/kg] +- `nⁱ`: Ice number concentration [1/kg] +- `Fᶠ`: Rime mass fraction (qᶠ/qⁱ) +- `ρᶠ`: Rime density [kg/m³] +- `ρ`: Air density [kg/m³] +- `Fˡ`: Liquid fraction (optional, for tabulated lookup) + +# Returns +- Reflectivity-weighted fall speed [m/s] (positive downward) +""" +@inline function ice_terminal_velocity_reflectivity_weighted(p3, qⁱ, nⁱ, Fᶠ, ρᶠ, ρ; Fˡ=zero(typeof(qⁱ))) + FT = typeof(qⁱ) + prp = p3.process_rates + fs = p3.ice.fall_speed + + ρ₀ = fs.reference_air_density + v_min = prp.ice_velocity_min + v_max = prp.ice_velocity_max + + qⁱ_eff = clamp_positive(qⁱ) + nⁱ_eff = max(nⁱ, FT(1)) + m̄ = qⁱ_eff / nⁱ_eff + ρ_correction = sqrt(ρ₀ / ρ) + + # Try to use tabulated fall speed if available + vₜ = _tabulated_reflectivity_weighted_fall_speed(fs.reflectivity_weighted, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + + return clamp(vₜ, v_min, v_max) +end + +# Tabulated version: use TabulatedFunction3D lookup +@inline function _tabulated_reflectivity_weighted_fall_speed(table::TabulatedFunction3D, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + FT = typeof(m̄) + log_mean_mass = log10(max(m̄, FT(1e-20))) + vₜ_norm = table(log_mean_mass, Fᶠ, Fˡ) + return vₜ_norm * ρ_correction +end + +# Fallback: use ratio to mass-weighted velocity +@inline function _tabulated_reflectivity_weighted_fall_speed(::Any, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + ratio = prp.velocity_ratio_reflectivity_to_mass + vₘ = _tabulated_mass_weighted_fall_speed(nothing, m̄, Fᶠ, Fˡ, ρ_correction, p3, prp) + return ratio * vₘ +end diff --git a/src/Microphysics/PredictedParticleProperties/quadrature.jl b/src/Microphysics/PredictedParticleProperties/quadrature.jl new file mode 100644 index 000000000..e43f2feda --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/quadrature.jl @@ -0,0 +1,993 @@ +##### +##### Quadrature Evaluation of P3 Integrals +##### +##### Numerical integration over the ice size distribution using +##### Chebyshev-Gauss quadrature on a transformed domain. +##### +##### +##### References: +##### - Morrison & Milbrandt (2015a): Fall speed constants, Best number formulation +##### - Mitchell & Heymsfield (2005): Drag coefficients +##### - Heymsfield et al. (2006): Density correction +##### + +export evaluate, chebyshev_gauss_nodes_weights + +# Constants from P3 Fortran implementation (create_p3_lookupTable_1.f90) +# Reference conditions for fall speed parameterization +const P3_REF_T = 253.15 # Reference temperature [K] +const P3_REF_P = 60000.0 # Reference pressure [Pa] +const P3_REF_RHO = P3_REF_P / (287.15 * P3_REF_T) # ≈ 0.825 kg/m³ + +# Dynamic viscosity at reference conditions (Sutherland's law) +# μ = 1.496e-6 * T^1.5 / (T + 120) +const P3_REF_ETA = 1.496e-6 * P3_REF_T^1.5 / (P3_REF_T + 120.0) # ≈ 1.62e-5 Pa s + +# Kinematic viscosity at reference conditions +const P3_REF_NU = P3_REF_ETA / P3_REF_RHO + +# Mitchell & Heymsfield (2005) surface roughness parameters +const MH_δ₀ = 5.83 +const MH_C₀ = 0.6 +const MH_C₁ = 4 / (MH_δ₀^2 * sqrt(MH_C₀)) +const MH_C₂ = MH_δ₀^2 / 4 + +##### +##### Chebyshev-Gauss quadrature +##### + +""" + chebyshev_gauss_nodes_weights(FT, n) + +Compute Chebyshev-Gauss quadrature nodes and weights for n points. + +Chebyshev-Gauss quadrature is particularly well-suited for smooth +integrands over unbounded domains after transformation. The nodes +cluster near the boundaries, which helps capture rapidly-varying +contributions near D = 0. + +Returns `(nodes, weights)` for approximating: + +```math +∫_{-1}^{1} f(x) dx ≈ ∑ᵢ wᵢ f(xᵢ) +``` + +These are then transformed to diameter space using [`transform_to_diameter`](@ref). +""" +function chebyshev_gauss_nodes_weights(FT::Type{<:AbstractFloat}, n::Int) + nodes = zeros(FT, n) + weights = fill(FT(π / n), n) + + for i in 1:n + nodes[i] = cos(FT((2i - 1) * π / (2n))) + end + + return nodes, weights +end + +chebyshev_gauss_nodes_weights(n::Int) = chebyshev_gauss_nodes_weights(Float64, n) + +##### +##### Domain transformation +##### +##### Transform from x ∈ [-1, 1] to D ∈ [0, ∞) using exponential mapping +##### + +""" + transform_to_diameter(x, λ; scale=10) + +Transform Chebyshev node x ∈ [-1, 1] to diameter D ∈ [0, ∞). + +Uses the mapping: +```math +D = \\frac{s}{\\lambda} \\cdot \\frac{1 + x}{1 - x + \\epsilon} +``` + +where s is a scale factor (default 10) that controls the integration range +relative to the characteristic size 1/λ. +""" +@inline function transform_to_diameter(x, λ; scale=10) + ε = eps(typeof(x)) + return scale / λ * (1 + x) / (1 - x + ε) +end + +""" + jacobian_diameter_transform(x, λ; scale=10) + +Jacobian dD/dx for the diameter transformation. +""" +@inline function jacobian_diameter_transform(x, λ; scale=10) + ε = eps(typeof(x)) + denom = (1 - x + ε)^2 + return scale / λ * 2 / denom +end + +##### +##### Generic integration interface +##### + +""" + evaluate(integral, state; n_quadrature=64) + +Evaluate a P3 integral over the ice size distribution using quadrature. + +This is the core numerical integration routine for computing bulk properties +and process rates from the gamma size distribution. Each integral type +dispatches to its own `integrand` function. + +**Algorithm:** + +1. Generate Chebyshev-Gauss nodes on [-1, 1] +2. Transform to diameter space D ∈ [0, ∞) using exponential mapping +3. Evaluate integrand at each quadrature point +4. Sum weighted contributions with Jacobian correction + +# Arguments + +- `integral`: Integral type (e.g., `MassWeightedFallSpeed()`) +- `state`: [`IceSizeDistributionState`](@ref) with N₀, μ, λ and rime properties +- `n_quadrature`: Number of quadrature points (default 64, sufficient for most integrals) + +# Returns + +The evaluated integral value with the same floating-point type as `state.slope`. + +# Example + +```julia +using Breeze.Microphysics.PredictedParticleProperties + +state = IceSizeDistributionState(Float64; intercept=1e6, shape=0.0, slope=1000.0) +Vn = evaluate(NumberWeightedFallSpeed(), state) +``` +""" +function evaluate(integral::AbstractP3Integral, state::IceSizeDistributionState; + n_quadrature::Int = 64) + FT = typeof(state.slope) + nodes, weights = chebyshev_gauss_nodes_weights(FT, n_quadrature) + + λ = state.slope + result = zero(FT) + + for i in 1:n_quadrature + x = nodes[i] + w = weights[i] + + D = transform_to_diameter(x, λ) + J = jacobian_diameter_transform(x, λ) + + # Compute integrand at this diameter + f = integrand(integral, D, state) + + result += w * f * J + end + + return result +end + +##### +##### Integrand functions for each integral type +##### + +# Default fallback +integrand(::AbstractP3Integral, D, state) = zero(D) + +##### +##### Fall speed integrals +##### + +""" + terminal_velocity(D, state) + +Terminal velocity V(D) for ice particles following Mitchell and Heymsfield (2005). + +The fall speed is calculated using the Best number formulation, which accounts for +particle mass, projected area, and air properties. A density correction factor +`(ρ₀/ρ)^0.54` is applied following Heymsfield et al. (2006). + +For mixed-phase particles (with liquid fraction Fˡ), the velocity is a linear +interpolation between the ice fall speed and rain fall speed: +`V = Fˡ * V_rain + (1 - Fˡ) * V_ice` +""" +@inline function terminal_velocity(D, state::IceSizeDistributionState) + FT = typeof(D) + Fˡ = state.liquid_fraction + + # Calculate ice fall speed (Mitchell & Heymsfield 2005) + # Uses mass/area of the ice portion only + m_ice = particle_mass_ice_only(D, state) + A_ice = particle_area_ice_only(D, state) + V_ice = ice_fall_speed_mh2005(D, state, m_ice, A_ice) + + # Apply density correction to ice fall speed + ρ = state.air_density + ρ₀ = state.reference_air_density + ρ_correction = (ρ₀ / max(ρ, FT(0.1)))^FT(0.54) + V_ice_corr = V_ice * ρ_correction + + # Calculate rain fall speed (if needed) + if Fˡ > eps(FT) + # Rain fall speed includes density correction internally + V_rain = rain_fall_speed(D, ρ_correction) + return Fˡ * V_rain + (1 - Fˡ) * V_ice_corr + else + return V_ice_corr + end +end + +""" + ice_fall_speed_mh2005(D, state, m, A) + +Compute terminal velocity of ice particle using Mitchell & Heymsfield (2005). +Calculates velocity at reference conditions (P3_REF_T, P3_REF_P). +""" +@inline function ice_fall_speed_mh2005(D, state::IceSizeDistributionState, m, A) + FT = typeof(D) + g = FT(9.81) + + # Reference properties + ρ_ref = FT(P3_REF_RHO) + η_ref = FT(P3_REF_ETA) # dynamic + ν_ref = FT(P3_REF_NU) # kinematic + + # Avoid division by zero + A_safe = max(A, eps(FT)) + + # Best number X at reference conditions + # X = 2 m g ρ D^2 / (A η^2) + X = 2 * m * g * ρ_ref * D^2 / (A_safe * η_ref^2) + + # Limit X for numerical stability (and to match Fortran checks?) + X = max(X, FT(1e-20)) + + # MH2005 drag terms (a0=0, b0=0 branch for aggregates) + X_sqrt = sqrt(X) + C1_X_sqrt = MH_C₁ * X_sqrt + term = sqrt(1 + C1_X_sqrt) + + # b₁ = (C₁ √X) / (2 (√(1+C₁√X)-1) √(1+C₁√X)) + denom_b = 2 * (term - 1) * term + b₁ = C1_X_sqrt / max(denom_b, eps(FT)) + + # a₁ = C₂ (√(1+C₁√X)-1)² / X^b₁ + # Note: X^b1 can be small. + # Fortran computes `xx**b1` then `a1 = ... / xx**b1` + + # If X is very small (Stokes regime), b1 -> 1, a1 -> ? + # Let's handle small X explicitly to avoid singularities + if X < 1e-5 + # Stokes flow: V = m g / (3 π η D) + # We can just return Stokes velocity + return m * g / (3 * FT(π) * η_ref * D) + end + + a₁ = MH_C₂ * (term - 1)^2 / X^b₁ + + # Velocity formula derived from MH2005 power law fit Re = a X^b + # V = a₁ * ν^(1-2b₁) * (2 m g / (ρ A))^b₁ * D^(2b₁ - 1) + + term_bracket = 2 * m * g / (ρ_ref * A_safe) + + V_ref = a₁ * ν_ref^(1 - 2*b₁) * term_bracket^b₁ * D^(2*b₁ - 1) + + return V_ref +end + +""" + rain_fall_speed(D, ρ_correction) + +Compute rain fall speed using piecewise power laws from P3 Fortran. +""" +@inline function rain_fall_speed(D, ρ_correction) + FT = typeof(D) + + # Mass of water sphere in GRAMS for the formula + # ρ_w = 997 kg/m³ + m_kg = (FT(π)/6) * FT(997) * D^3 + m_g = m_kg * 1000 + + # Formulas give V in cm/s + if D <= 134.43e-6 + V_cm = 4.5795e5 * m_g^(2/3) + elseif D < 1511.64e-6 + V_cm = 4.962e3 * m_g^(1/3) + elseif D < 3477.84e-6 + V_cm = 1.732e3 * m_g^(1/6) + else + V_cm = FT(917.0) + end + + return V_cm * FT(0.01) * ρ_correction +end + +""" + particle_mass_ice_only(D, state) + +Mass of the ice portion of the particle (ignoring liquid water). +Used for fall speed calculation of the ice component. +""" +@inline function particle_mass_ice_only(D, state::IceSizeDistributionState) + FT = typeof(D) + α = state.mass_coefficient + β = state.mass_exponent + ρᵢ = state.ice_density + + thresholds = regime_thresholds_from_state(D, state) + + # Regime 1: small spheres + a₁ = ρᵢ * FT(π) / 6 + b₁ = FT(3) + + # Regime 2: aggregates + a₂ = FT(α) + b₂ = FT(β) + + # Regime 3: graupel + a₃ = thresholds.ρ_graupel * FT(π) / 6 + b₃ = FT(3) + + # Regime 4: partially rimed + # Use safe rime fraction for coefficient calculation + Fᶠ_safe = min(state.rime_fraction, FT(1) - eps(FT)) + a₄ = FT(α) / (1 - Fᶠ_safe) + b₄ = FT(β) + + is_regime_4 = D ≥ thresholds.partial_rime + is_regime_3 = D ≥ thresholds.graupel + is_regime_2 = D ≥ thresholds.spherical + + a = a₁ + b = b₁ + + a = ifelse(is_regime_2, a₂, a) + b = ifelse(is_regime_2, b₂, b) + + a = ifelse(is_regime_3, a₃, a) + b = ifelse(is_regime_3, b₃, b) + + a = ifelse(is_regime_4, a₄, a) + b = ifelse(is_regime_4, b₄, b) + + return a * D^b +end + +""" + particle_area_ice_only(D, state) + +Projected area of the ice portion of the particle. +""" +@inline function particle_area_ice_only(D, state::IceSizeDistributionState) + FT = typeof(D) + Fᶠ = state.rime_fraction + + thresholds = regime_thresholds_from_state(D, state) + + # Spherical area + A_sphere = FT(π) / 4 * D^2 + + # Aggregate area + γ = FT(0.2285) + σ = FT(1.88) + A_aggregate = γ * D^σ + + is_small = D < thresholds.spherical + is_graupel = D ≥ thresholds.graupel + + A_intermediate = (1 - Fᶠ) * A_aggregate + Fᶠ * A_sphere + + A = ifelse(is_small, A_sphere, A_intermediate) + A = ifelse(is_graupel, A_sphere, A) + + return A +end + +""" + regime_thresholds_from_state(D, state) + +Compute ice regime thresholds from the state's mass-diameter parameters. +Returns an IceRegimeThresholds struct with spherical, graupel, partial_rime thresholds. +""" +@inline function regime_thresholds_from_state(D, state::IceSizeDistributionState) + FT = typeof(D) + α = state.mass_coefficient + β = state.mass_exponent + ρᵢ = state.ice_density + Fᶠ = state.rime_fraction + ρᶠ = state.rime_density + + # Regime 1 threshold: D where power law equals sphere + # (π/6) ρᵢ D³ = α D^β → D = (6α / (π ρᵢ))^(1/(3-β)) + D_spherical = (6 * α / (FT(π) * ρᵢ))^(1 / (3 - β)) + + # For unrimed ice, graupel and partial rime thresholds are infinite + is_unrimed = Fᶠ < FT(1e-10) + + # Safe rime fraction for rimed calculations + Fᶠ_safe = max(Fᶠ, FT(1e-10)) + + # Deposited ice density (Eq. 16 from MM15a) + k = (1 - Fᶠ_safe)^(-1 / (3 - β)) + num = ρᶠ * Fᶠ_safe + den = (β - 2) * (k - 1) / ((1 - Fᶠ_safe) * k - 1) - (1 - Fᶠ_safe) + ρ_dep = num / max(den, FT(1e-10)) + + # Graupel density + ρ_g = Fᶠ_safe * ρᶠ + (1 - Fᶠ_safe) * ρ_dep + + # Graupel threshold + D_graupel_calc = (6 * α / (FT(π) * ρ_g))^(1 / (3 - β)) + + # Partial rime threshold + D_partial_calc = (6 * α / (FT(π) * ρ_g * (1 - Fᶠ_safe)))^(1 / (3 - β)) + + D_graupel = ifelse(is_unrimed, FT(Inf), D_graupel_calc) + D_partial = ifelse(is_unrimed, FT(Inf), D_partial_calc) + ρ_graupel = ifelse(is_unrimed, ρᵢ, ρ_g) + + return (spherical = D_spherical, graupel = D_graupel, partial_rime = D_partial, ρ_graupel = ρ_graupel) +end + +# Number-weighted fall speed: ∫ V(D) N'(D) dD +@inline function integrand(::NumberWeightedFallSpeed, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + Np = size_distribution(D, state) + return V * Np +end + +# Mass-weighted fall speed: ∫ V(D) m(D) N'(D) dD +@inline function integrand(::MassWeightedFallSpeed, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + m = particle_mass(D, state) + Np = size_distribution(D, state) + return V * m * Np +end + +# Reflectivity-weighted fall speed: ∫ V(D) D^6 N'(D) dD +@inline function integrand(::ReflectivityWeightedFallSpeed, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + Np = size_distribution(D, state) + return V * D^6 * Np +end + +##### +##### Particle mass +##### + +""" + particle_mass(D, state) + +Particle mass m(D) as a function of diameter. + +Includes the mass of the ice portion (from P3 4-regime m-D relationships) +plus any liquid water coating (from liquid fraction Fˡ). + +`m(D) = (1 - Fˡ) * m_ice(D) + Fˡ * m_liquid(D)` + +where m_liquid is the mass of a water sphere. +""" +@inline function particle_mass(D, state::IceSizeDistributionState) + FT = typeof(D) + Fˡ = state.liquid_fraction + + # Calculate ice mass (unmodified by liquid fraction) + m_ice = particle_mass_ice_only(D, state) + + # Liquid mass (sphere) + # ρ_w = 1000 kg/m³ (from P3 Fortran) + m_liquid = FT(π)/6 * 1000 * D^3 + + return (1 - Fˡ) * m_ice + Fˡ * m_liquid +end + + +##### +##### Deposition/ventilation integrals +##### + +""" + ventilation_factor(D, state, constant_term) + +Ventilation factor f_v for vapor diffusion enhancement following Hall & Pruppacher (1976). + +The ventilation factor accounts for enhanced mass transfer due to air flow +around falling particles. For a particle of diameter D falling at velocity V: + +f_v = a_v + b_v × Re^(1/2) × Sc^(1/3) + +where Re = V×D/ν is the Reynolds number and Sc = ν/D_v is the Schmidt number. +For typical atmospheric conditions with Sc^(1/3) ≈ 0.9: + +- Small particles (D ≤ 100 μm): f_v ≈ 1.0 (diffusion-limited) +- Large particles (D > 100 μm): f_v = 0.65 + 0.44 × √(V × D / ν) + +This function returns either the constant term (0.65) or the Reynolds-dependent +term (0.44 × √(V×D)) depending on the `constant_term` argument, allowing +separation for integral evaluation. +""" +@inline function ventilation_factor(D, state::IceSizeDistributionState, constant_term) + FT = typeof(D) + V = terminal_velocity(D, state) + + # Kinematic viscosity of air (approximately 1.5e-5 m²/s at typical conditions) + ν = FT(1.5e-5) + + D_threshold = FT(100e-6) + is_small = D ≤ D_threshold + + # Small particles: no ventilation enhancement (f_v = 1) + # constant_term=true → 1, constant_term=false → 0 + small_value = ifelse(constant_term, one(FT), zero(FT)) + + # Large particles: f_v = 0.65 + 0.44 × √(V × D / ν) + # constant_term=true → 0.65, constant_term=false → 0.44 × √(V × D / ν) + Re_term = sqrt(V * D / ν) + large_value = ifelse(constant_term, FT(0.65), FT(0.44) * Re_term) + + return ifelse(is_small, small_value, large_value) +end + +# Backwards compatibility wrapper +@inline ventilation_factor(D, state; constant_term=true) = ventilation_factor(D, state, constant_term) + +# Basic ventilation: ∫ fᵛᵉ(D) C(D) N'(D) dD +@inline function integrand(::Ventilation, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=true) + C = capacitance(D, state) + Np = size_distribution(D, state) + return fᵛᵉ * C * Np +end + +@inline function integrand(::VentilationEnhanced, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=false) + C = capacitance(D, state) + Np = size_distribution(D, state) + return fᵛᵉ * C * Np +end + +# Size-regime-specific ventilation for melting +@inline function integrand(::SmallIceVentilationConstant, D, state::IceSizeDistributionState) + thresholds = regime_thresholds_from_state(D, state) + D_crit = thresholds.spherical + fᵛᵉ = ventilation_factor(D, state; constant_term=true) + C = capacitance(D, state) + Np = size_distribution(D, state) + contribution = fᵛᵉ * C * Np + return ifelse(D ≤ D_crit, contribution, zero(D)) +end + +@inline function integrand(::SmallIceVentilationReynolds, D, state::IceSizeDistributionState) + thresholds = regime_thresholds_from_state(D, state) + D_crit = thresholds.spherical + fᵛᵉ = ventilation_factor(D, state; constant_term=false) + C = capacitance(D, state) + Np = size_distribution(D, state) + contribution = fᵛᵉ * C * Np + return ifelse(D ≤ D_crit, contribution, zero(D)) +end + +@inline function integrand(::LargeIceVentilationConstant, D, state::IceSizeDistributionState) + thresholds = regime_thresholds_from_state(D, state) + D_crit = thresholds.spherical + fᵛᵉ = ventilation_factor(D, state; constant_term=true) + C = capacitance(D, state) + Np = size_distribution(D, state) + contribution = fᵛᵉ * C * Np + return ifelse(D > D_crit, contribution, zero(D)) +end + +@inline function integrand(::LargeIceVentilationReynolds, D, state::IceSizeDistributionState) + thresholds = regime_thresholds_from_state(D, state) + D_crit = thresholds.spherical + fᵛᵉ = ventilation_factor(D, state; constant_term=false) + C = capacitance(D, state) + Np = size_distribution(D, state) + contribution = fᵛᵉ * C * Np + return ifelse(D > D_crit, contribution, zero(D)) +end + +""" + capacitance(D, state) + +Capacitance C(D) for vapor diffusion following regime-dependent formulation. + +The capacitance determines the rate of vapor exchange with ice particles: +- Small spherical ice (D < D_th): C = D/2 (sphere) +- Large ice crystals/aggregates: C ≈ 0.48 × D (non-spherical) +- Heavily rimed (graupel): C = D/2 (approximately spherical) + +For non-spherical particles, the capacitance is approximated as that of +an oblate spheroid with aspect ratio typical of vapor-grown crystals. +See Pruppacher & Klett (1997) Chapter 13. +""" +@inline function capacitance(D, state::IceSizeDistributionState) + FT = typeof(D) + Fᶠ = state.rime_fraction + + # Get regime thresholds + thresholds = regime_thresholds_from_state(D, state) + + # Sphere capacitance + C_sphere = D / 2 + + # Non-spherical capacitance (oblate spheroid approximation) + # Typical aspect ratio of 0.6 gives C ≈ 0.48 D + C_nonspherical = FT(0.48) * D + + # Small spherical ice + is_small = D < thresholds.spherical + + # Heavily rimed particles become more spherical + # Graupel and heavily rimed particles: use spherical capacitance + is_graupel = D ≥ thresholds.graupel + + # Interpolate based on rime fraction for intermediate regime + # More rime → more spherical + C_intermediate = (1 - Fᶠ) * C_nonspherical + Fᶠ * C_sphere + + # Select based on regime + C = ifelse(is_small, C_sphere, C_intermediate) + C = ifelse(is_graupel, C_sphere, C) + + return C +end + +##### +##### Bulk property integrals +##### + +# Effective radius: ∫ D³ N'(D) dD / ∫ D² N'(D) dD +# (computed as ratio of two integrals - here we return numerator) +@inline function integrand(::EffectiveRadius, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + return D^3 * Np +end + +# Mean diameter: ∫ D m(D) N'(D) dD +@inline function integrand(::MeanDiameter, D, state::IceSizeDistributionState) + m = particle_mass(D, state) + Np = size_distribution(D, state) + return D * m * Np +end + +# Mean density: ∫ ρ(D) m(D) N'(D) dD +@inline function integrand(::MeanDensity, D, state::IceSizeDistributionState) + m = particle_mass(D, state) + ρ = particle_density(D, state) + Np = size_distribution(D, state) + return ρ * m * Np +end + +# Reflectivity: ∫ D^6 N'(D) dD +@inline function integrand(::Reflectivity, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + return D^6 * Np +end + +# Slope parameter λ - diagnostic, not an integral +@inline integrand(::SlopeParameter, D, state::IceSizeDistributionState) = zero(D) + +# Shape parameter μ - diagnostic, not an integral +@inline integrand(::ShapeParameter, D, state::IceSizeDistributionState) = zero(D) + +# Shedding rate: integral over particles above melting threshold +@inline function integrand(::SheddingRate, D, state::IceSizeDistributionState) + m = particle_mass(D, state) + Np = size_distribution(D, state) + Fˡ = state.liquid_fraction + return Fˡ * m * Np # Simplified: liquid fraction times mass +end + +""" + particle_density(D, state) + +Particle effective density ρ(D) as a function of diameter. + +The density is computed from the mass and volume: +ρ_eff(D) = m(D) / V(D) = m(D) / [(π/6) D³] + +This gives regime-dependent effective densities: +- Small spherical ice: ρ_eff = ρᵢ = 917 kg/m³ +- Aggregates: ρ_eff = 6α D^(β-3) / π (decreases with size for β < 3) +- Graupel: ρ_eff = ρ_g +- Partially rimed: ρ_eff = 6α D^(β-3) / [π(1-Fᶠ)] +""" +@inline function particle_density(D, state::IceSizeDistributionState) + FT = typeof(D) + + # Get particle mass from regime-dependent formulation + m = particle_mass(D, state) + + # Particle volume (sphere) + V = FT(π) / 6 * D^3 + + # Effective density = mass / volume + # Clamp to avoid unrealistic values + ρ_eff = m / max(V, eps(FT)) + + # Clamp to physical range [50, 1000] kg/m³ (upper bound 1000 for liquid water) + return clamp(ρ_eff, FT(50), FT(1000)) +end + +##### +##### Collection integrals +##### + +""" + collision_kernel(D₁, D₂, state, E_coll) + +Collision kernel K(D₁,D₂) for ice-ice aggregation following Morrison & Milbrandt (2015a). + +K(D₁,D₂) = E_coll × (π/4)(D₁+D₂)² × |V(D₁) - V(D₂)| + +where: +- E_coll is the collection efficiency (typically 0.1-1.0 for aggregation) +- (π/4)(D₁+D₂)² is the geometric sweep-out cross-section +- |V(D₁) - V(D₂)| is the differential fall speed +""" +@inline function collision_kernel(D₁, D₂, state::IceSizeDistributionState, E_coll) + FT = typeof(D₁) + + # Terminal velocities at each diameter + V₁ = terminal_velocity(D₁, state) + V₂ = terminal_velocity(D₂, state) + + # Differential fall speed + ΔV = abs(V₁ - V₂) + + # Geometric sweep-out area: π/4 × (D₁ + D₂)² + A_sweep = FT(π) / 4 * (D₁ + D₂)^2 + + return E_coll * A_sweep * ΔV +end + +""" + evaluate_double_integral(state, kernel_func; n_quadrature=32) + +Evaluate a double integral ∫∫ kernel_func(D₁, D₂, state) N'(D₁) N'(D₂) dD₁ dD₂ +using 2D Chebyshev-Gauss quadrature. + +This is used for collection integrals (aggregation, self-collection) that require +integration over pairs of particle sizes. +""" +function evaluate_double_integral(state::IceSizeDistributionState, kernel_func; + n_quadrature::Int = 32) + FT = typeof(state.slope) + nodes, weights = chebyshev_gauss_nodes_weights(FT, n_quadrature) + + λ = state.slope + result = zero(FT) + + for i in 1:n_quadrature + x₁ = nodes[i] + w₁ = weights[i] + D₁ = transform_to_diameter(x₁, λ) + J₁ = jacobian_diameter_transform(x₁, λ) + N₁ = size_distribution(D₁, state) + + for j in 1:n_quadrature + x₂ = nodes[j] + w₂ = weights[j] + D₂ = transform_to_diameter(x₂, λ) + J₂ = jacobian_diameter_transform(x₂, λ) + N₂ = size_distribution(D₂, state) + + # Kernel value + K = kernel_func(D₁, D₂, state) + + result += w₁ * w₂ * K * N₁ * N₂ * J₁ * J₂ + end + end + + return result +end + +""" + aggregation_kernel(D₁, D₂, state) + +Aggregation kernel for ice-ice self-collection. +""" +@inline function aggregation_kernel(D₁, D₂, state::IceSizeDistributionState) + FT = typeof(D₁) + E_agg = FT(0.1) # Default aggregation efficiency (temperature-dependent in full model) + return collision_kernel(D₁, D₂, state, E_agg) +end + +# Aggregation number: ∫∫ K(D₁,D₂) N'(D₁) N'(D₂) dD₁ dD₂ +# Using approximation from Wisner et al. (1972) for computational efficiency: +# I_agg ≈ ∫ V(D) A(D) N(D)² dD × scale_factor +# This is the self-collection form used in most bulk schemes +@inline function integrand(::AggregationNumber, D, state::IceSizeDistributionState) + FT = typeof(D) + V = terminal_velocity(D, state) + A = particle_area(D, state) + Np = size_distribution(D, state) + + # Aggregation efficiency (simplified) + E_agg = FT(0.1) + + # Self-collection approximation: ∫ E_agg × V × A × N² dD + return E_agg * V * A * Np^2 +end + +""" + evaluate_aggregation_integral(state; n_quadrature=32) + +Evaluate the full 2D aggregation integral using proper collision kernel. +This is more accurate than the 1D approximation but slower. + +∫∫ (1/2) K_agg(D₁,D₂) N'(D₁) N'(D₂) dD₁ dD₂ + +The factor of 1/2 avoids double-counting symmetric collisions. +""" +function evaluate_aggregation_integral(state::IceSizeDistributionState; + n_quadrature::Int = 32) + return evaluate_double_integral(state, aggregation_kernel; n_quadrature) / 2 +end + +# Rain collection by ice (riming kernel) +# ∫ E_rim × V(D) × A(D) × N'(D) dD +@inline function integrand(::RainCollectionNumber, D, state::IceSizeDistributionState) + FT = typeof(D) + V = terminal_velocity(D, state) + A = particle_area(D, state) + Np = size_distribution(D, state) + + # Collection efficiency for rain-ice (typically higher than ice-ice) + E_rim = FT(1.0) + + return E_rim * V * A * Np +end + +""" + riming_kernel(D_ice, D_drop, V_ice, V_drop, E_rim) + +Riming kernel for ice-droplet collection. + +K = E_rim × (π/4)(D_ice + D_drop)² × |V_ice - V_drop| + +For riming, the collection efficiency E_rim ≈ 1 for large ice collecting +small cloud droplets, but decreases for small ice or large rain drops. +""" +@inline function riming_kernel(D_ice, D_drop, V_ice, V_drop, E_rim) + FT = typeof(D_ice) + A_sweep = FT(π) / 4 * (D_ice + D_drop)^2 + ΔV = abs(V_ice - V_drop) + return E_rim * A_sweep * ΔV +end + +""" + particle_area(D, state) + +Projected cross-sectional area A(D) for ice particles. + +Includes liquid fraction weighting for mixed-phase particles. +""" +@inline function particle_area(D, state::IceSizeDistributionState) + FT = typeof(D) + Fˡ = state.liquid_fraction + + # Calculate ice area (unmodified by liquid fraction) + A_ice = particle_area_ice_only(D, state) + + # Liquid area (sphere) + A_liquid = FT(π)/4 * D^2 + + return (1 - Fˡ) * A_ice + Fˡ * A_liquid +end + +##### +##### Sixth moment integrals +##### + +# Sixth moment rime tendency +@inline function integrand(::SixthMomentRime, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + return D^6 * Np +end + +# Sixth moment deposition tendencies +@inline function integrand(::SixthMomentDeposition, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=true) + C = capacitance(D, state) + Np = size_distribution(D, state) + return 6 * D^5 * fᵛᵉ * C * Np +end + +@inline function integrand(::SixthMomentDeposition1, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=false) + C = capacitance(D, state) + Np = size_distribution(D, state) + return 6 * D^5 * fᵛᵉ * C * Np +end + +# Sixth moment melting tendencies +@inline function integrand(::SixthMomentMelt1, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + return 6 * D^5 * Np +end + +@inline function integrand(::SixthMomentMelt2, D, state::IceSizeDistributionState) + m = particle_mass(D, state) + Np = size_distribution(D, state) + return D^6 / m * Np +end + +# Sixth moment aggregation +@inline function integrand(::SixthMomentAggregation, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + A = particle_area(D, state) + Np = size_distribution(D, state) + return D^6 * V * A * Np^2 +end + +# Sixth moment shedding +@inline function integrand(::SixthMomentShedding, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + Fˡ = state.liquid_fraction + return Fˡ * D^6 * Np +end + +# Sixth moment sublimation tendencies +@inline function integrand(::SixthMomentSublimation, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=true) + C = capacitance(D, state) + Np = size_distribution(D, state) + return 6 * D^5 * fᵛᵉ * C * Np +end + +@inline function integrand(::SixthMomentSublimation1, D, state::IceSizeDistributionState) + fᵛᵉ = ventilation_factor(D, state; constant_term=false) + C = capacitance(D, state) + Np = size_distribution(D, state) + return 6 * D^5 * fᵛᵉ * C * Np +end + +##### +##### Lambda limiter integrals +##### + +@inline function integrand(::NumberMomentLambdaLimit, D, state::IceSizeDistributionState) + Np = size_distribution(D, state) + return Np +end + +@inline function integrand(::MassMomentLambdaLimit, D, state::IceSizeDistributionState) + m = particle_mass(D, state) + Np = size_distribution(D, state) + return m * Np +end + +##### +##### Rain integrals +##### + +@inline integrand(::RainShapeParameter, D, state) = zero(D) +@inline integrand(::RainVelocityNumber, D, state) = zero(D) +@inline integrand(::RainVelocityMass, D, state) = zero(D) +@inline integrand(::RainEvaporation, D, state) = zero(D) + +##### +##### Ice-rain collection integrals +##### + +@inline function integrand(::IceRainMassCollection, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + A = particle_area(D, state) + m = particle_mass(D, state) + Np = size_distribution(D, state) + return V * A * m * Np +end + +@inline function integrand(::IceRainNumberCollection, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + A = particle_area(D, state) + Np = size_distribution(D, state) + return V * A * Np +end + +@inline function integrand(::IceRainSixthMomentCollection, D, state::IceSizeDistributionState) + V = terminal_velocity(D, state) + A = particle_area(D, state) + Np = size_distribution(D, state) + return D^6 * V * A * Np +end diff --git a/src/Microphysics/PredictedParticleProperties/rain_properties.jl b/src/Microphysics/PredictedParticleProperties/rain_properties.jl new file mode 100644 index 000000000..1019cadef --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/rain_properties.jl @@ -0,0 +1,85 @@ +##### +##### Rain Properties +##### +##### Rain particle properties and integrals for the P3 scheme. +##### + +""" + RainProperties + +Rain particle size distribution and fall speed parameters. +See [`RainProperties`](@ref) constructor for details. +""" +struct RainProperties{FT, MU, VN, VM, EV} + maximum_mean_diameter :: FT + fall_speed_coefficient :: FT + fall_speed_exponent :: FT + shape_parameter :: MU + velocity_number :: VN + velocity_mass :: VM + evaporation :: EV +end + +""" +$(TYPEDSIGNATURES) + +Construct `RainProperties` with parameters and quadrature-based integrals. + +Rain in P3 follows a gamma size distribution similar to ice: + +```math +N'(D) = N₀ D^{μ_r} e^{-λ_r D} +``` + +The shape parameter ``μ_r`` is diagnosed from the rain mass and number +concentrations following [Milbrandt and Yau (2005)](@cite MilbrandtYau2005). + +**Terminal velocity:** + +```math +V(D) = a_V D^{b_V} +``` + +Default coefficients give fall speeds in m/s for D in meters. + +**Integrals:** + +- `shape_parameter`: Diagnosed μ_r from q_r, N_r +- `velocity_number`, `velocity_mass`: Weighted fall speeds +- `evaporation`: Rate integral for rain evaporation + +# Keyword Arguments + +- `maximum_mean_diameter`: Upper Dm limit [m], default 6×10⁻³ (6 mm) +- `fall_speed_coefficient`: aᵥ [m^{1-b}/s], default 4854 +- `fall_speed_exponent`: bᵥ [-], default 1.0 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization), +[Milbrandt and Yau (2005)](@cite MilbrandtYau2005), +[Seifert and Beheng (2006)](@cite SeifertBeheng2006). +""" +function RainProperties(FT::Type{<:AbstractFloat} = Float64; + maximum_mean_diameter = 6e-3, + fall_speed_coefficient = 4854, + fall_speed_exponent = 1) + return RainProperties( + FT(maximum_mean_diameter), + FT(fall_speed_coefficient), + FT(fall_speed_exponent), + RainShapeParameter(), + RainVelocityNumber(), + RainVelocityMass(), + RainEvaporation() + ) +end + +Base.summary(::RainProperties) = "RainProperties" + +function Base.show(io::IO, r::RainProperties) + print(io, summary(r), "(") + print(io, "Dmax=", r.maximum_mean_diameter, ", ") + print(io, "aᵥ=", r.fall_speed_coefficient, ", ") + print(io, "bᵥ=", r.fall_speed_exponent, ")") +end diff --git a/src/Microphysics/PredictedParticleProperties/size_distribution.jl b/src/Microphysics/PredictedParticleProperties/size_distribution.jl new file mode 100644 index 000000000..984e43fd8 --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/size_distribution.jl @@ -0,0 +1,224 @@ +##### +##### Ice Size Distribution +##### +##### The P3 scheme uses a generalized gamma distribution for ice particles. +##### + +""" + IceSizeDistributionState + +State container for ice size distribution integration. +See [`IceSizeDistributionState`](@ref) constructor for details. +""" +struct IceSizeDistributionState{FT} + intercept :: FT + shape :: FT + slope :: FT + rime_fraction :: FT + liquid_fraction :: FT + rime_density :: FT + # Mass-diameter power law parameters (α, β) from m = α D^β + mass_coefficient :: FT + mass_exponent :: FT + ice_density :: FT + # Reference air density for fall speed correction + reference_air_density :: FT + air_density :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct an `IceSizeDistributionState` for quadrature evaluation. + +The ice particle size distribution follows a generalized gamma form: + +```math +N'(D) = N_0 D^μ e^{-λD} +``` + +The gamma distribution is parameterized by three quantities: + +- **N₀** (intercept): Sets the total number of particles +- **μ** (shape): Controls the relative abundance of small vs. large particles +- **λ** (slope): Sets the characteristic inverse diameter + +For P3, these are determined from prognostic moments using the +[`distribution_parameters`](@ref) function. + +**Rime and liquid properties** affect the mass-diameter relationship: + +- `rime_fraction`: Fraction of mass that is rime (0 = pristine, 1 = graupel) +- `rime_density`: Density of the accreted rime layer +- `liquid_fraction`: Liquid water coating from partial melting + +# Required Keyword Arguments + +- `intercept`: N₀ [m^{-(4+μ)}] +- `shape`: μ [-] +- `slope`: λ [1/m] + +# Optional Keyword Arguments + +- `rime_fraction`: Fᶠ [-], default 0 (unrimed) +- `liquid_fraction`: Fˡ [-], default 0 (no meltwater) +- `rime_density`: ρᶠ [kg/m³], default 400 +- `mass_coefficient`: α in m = α D^β [kg/m^β], default 0.0121 +- `mass_exponent`: β in m = α D^β [-], default 1.9 +- `ice_density`: Pure ice density [kg/m³], default 917 +- `reference_air_density`: ρ₀ for fall speed correction [kg/m³], default 1.225 +- `air_density`: Local air density [kg/m³], default 1.225 + +# References + +[Morrison and Milbrandt (2015a)](@cite Morrison2015parameterization) Section 2b. +""" +function IceSizeDistributionState(FT::Type{<:AbstractFloat} = Float64; + intercept, + shape, + slope, + rime_fraction = zero(FT), + liquid_fraction = zero(FT), + rime_density = FT(400), + mass_coefficient = FT(0.0121), + mass_exponent = FT(1.9), + ice_density = FT(917), + reference_air_density = FT(1.225), + air_density = FT(1.225)) + return IceSizeDistributionState( + FT(intercept), + FT(shape), + FT(slope), + FT(rime_fraction), + FT(liquid_fraction), + FT(rime_density), + FT(mass_coefficient), + FT(mass_exponent), + FT(ice_density), + FT(reference_air_density), + FT(air_density) + ) +end + +""" + size_distribution(D, state::IceSizeDistributionState) + +Evaluate the ice size distribution ``N'(D)`` at diameter D. + +Returns the number density of particles per unit diameter interval: + +```math +N'(D) = N_0 D^μ e^{-λD} +``` + +The total number concentration is ``N = ∫_0^∞ N'(D) dD``. +""" +@inline function size_distribution(D, state::IceSizeDistributionState) + N₀ = state.intercept + μ = state.shape + λ = state.slope + return N₀ * D^μ * exp(-λ * D) +end + +##### +##### P3 particle property regimes +##### + +""" + critical_diameter_small_ice(rime_fraction) + +Threshold diameter below which ice particles are treated as small spheres. + +This function returns the D_th threshold computed from the mass-diameter +relationship: D_th = (6α / (π ρᵢ))^(1/(3-β)) + +Using default P3 parameters (α = 0.0121, β = 1.9, ρᵢ = 917): +D_th ≈ 15 μm + +See [`ice_regime_thresholds`](@ref) for the complete implementation with +explicit mass power law parameters. +""" +@inline function critical_diameter_small_ice(rime_fraction) + FT = typeof(rime_fraction) + # D_th = (6α / (π ρᵢ))^(1/(3-β)) with default P3 parameters + α = FT(0.0121) + β = FT(1.9) + ρᵢ = FT(917) + return (6 * α / (FT(π) * ρᵢ))^(1 / (3 - β)) +end + +""" + critical_diameter_unrimed(rime_fraction, rime_density) + +Threshold diameter separating unrimed aggregates from partially rimed particles. + +For unrimed ice (Fᶠ = 0), this threshold is infinite (no partially rimed regime). +For rimed ice, this is the D_cr threshold from Morrison & Milbrandt (2015a). +""" +@inline function critical_diameter_unrimed(rime_fraction, rime_density) + FT = typeof(rime_fraction) + Fᶠ = rime_fraction + ρᶠ = rime_density + + # For unrimed ice, return large value (no partial rime regime) + is_unrimed = Fᶠ < FT(1e-10) + + # Default P3 parameters + α = FT(0.0121) + β = FT(1.9) + + # Safe rime fraction + Fᶠ_safe = max(Fᶠ, FT(1e-10)) + + # Deposited ice density (Eq. 16 from MM15a) + k = (1 - Fᶠ_safe)^(-1 / (3 - β)) + num = ρᶠ * Fᶠ_safe + den = (β - 2) * (k - 1) / ((1 - Fᶠ_safe) * k - 1) - (1 - Fᶠ_safe) + ρ_dep = num / max(den, FT(1e-10)) + + # Graupel density + ρ_g = Fᶠ_safe * ρᶠ + (1 - Fᶠ_safe) * ρ_dep + + # Partial rime threshold: D_cr + D_cr = (6 * α / (FT(π) * ρ_g * (1 - Fᶠ_safe)))^(1 / (3 - β)) + + return ifelse(is_unrimed, FT(Inf), D_cr) +end + +""" + critical_diameter_graupel(rime_fraction, rime_density) + +Threshold diameter separating partially rimed ice from dense graupel. + +For unrimed ice (Fᶠ = 0), this threshold is infinite (no graupel regime). +For rimed ice, this is the D_gr threshold from Morrison & Milbrandt (2015a). +""" +@inline function critical_diameter_graupel(rime_fraction, rime_density) + FT = typeof(rime_fraction) + Fᶠ = rime_fraction + ρᶠ = rime_density + + # For unrimed ice, return large value (no graupel regime) + is_unrimed = Fᶠ < FT(1e-10) + + # Default P3 parameters + α = FT(0.0121) + β = FT(1.9) + + # Safe rime fraction + Fᶠ_safe = max(Fᶠ, FT(1e-10)) + + # Deposited ice density (Eq. 16 from MM15a) + k = (1 - Fᶠ_safe)^(-1 / (3 - β)) + num = ρᶠ * Fᶠ_safe + den = (β - 2) * (k - 1) / ((1 - Fᶠ_safe) * k - 1) - (1 - Fᶠ_safe) + ρ_dep = num / max(den, FT(1e-10)) + + # Graupel density + ρ_g = Fᶠ_safe * ρᶠ + (1 - Fᶠ_safe) * ρ_dep + + # Graupel threshold: D_gr + D_gr = (6 * α / (FT(π) * ρ_g))^(1 / (3 - β)) + + return ifelse(is_unrimed, FT(Inf), D_gr) +end diff --git a/src/Microphysics/PredictedParticleProperties/tabulation.jl b/src/Microphysics/PredictedParticleProperties/tabulation.jl new file mode 100644 index 000000000..e2b8871eb --- /dev/null +++ b/src/Microphysics/PredictedParticleProperties/tabulation.jl @@ -0,0 +1,696 @@ +##### +##### Tabulation of P3 Integrals using TabulatedFunction pattern +##### +##### This file provides P3 integral tabulation using the same idioms as +##### Oceananigans.Utils.TabulatedFunction, but extended to 3D for the +##### (mean_particle_mass, rime_fraction, liquid_fraction) parameter space. +##### + +export tabulate, TabulationParameters, P3IntegralEvaluator + +using Adapt: Adapt +using Oceananigans.Architectures: CPU, device, on_architecture + +##### +##### P3IntegralEvaluator - callable struct for integral computation +##### + +""" + P3IntegralEvaluator{I, FT} + +A callable struct that evaluates a P3 integral at any point in parameter space +using quadrature. This is the "function" that can be tabulated. + +The evaluator is callable as `evaluator(log_mean_mass, rime_fraction, liquid_fraction)` +and returns the integral value. + +# Fields +$(TYPEDFIELDS) + +# Example + +```julia +using Breeze.Microphysics.PredictedParticleProperties + +# Create an evaluator for mass-weighted fall speed +evaluator = P3IntegralEvaluator(MassWeightedFallSpeed()) + +# Evaluate at a specific point (log₁₀ of mean mass, rime fraction, liquid fraction) +value = evaluator(-12.0, 0.5, 0.0) +``` +""" +struct P3IntegralEvaluator{I<:AbstractP3Integral, N, W, FT} + "The integral type being evaluated" + integral :: I + "Pre-computed quadrature nodes on [-1, 1]" + nodes :: N + "Pre-computed quadrature weights" + weights :: W + "Pure ice density [kg/m³]" + pure_ice_density :: FT + "Unrimed aggregate effective density factor" + unrimed_density_factor :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct a `P3IntegralEvaluator` for the given integral type. + +The evaluator pre-computes quadrature nodes and weights for efficient +repeated evaluation during tabulation. + +# Keyword Arguments +- `number_of_quadrature_points`: Number of quadrature points (default 64) +- `pure_ice_density`: Pure ice density [kg/m³] (default 917) +- `unrimed_density_factor`: Effective density factor for unrimed aggregates (default 0.1) +""" +function P3IntegralEvaluator(integral::AbstractP3Integral, + FT::Type{<:AbstractFloat} = Float64; + number_of_quadrature_points::Int = 64, + pure_ice_density = FT(917), + unrimed_density_factor = FT(0.1)) + + nodes, weights = chebyshev_gauss_nodes_weights(FT, number_of_quadrature_points) + + return P3IntegralEvaluator( + integral, + nodes, + weights, + FT(pure_ice_density), + FT(unrimed_density_factor) + ) +end + +""" + (evaluator::P3IntegralEvaluator)(log_mean_mass, rime_fraction, liquid_fraction) + +Evaluate the P3 integral at the given parameter point. + +# Arguments +- `log_mean_mass`: log₁₀ of mean particle mass [kg] +- `rime_fraction`: Rime mass fraction [0, 1] +- `liquid_fraction`: Liquid water fraction [0, 1] + +# Returns +The evaluated integral value. +""" +@inline function (e::P3IntegralEvaluator)(log_mean_mass, rime_fraction, liquid_fraction; + rime_density = typeof(log_mean_mass)(400), + shape_parameter = zero(typeof(log_mean_mass))) + FT = typeof(log_mean_mass) + mean_particle_mass = FT(10)^log_mean_mass + + # Build the ice size distribution state from physical quantities + state = state_from_mean_particle_mass(e, mean_particle_mass, rime_fraction, liquid_fraction; + rime_density, shape_parameter) + + # Evaluate integral using pre-computed quadrature + return evaluate_quadrature(e.integral, state, e.nodes, e.weights) +end + +""" + state_from_mean_particle_mass(evaluator, mean_particle_mass, rime_fraction, liquid_fraction; kwargs...) + +Create an `IceSizeDistributionState` from physical quantities. + +Given mean particle mass = qⁱ/Nⁱ (mass per particle), this function determines +the size distribution parameters (N₀, μ, λ). +""" +@inline function state_from_mean_particle_mass(e::P3IntegralEvaluator, + mean_particle_mass, + rime_fraction, + liquid_fraction; + rime_density = typeof(mean_particle_mass)(400), + shape_parameter = zero(typeof(mean_particle_mass)), + air_density = typeof(mean_particle_mass)(1.225)) + FT = typeof(mean_particle_mass) + + # Default P3 mass-diameter parameters + mass_coefficient = FT(0.0121) + mass_exponent = FT(1.9) + reference_air_density = FT(1.225) + + # Effective density: interpolate between aggregate and rime + effective_density = (1 - rime_fraction) * e.pure_ice_density * e.unrimed_density_factor + + rime_fraction * rime_density + + # Characteristic diameter from mean_particle_mass = (π/6) ρ_eff D³ + characteristic_diameter = cbrt(6 * mean_particle_mass / (FT(π) * effective_density)) + + # λ ~ 4 / D for exponential distribution (μ = 0) + slope_parameter = FT(4) / max(characteristic_diameter, FT(1e-8)) + + # N₀ from normalization (placeholder value for reasonable number concentration) + intercept_parameter = FT(1e6) + + return IceSizeDistributionState( + intercept_parameter, + shape_parameter, + slope_parameter, + rime_fraction, + liquid_fraction, + rime_density, + mass_coefficient, + mass_exponent, + e.pure_ice_density, + reference_air_density, + air_density + ) +end + +""" + evaluate_quadrature(integral, state, nodes, weights) + +Evaluate a P3 integral using pre-computed quadrature nodes and weights. +This is the core numerical integration routine. +""" +@inline function evaluate_quadrature(integral::AbstractP3Integral, + state::IceSizeDistributionState, + nodes, weights) + FT = typeof(state.slope) + λ = state.slope + result = zero(FT) + n = length(nodes) + + for i in 1:n + x = @inbounds nodes[i] + w = @inbounds weights[i] + + D = transform_to_diameter(x, λ) + J = jacobian_diameter_transform(x, λ) + f = integrand(integral, D, state) + + result += w * f * J + end + + return result +end + +##### +##### TabulatedFunction3D - 3D extension of TabulatedFunction pattern +##### + +""" + TabulatedFunction3D{F, T, FT} + +A wrapper around a ternary callable `func(x, y, z)` that precomputes values in a +3D lookup table for fast trilinear interpolation. This extends the +`Oceananigans.Utils.TabulatedFunction` pattern to three dimensions. + +The P3 scheme uses this for efficient integral evaluation during simulation, +avoiding expensive quadrature computations in GPU kernels. + +# Fields +$(TYPEDFIELDS) +""" +struct TabulatedFunction3D{F, T, FT} + "The original callable being tabulated (for reference/fallback)" + func :: F + "Precomputed values (3D array)" + table :: T + "Minimum x value (log mean particle mass)" + x_min :: FT + "Maximum x value" + x_max :: FT + "Inverse spacing in x" + inverse_Δx :: FT + "Minimum y value (rime fraction)" + y_min :: FT + "Maximum y value" + y_max :: FT + "Inverse spacing in y" + inverse_Δy :: FT + "Minimum z value (liquid fraction)" + z_min :: FT + "Maximum z value" + z_max :: FT + "Inverse spacing in z" + inverse_Δz :: FT +end + +""" +$(TYPEDSIGNATURES) + +Construct a `TabulatedFunction3D` by precomputing values of `func` over a 3D grid. + +# Arguments +- `func`: Callable `func(x, y, z)` to tabulate +- `arch`: Architecture (`CPU()` or `GPU()`) +- `FT`: Float type + +# Keyword Arguments +- `x_range`: Tuple `(x_min, x_max)` for first dimension (log mean mass) +- `y_range`: Tuple `(y_min, y_max)` for second dimension (rime fraction) +- `z_range`: Tuple `(z_min, z_max)` for third dimension (liquid fraction) +- `x_points`: Number of grid points in x (default 50) +- `y_points`: Number of grid points in y (default 4) +- `z_points`: Number of grid points in z (default 4) +""" +function TabulatedFunction3D(func, arch=CPU(), FT=Float64; + x_range, + y_range = (FT(0), FT(1)), + z_range = (FT(0), FT(1)), + x_points = 50, + y_points = 4, + z_points = 4) + + x_min, x_max = x_range + y_min, y_max = y_range + z_min, z_max = z_range + + Δx = (x_max - x_min) / (x_points - 1) + Δy = (y_max - y_min) / max(y_points - 1, 1) + Δz = (z_max - z_min) / max(z_points - 1, 1) + + inverse_Δx = 1 / Δx + inverse_Δy = ifelse(y_points > 1, 1 / Δy, zero(FT)) + inverse_Δz = ifelse(z_points > 1, 1 / Δz, zero(FT)) + + # Precompute table values on CPU first + table = zeros(FT, x_points, y_points, z_points) + + for k in 1:z_points + z = z_min + (k - 1) * Δz + for j in 1:y_points + y = y_min + (j - 1) * Δy + for i in 1:x_points + x = x_min + (i - 1) * Δx + table[i, j, k] = func(x, y, z) + end + end + end + + # Transfer to target architecture + table = on_architecture(arch, table) + + return TabulatedFunction3D( + func, + table, + convert(FT, x_min), convert(FT, x_max), convert(FT, inverse_Δx), + convert(FT, y_min), convert(FT, y_max), convert(FT, inverse_Δy), + convert(FT, z_min), convert(FT, z_max), convert(FT, inverse_Δz) + ) +end + +##### +##### Trilinear interpolation for TabulatedFunction3D +##### + +@inline function _clamp_and_index(val, v_min, v_max, inverse_Δv, n) + v_clamped = clamp(val, v_min, v_max) + fractional_idx = (v_clamped - v_min) * inverse_Δv + + # 0-based indices + i⁻ = Base.unsafe_trunc(Int, fractional_idx) + i⁺ = min(i⁻ + 1, n - 1) + ξ = fractional_idx - i⁻ + + # Convert to 1-based + return i⁻ + 1, i⁺ + 1, ξ +end + +""" + (f::TabulatedFunction3D)(x, y, z) + +Evaluate the tabulated function using trilinear interpolation. +""" +@inline function (f::TabulatedFunction3D)(x, y, z) + nx, ny, nz = size(f.table) + + i⁻, i⁺, ξx = _clamp_and_index(x, f.x_min, f.x_max, f.inverse_Δx, nx) + j⁻, j⁺, ξy = _clamp_and_index(y, f.y_min, f.y_max, f.inverse_Δy, ny) + k⁻, k⁺, ξz = _clamp_and_index(z, f.z_min, f.z_max, f.inverse_Δz, nz) + + # Trilinear interpolation + @inbounds begin + c000 = f.table[i⁻, j⁻, k⁻] + c100 = f.table[i⁺, j⁻, k⁻] + c010 = f.table[i⁻, j⁺, k⁻] + c110 = f.table[i⁺, j⁺, k⁻] + c001 = f.table[i⁻, j⁻, k⁺] + c101 = f.table[i⁺, j⁻, k⁺] + c011 = f.table[i⁻, j⁺, k⁺] + c111 = f.table[i⁺, j⁺, k⁺] + end + + # Interpolate in x + c00 = (1 - ξx) * c000 + ξx * c100 + c10 = (1 - ξx) * c010 + ξx * c110 + c01 = (1 - ξx) * c001 + ξx * c101 + c11 = (1 - ξx) * c011 + ξx * c111 + + # Interpolate in y + c0 = (1 - ξy) * c00 + ξy * c10 + c1 = (1 - ξy) * c01 + ξy * c11 + + # Interpolate in z + return (1 - ξz) * c0 + ξz * c1 +end + +##### +##### GPU/architecture support for TabulatedFunction3D +##### + +Oceananigans.Architectures.on_architecture(arch, f::TabulatedFunction3D) = + TabulatedFunction3D(f.func, + on_architecture(arch, f.table), + f.x_min, f.x_max, f.inverse_Δx, + f.y_min, f.y_max, f.inverse_Δy, + f.z_min, f.z_max, f.inverse_Δz) + +Adapt.adapt_structure(to, f::TabulatedFunction3D) = + TabulatedFunction3D(nothing, + Adapt.adapt(to, f.table), + f.x_min, f.x_max, f.inverse_Δx, + f.y_min, f.y_max, f.inverse_Δy, + f.z_min, f.z_max, f.inverse_Δz) + +##### +##### Pretty printing +##### + +function Base.summary(f::TabulatedFunction3D) + nx, ny, nz = size(f.table) + return "TabulatedFunction3D with $(nx)×$(ny)×$(nz) points" +end + +function Base.show(io::IO, f::TabulatedFunction3D) + print(io, summary(f)) + print(io, " over x∈[$(f.x_min), $(f.x_max)], y∈[$(f.y_min), $(f.y_max)], z∈[$(f.z_min), $(f.z_max)]") + if f.func !== nothing + print(io, " of ", typeof(f.func).name.name) + end +end + +##### +##### TabulationParameters +##### + +""" + TabulationParameters{FT} + +Configuration for P3 integral tabulation. See constructor for details. +""" +struct TabulationParameters{FT} + number_of_mass_points :: Int + number_of_rime_fraction_points :: Int + number_of_liquid_fraction_points :: Int + minimum_log_mean_particle_mass :: FT + maximum_log_mean_particle_mass :: FT + number_of_quadrature_points :: Int +end + +""" +$(TYPEDSIGNATURES) + +Configure the lookup table grid for P3 integrals. + +The P3 Fortran code pre-computes bulk integrals on a 3D grid indexed by: + +1. **Log mean particle mass** `log₁₀(qⁱ/Nⁱ)` [log kg]: Mass per particle (linearly spaced in log) +2. **Rime fraction** `∈ [0, 1]`: Mass fraction that is rime (frozen accretion) +3. **Liquid fraction** `∈ [0, 1]`: Mass fraction that is liquid water on ice + +During simulation, integral values are interpolated from this table rather +than computed via quadrature, which is much faster. + +# Keyword Arguments + +- `number_of_mass_points`: Grid points in mean particle mass (default 50) +- `number_of_rime_fraction_points`: Grid points in rime fraction (default 4) +- `number_of_liquid_fraction_points`: Grid points in liquid fraction (default 4) +- `minimum_log_mean_particle_mass`: Minimum log₁₀(mass) [log kg], default -18 +- `maximum_log_mean_particle_mass`: Maximum log₁₀(mass) [log kg], default -5 +- `number_of_quadrature_points`: Quadrature points for filling table (default 64) + +# References + +Table structure follows `create_p3_lookupTable_1.f90` in P3-microphysics. +""" +function TabulationParameters(FT::Type{<:AbstractFloat} = Float64; + number_of_mass_points::Int = 50, + number_of_rime_fraction_points::Int = 4, + number_of_liquid_fraction_points::Int = 4, + minimum_log_mean_particle_mass = FT(-18), + maximum_log_mean_particle_mass = FT(-5), + number_of_quadrature_points::Int = 64) + return TabulationParameters( + number_of_mass_points, + number_of_rime_fraction_points, + number_of_liquid_fraction_points, + FT(minimum_log_mean_particle_mass), + FT(maximum_log_mean_particle_mass), + number_of_quadrature_points + ) +end + +##### +##### Main tabulate interface +##### + +""" +$(TYPEDSIGNATURES) + +Tabulate a P3 integral using the `TabulatedFunction3D` pattern. + +Creates a callable evaluator function and tabulates it over the 3D parameter space. + +# Arguments +- `integral`: Integral type to tabulate (e.g., `MassWeightedFallSpeed()`) +- `arch`: `CPU()` or `GPU()` - determines where table is stored +- `params`: [`TabulationParameters`](@ref) defining the grid + +# Returns +A [`TabulatedFunction3D`](@ref) that can be called like the original evaluator. + +# Example + +```julia +using Oceananigans +using Breeze.Microphysics.PredictedParticleProperties + +# Create and tabulate a fall speed integral +params = TabulationParameters() +tabulated = tabulate(MassWeightedFallSpeed(), CPU(), params) + +# Evaluate via interpolation (fast) +value = tabulated(-12.0, 0.5, 0.0) +``` +""" +function tabulate(integral::AbstractP3Integral, arch=CPU(), + params::TabulationParameters = TabulationParameters()) + + FT = typeof(params.minimum_log_mean_particle_mass) + + # Create the evaluator function + evaluator = P3IntegralEvaluator(integral, FT; + number_of_quadrature_points = params.number_of_quadrature_points) + + # Tabulate using TabulatedFunction3D + return TabulatedFunction3D(evaluator, arch, FT; + x_range = (params.minimum_log_mean_particle_mass, + params.maximum_log_mean_particle_mass), + y_range = (zero(FT), one(FT)), + z_range = (zero(FT), one(FT)), + x_points = params.number_of_mass_points, + y_points = params.number_of_rime_fraction_points, + z_points = params.number_of_liquid_fraction_points) +end + +""" +$(TYPEDSIGNATURES) + +Tabulate all integrals in an `IceFallSpeed` container. + +Returns a new `IceFallSpeed` with `TabulatedFunction3D` fields. +""" +function tabulate(fall_speed::IceFallSpeed, arch=CPU(), + params::TabulationParameters = TabulationParameters()) + + return IceFallSpeed( + fall_speed.reference_air_density, + fall_speed.fall_speed_coefficient, + fall_speed.fall_speed_exponent, + tabulate(fall_speed.number_weighted, arch, params), + tabulate(fall_speed.mass_weighted, arch, params), + tabulate(fall_speed.reflectivity_weighted, arch, params) + ) +end + +""" +$(TYPEDSIGNATURES) + +Tabulate all integrals in an `IceDeposition` container. +""" +function tabulate(deposition::IceDeposition, arch=CPU(), + params::TabulationParameters = TabulationParameters()) + + return IceDeposition( + deposition.thermal_conductivity, + deposition.vapor_diffusivity, + tabulate(deposition.ventilation, arch, params), + tabulate(deposition.ventilation_enhanced, arch, params), + tabulate(deposition.small_ice_ventilation_constant, arch, params), + tabulate(deposition.small_ice_ventilation_reynolds, arch, params), + tabulate(deposition.large_ice_ventilation_constant, arch, params), + tabulate(deposition.large_ice_ventilation_reynolds, arch, params) + ) +end + +""" +$(TYPEDSIGNATURES) + +Tabulate specific integrals within a P3 microphysics scheme. + +Returns a new `PredictedParticlePropertiesMicrophysics` with the specified +integrals replaced by `TabulatedFunction3D` lookup tables. + +# Arguments +- `p3`: [`PredictedParticlePropertiesMicrophysics`](@ref) +- `property`: Which integrals to tabulate + - `:ice_fall_speed`: All fall speed integrals + - `:ice_deposition`: All deposition/ventilation integrals +- `arch`: `CPU()` or `GPU()` + +# Keyword Arguments +Passed to [`TabulationParameters`](@ref): `number_of_mass_points`, +`number_of_rime_fraction_points`, etc. + +# Example + +```julia +using Oceananigans +using Breeze.Microphysics.PredictedParticleProperties + +p3 = PredictedParticlePropertiesMicrophysics() +p3_fast = tabulate(p3, :ice_fall_speed, CPU(); number_of_mass_points=100) +``` +""" +function tabulate(p3::PredictedParticlePropertiesMicrophysics{FT}, + property::Symbol, + arch=CPU(); + kwargs...) where FT + + params = TabulationParameters(FT; kwargs...) + + if property == :ice_fall_speed + new_fall_speed = tabulate(p3.ice.fall_speed, arch, params) + new_ice = IceProperties( + p3.ice.minimum_rime_density, + p3.ice.maximum_rime_density, + p3.ice.maximum_shape_parameter, + p3.ice.minimum_reflectivity, + new_fall_speed, + p3.ice.deposition, + p3.ice.bulk_properties, + p3.ice.collection, + p3.ice.sixth_moment, + p3.ice.lambda_limiter, + p3.ice.ice_rain + ) + return PredictedParticlePropertiesMicrophysics( + p3.water_density, + p3.minimum_mass_mixing_ratio, + p3.minimum_number_mixing_ratio, + new_ice, + p3.rain, + p3.cloud, + p3.process_rates, + p3.precipitation_boundary_condition + ) + + elseif property == :ice_deposition + new_deposition = tabulate(p3.ice.deposition, arch, params) + new_ice = IceProperties( + p3.ice.minimum_rime_density, + p3.ice.maximum_rime_density, + p3.ice.maximum_shape_parameter, + p3.ice.minimum_reflectivity, + p3.ice.fall_speed, + new_deposition, + p3.ice.bulk_properties, + p3.ice.collection, + p3.ice.sixth_moment, + p3.ice.lambda_limiter, + p3.ice.ice_rain + ) + return PredictedParticlePropertiesMicrophysics( + p3.water_density, + p3.minimum_mass_mixing_ratio, + p3.minimum_number_mixing_ratio, + new_ice, + p3.rain, + p3.cloud, + p3.process_rates, + p3.precipitation_boundary_condition + ) + + else + throw(ArgumentError("Unknown property to tabulate: $property. " * + "Supported: :ice_fall_speed, :ice_deposition")) + end +end + +""" +$(TYPEDSIGNATURES) + +Tabulate all ice integral properties for fast lookup during simulation. + +This is a convenience function that tabulates fall speed, deposition, +and other integral properties in one call. + +# Arguments +- `p3`: P3 microphysics scheme +- `arch`: Architecture (`CPU()` or `GPU()`) + +# Keyword Arguments +Passed to [`TabulationParameters`](@ref). + +# Returns +A new `PredictedParticlePropertiesMicrophysics` with all ice integrals tabulated. + +# Example + +```julia +using Oceananigans +using Breeze.Microphysics.PredictedParticleProperties + +p3 = PredictedParticlePropertiesMicrophysics() +p3_tabulated = tabulate(p3, CPU()) +``` +""" +function tabulate(p3::PredictedParticlePropertiesMicrophysics{FT}, arch=CPU(); + kwargs...) where FT + + params = TabulationParameters(FT; kwargs...) + + # Tabulate fall speed and deposition integrals + tabulated_fall_speed = tabulate(p3.ice.fall_speed, arch, params) + tabulated_deposition = tabulate(p3.ice.deposition, arch, params) + + new_ice = IceProperties( + p3.ice.minimum_rime_density, + p3.ice.maximum_rime_density, + p3.ice.maximum_shape_parameter, + p3.ice.minimum_reflectivity, + tabulated_fall_speed, + tabulated_deposition, + p3.ice.bulk_properties, + p3.ice.collection, + p3.ice.sixth_moment, + p3.ice.lambda_limiter, + p3.ice.ice_rain + ) + + return PredictedParticlePropertiesMicrophysics( + p3.water_density, + p3.minimum_mass_mixing_ratio, + p3.minimum_number_mixing_ratio, + new_ice, + p3.rain, + p3.cloud, + p3.process_rates, + p3.precipitation_boundary_condition + ) +end diff --git a/test/predicted_particle_properties.jl b/test/predicted_particle_properties.jl new file mode 100644 index 000000000..3f5264de5 --- /dev/null +++ b/test/predicted_particle_properties.jl @@ -0,0 +1,1119 @@ +using Test +using Breeze.Microphysics.PredictedParticleProperties +using Breeze.AtmosphereModels: prognostic_field_names + +using Breeze.Microphysics.PredictedParticleProperties: + IceSizeDistributionState, + evaluate, + chebyshev_gauss_nodes_weights, + size_distribution, + tabulate, + TabulationParameters, + TabulatedFunction3D + +using Oceananigans: CPU + +@testset "Predicted Particle Properties (P3) Microphysics" begin + + @testset "Smoke tests - type construction" begin + # Test main scheme construction + p3 = PredictedParticlePropertiesMicrophysics() + @test p3 isa PredictedParticlePropertiesMicrophysics + @test p3.water_density == 1000.0 + @test p3.minimum_mass_mixing_ratio == 1e-14 + @test p3.minimum_number_mixing_ratio == 1e-16 + + # Test alias + p3_alias = P3Microphysics() + @test p3_alias isa PredictedParticlePropertiesMicrophysics + + # Test with Float32 + p3_f32 = PredictedParticlePropertiesMicrophysics(Float32) + @test p3_f32.water_density isa Float32 + @test p3_f32.minimum_mass_mixing_ratio isa Float32 + @test p3_f32.ice.fall_speed.reference_air_density isa Float32 + end + + @testset "Ice properties construction" begin + ice = IceProperties() + @test ice isa IceProperties + @test ice.minimum_rime_density == 50.0 + @test ice.maximum_rime_density == 900.0 + @test ice.maximum_shape_parameter == 10.0 + + # Check all sub-containers exist + @test ice.fall_speed isa IceFallSpeed + @test ice.deposition isa IceDeposition + @test ice.bulk_properties isa IceBulkProperties + @test ice.collection isa IceCollection + @test ice.sixth_moment isa IceSixthMoment + @test ice.lambda_limiter isa IceLambdaLimiter + @test ice.ice_rain isa IceRainCollection + end + + @testset "Ice fall speed" begin + fs = IceFallSpeed() + @test fs.reference_air_density ≈ 1.225 + @test fs.fall_speed_coefficient ≈ 11.72 + @test fs.fall_speed_exponent ≈ 0.41 + + @test fs.number_weighted isa NumberWeightedFallSpeed + @test fs.mass_weighted isa MassWeightedFallSpeed + @test fs.reflectivity_weighted isa ReflectivityWeightedFallSpeed + end + + @testset "Ice deposition" begin + dep = IceDeposition() + @test dep.thermal_conductivity ≈ 0.024 + @test dep.vapor_diffusivity ≈ 2.2e-5 + + @test dep.ventilation isa Ventilation + @test dep.ventilation_enhanced isa VentilationEnhanced + @test dep.small_ice_ventilation_constant isa SmallIceVentilationConstant + @test dep.small_ice_ventilation_reynolds isa SmallIceVentilationReynolds + @test dep.large_ice_ventilation_constant isa LargeIceVentilationConstant + @test dep.large_ice_ventilation_reynolds isa LargeIceVentilationReynolds + end + + @testset "Ice bulk properties" begin + bp = IceBulkProperties() + @test bp.maximum_mean_diameter ≈ 0.02 + @test bp.minimum_mean_diameter ≈ 1e-5 + + @test bp.effective_radius isa EffectiveRadius + @test bp.mean_diameter isa MeanDiameter + @test bp.mean_density isa MeanDensity + @test bp.reflectivity isa Reflectivity + @test bp.slope isa SlopeParameter + @test bp.shape isa ShapeParameter + @test bp.shedding isa SheddingRate + end + + @testset "Ice collection" begin + col = IceCollection() + @test col.ice_cloud_collection_efficiency ≈ 0.1 + @test col.ice_rain_collection_efficiency ≈ 1.0 + + @test col.aggregation isa AggregationNumber + @test col.rain_collection isa RainCollectionNumber + end + + @testset "Ice sixth moment" begin + m6 = IceSixthMoment() + @test m6.rime isa SixthMomentRime + @test m6.deposition isa SixthMomentDeposition + @test m6.deposition1 isa SixthMomentDeposition1 + @test m6.melt1 isa SixthMomentMelt1 + @test m6.melt2 isa SixthMomentMelt2 + @test m6.aggregation isa SixthMomentAggregation + @test m6.shedding isa SixthMomentShedding + @test m6.sublimation isa SixthMomentSublimation + @test m6.sublimation1 isa SixthMomentSublimation1 + end + + @testset "Ice lambda limiter" begin + ll = IceLambdaLimiter() + @test ll.small_q isa NumberMomentLambdaLimit + @test ll.large_q isa MassMomentLambdaLimit + end + + @testset "Ice-rain collection" begin + ir = IceRainCollection() + @test ir.mass isa IceRainMassCollection + @test ir.number isa IceRainNumberCollection + @test ir.sixth_moment isa IceRainSixthMomentCollection + end + + @testset "Rain properties" begin + rain = RainProperties() + @test rain.maximum_mean_diameter ≈ 6e-3 + @test rain.fall_speed_coefficient ≈ 4854.0 + @test rain.fall_speed_exponent ≈ 1.0 + + @test rain.shape_parameter isa RainShapeParameter + @test rain.velocity_number isa RainVelocityNumber + @test rain.velocity_mass isa RainVelocityMass + @test rain.evaporation isa RainEvaporation + end + + @testset "Cloud droplet properties" begin + cloud = CloudDropletProperties() + @test cloud.number_concentration ≈ 100e6 + @test cloud.autoconversion_threshold ≈ 25e-6 + @test cloud.condensation_timescale ≈ 1.0 + + # Test custom parameters + cloud_custom = CloudDropletProperties(Float64; number_concentration=50e6) + @test cloud_custom.number_concentration ≈ 50e6 + end + + @testset "Water density is shared" begin + # Water density should be at top level, shared by cloud and rain + p3 = PredictedParticlePropertiesMicrophysics() + @test p3.water_density ≈ 1000.0 + + # Custom water density + p3_custom = PredictedParticlePropertiesMicrophysics(Float64; water_density=998.0) + @test p3_custom.water_density ≈ 998.0 + end + + @testset "Prognostic field names" begin + p3 = PredictedParticlePropertiesMicrophysics() + names = prognostic_field_names(p3) + + # With prescribed cloud number (default) + @test :ρqᶜˡ ∈ names + @test :ρqʳ ∈ names + @test :ρnʳ ∈ names + @test :ρqⁱ ∈ names + @test :ρnⁱ ∈ names + @test :ρqᶠ ∈ names + @test :ρbᶠ ∈ names + @test :ρzⁱ ∈ names + @test :ρqʷⁱ ∈ names + + # Cloud number should NOT be in names with prescribed mode + @test :ρnᶜˡ ∉ names + end + + @testset "Integral type hierarchy" begin + # Test abstract types + @test NumberWeightedFallSpeed <: AbstractFallSpeedIntegral + @test AbstractFallSpeedIntegral <: AbstractIceIntegral + @test AbstractIceIntegral <: AbstractP3Integral + + @test Ventilation <: AbstractDepositionIntegral + @test EffectiveRadius <: AbstractBulkPropertyIntegral + @test AggregationNumber <: AbstractCollectionIntegral + @test SixthMomentRime <: AbstractSixthMomentIntegral + @test NumberMomentLambdaLimit <: AbstractLambdaLimiterIntegral + + @test RainShapeParameter <: AbstractRainIntegral + @test AbstractRainIntegral <: AbstractP3Integral + end + + @testset "TabulatedIntegral" begin + # Create a test array + data = rand(10, 5, 3) + tab = TabulatedIntegral(data) + + @test tab isa TabulatedIntegral + @test size(tab) == (10, 5, 3) + @test tab[1, 1, 1] == data[1, 1, 1] + @test tab[5, 3, 2] == data[5, 3, 2] + end + + @testset "Show methods" begin + # Just test that show methods don't error + p3 = PredictedParticlePropertiesMicrophysics() + io = IOBuffer() + show(io, p3) + @test length(take!(io)) > 0 + + show(io, p3.ice) + @test length(take!(io)) > 0 + + show(io, p3.ice.fall_speed) + @test length(take!(io)) > 0 + + show(io, p3.rain) + @test length(take!(io)) > 0 + + show(io, p3.cloud) + @test length(take!(io)) > 0 + end + + @testset "Ice size distribution state" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + @test state.intercept ≈ 1e6 + @test state.shape ≈ 0.0 + @test state.slope ≈ 1000.0 + @test state.rime_fraction ≈ 0.0 + @test state.liquid_fraction ≈ 0.0 + @test state.rime_density ≈ 400.0 + + # Test with rime + state_rimed = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 2.0, + slope = 500.0, + rime_fraction = 0.5, + rime_density = 600.0) + + @test state_rimed.rime_fraction ≈ 0.5 + @test state_rimed.rime_density ≈ 600.0 + + # Test size distribution evaluation + D = 100e-6 # 100 μm + Np = size_distribution(D, state) + @test Np > 0 + + # N'(D) = N₀ D^μ exp(-λD) for μ=0: N₀ exp(-λD) + expected = 1e6 * exp(-1000 * D) + @test Np ≈ expected + end + + @testset "Chebyshev-Gauss quadrature" begin + nodes, weights = chebyshev_gauss_nodes_weights(Float64, 32) + + @test length(nodes) == 32 + @test length(weights) == 32 + + # Nodes should be in [-1, 1] + @test all(-1 ≤ x ≤ 1 for x in nodes) + + # Weights should sum to π (for Chebyshev-Gauss) + @test sum(weights) ≈ π + + # Test Float32 + nodes32, weights32 = chebyshev_gauss_nodes_weights(Float32, 16) + @test eltype(nodes32) == Float32 + @test eltype(weights32) == Float32 + end + + @testset "Quadrature evaluation - fall speed integrals" begin + # Create a test state + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + # Test number-weighted fall speed + V_n = evaluate(NumberWeightedFallSpeed(), state) + @test V_n > 0 + @test isfinite(V_n) + + # Test mass-weighted fall speed + V_m = evaluate(MassWeightedFallSpeed(), state) + @test V_m > 0 + @test isfinite(V_m) + + # Test reflectivity-weighted fall speed + V_z = evaluate(ReflectivityWeightedFallSpeed(), state) + @test V_z > 0 + @test isfinite(V_z) + + # Mass-weighted should be larger than number-weighted + # (larger particles fall faster and contribute more mass) + # Note: this depends on the specific parameterization + end + + @testset "Quadrature evaluation - deposition integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + # Basic ventilation + v = evaluate(Ventilation(), state) + @test v ≥ 0 + @test isfinite(v) + + v_enh = evaluate(VentilationEnhanced(), state) + @test v_enh ≥ 0 + @test isfinite(v_enh) + + # Size-regime ventilation + v_sc = evaluate(SmallIceVentilationConstant(), state) + v_sr = evaluate(SmallIceVentilationReynolds(), state) + v_lc = evaluate(LargeIceVentilationConstant(), state) + v_lr = evaluate(LargeIceVentilationReynolds(), state) + + @test all(isfinite, [v_sc, v_sr, v_lc, v_lr]) + end + + @testset "Quadrature evaluation - bulk property integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + r_eff = evaluate(EffectiveRadius(), state) + @test r_eff > 0 + @test isfinite(r_eff) + + d_m = evaluate(MeanDiameter(), state) + @test d_m > 0 + @test isfinite(d_m) + + ρ_m = evaluate(MeanDensity(), state) + @test ρ_m > 0 + @test isfinite(ρ_m) + + Z = evaluate(Reflectivity(), state) + @test Z > 0 + @test isfinite(Z) + end + + @testset "Quadrature evaluation - collection integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + n_agg = evaluate(AggregationNumber(), state) + @test n_agg > 0 + @test isfinite(n_agg) + + n_rw = evaluate(RainCollectionNumber(), state) + @test n_rw > 0 + @test isfinite(n_rw) + end + + @testset "Quadrature evaluation - sixth moment integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0, + liquid_fraction = 0.1) + + m6_rime = evaluate(SixthMomentRime(), state) + @test m6_rime > 0 + @test isfinite(m6_rime) + + m6_dep = evaluate(SixthMomentDeposition(), state) + @test m6_dep > 0 + @test isfinite(m6_dep) + + m6_agg = evaluate(SixthMomentAggregation(), state) + @test m6_agg > 0 + @test isfinite(m6_agg) + + m6_shed = evaluate(SixthMomentShedding(), state) + @test m6_shed > 0 # Non-zero because liquid_fraction > 0 + @test isfinite(m6_shed) + end + + @testset "Quadrature evaluation - lambda limiter integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + i_small = evaluate(NumberMomentLambdaLimit(), state) + @test i_small > 0 + @test isfinite(i_small) + + i_large = evaluate(MassMomentLambdaLimit(), state) + @test i_large > 0 + @test isfinite(i_large) + end + + @testset "Quadrature evaluation - ice-rain collection integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + q_ir = evaluate(IceRainMassCollection(), state) + @test q_ir > 0 + @test isfinite(q_ir) + + n_ir = evaluate(IceRainNumberCollection(), state) + @test n_ir > 0 + @test isfinite(n_ir) + + z_ir = evaluate(IceRainSixthMomentCollection(), state) + @test z_ir > 0 + @test isfinite(z_ir) + end + + @testset "Quadrature convergence" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 1000.0) + + # Test that quadrature converges with increasing number of points + V_16 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=16) + V_32 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=32) + V_64 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=64) + V_128 = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=128) + + # Should converge (differences decrease) + diff_16_32 = abs(V_32 - V_16) + diff_32_64 = abs(V_64 - V_32) + diff_64_128 = abs(V_128 - V_64) + + @test diff_32_64 < diff_16_32 || diff_32_64 < 1e-10 + @test diff_64_128 < diff_32_64 || diff_64_128 < 1e-10 + end + + @testset "Tabulation parameters" begin + params = TabulationParameters() + @test params.number_of_mass_points == 50 + @test params.number_of_rime_fraction_points == 4 + @test params.number_of_liquid_fraction_points == 4 + @test params.minimum_log_mean_particle_mass ≈ -18 + @test params.maximum_log_mean_particle_mass ≈ -5 + @test params.number_of_quadrature_points == 64 + + # Custom parameters + params_custom = TabulationParameters(Float32; + number_of_mass_points=20, + number_of_rime_fraction_points=3, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=32) + @test params_custom.number_of_mass_points == 20 + @test params_custom.number_of_rime_fraction_points == 3 + @test params_custom.number_of_liquid_fraction_points == 2 + @test params_custom.minimum_log_mean_particle_mass isa Float32 + end + + @testset "Tabulate single integral" begin + params = TabulationParameters(Float64; + number_of_mass_points=5, + number_of_rime_fraction_points=2, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=16) + + # Tabulate number-weighted fall speed + tab_Vn = tabulate(NumberWeightedFallSpeed(), CPU(), params) + + @test tab_Vn isa TabulatedFunction3D + @test size(tab_Vn.table) == (5, 2, 2) + + # Values should be positive and finite + @test all(isfinite, tab_Vn.table) + @test all(x -> x > 0, tab_Vn.table) + + # Test indexing via table + @test tab_Vn.table[1, 1, 1] > 0 + @test tab_Vn.table[5, 2, 2] > 0 + end + + @testset "Tabulate IceFallSpeed container" begin + params = TabulationParameters(Float64; + number_of_mass_points=5, + number_of_rime_fraction_points=2, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=16) + + fs = IceFallSpeed() + fs_tab = tabulate(fs, CPU(), params) + + # Parameters should be preserved + @test fs_tab.reference_air_density == fs.reference_air_density + @test fs_tab.fall_speed_coefficient == fs.fall_speed_coefficient + @test fs_tab.fall_speed_exponent == fs.fall_speed_exponent + + # Integrals should be tabulated + @test fs_tab.number_weighted isa TabulatedFunction3D + @test fs_tab.mass_weighted isa TabulatedFunction3D + @test fs_tab.reflectivity_weighted isa TabulatedFunction3D + + # Check sizes + @test size(fs_tab.number_weighted.table) == (5, 2, 2) + @test size(fs_tab.mass_weighted.table) == (5, 2, 2) + @test size(fs_tab.reflectivity_weighted.table) == (5, 2, 2) + end + + @testset "Tabulate IceDeposition container" begin + params = TabulationParameters(Float64; + number_of_mass_points=5, + number_of_rime_fraction_points=2, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=16) + + dep = IceDeposition() + dep_tab = tabulate(dep, CPU(), params) + + # Parameters should be preserved + @test dep_tab.thermal_conductivity == dep.thermal_conductivity + @test dep_tab.vapor_diffusivity == dep.vapor_diffusivity + + # All 6 integrals should be tabulated + @test dep_tab.ventilation isa TabulatedFunction3D + @test dep_tab.ventilation_enhanced isa TabulatedFunction3D + @test dep_tab.small_ice_ventilation_constant isa TabulatedFunction3D + @test dep_tab.small_ice_ventilation_reynolds isa TabulatedFunction3D + @test dep_tab.large_ice_ventilation_constant isa TabulatedFunction3D + @test dep_tab.large_ice_ventilation_reynolds isa TabulatedFunction3D + end + + @testset "Tabulate P3 scheme by property" begin + p3 = PredictedParticlePropertiesMicrophysics() + + # Tabulate fall speed + p3_fs = tabulate(p3, :ice_fall_speed, CPU(); + number_of_mass_points=5, + number_of_rime_fraction_points=2, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=16) + + @test p3_fs isa PredictedParticlePropertiesMicrophysics + @test p3_fs.ice.fall_speed.number_weighted isa TabulatedFunction3D + @test p3_fs.ice.fall_speed.mass_weighted isa TabulatedFunction3D + + # Other properties should be unchanged + @test p3_fs.ice.deposition.ventilation isa Ventilation + @test p3_fs.rain == p3.rain + @test p3_fs.cloud == p3.cloud + + # Tabulate deposition + p3_dep = tabulate(p3, :ice_deposition, CPU(); + number_of_mass_points=5, + number_of_rime_fraction_points=2, + number_of_liquid_fraction_points=2, + number_of_quadrature_points=16) + + @test p3_dep.ice.deposition.ventilation isa TabulatedFunction3D + @test p3_dep.ice.fall_speed.number_weighted isa NumberWeightedFallSpeed + end + + @testset "Tabulation error handling" begin + p3 = PredictedParticlePropertiesMicrophysics() + + # Unknown property should throw + @test_throws ArgumentError tabulate(p3, :unknown_property, CPU()) + end + + ##### + ##### Physical consistency tests + ##### + + @testset "Fall speed physical consistency" begin + # For a given PSD, larger particles fall faster + # Note: evaluate() returns density-weighted integrals (fluxes), not mean velocities. + # So we cannot compare V_n and V_m directly without normalization. + + state = IceSizeDistributionState(Float64; + intercept = 1e6, + shape = 0.0, + slope = 500.0) # Larger particles (smaller λ) + + V_n = evaluate(NumberWeightedFallSpeed(), state) + V_m = evaluate(MassWeightedFallSpeed(), state) + V_z = evaluate(ReflectivityWeightedFallSpeed(), state) + + # All should be positive + @test V_n > 0 + @test V_m > 0 + @test V_z > 0 + + # Typical ice fall speeds should be 0.1 - 10 m/s + # These are normalized integrals, but check they're in a reasonable range + @test isfinite(V_n) + @test isfinite(V_m) + @test isfinite(V_z) + end + + @testset "Size distribution moments" begin + # For exponential distribution (μ=0), analytical moments are known: + # M_n = N₀ Γ(n+1) / λ^{n+1} + # M_0 = N₀ / λ (total number) + # M_1 = N₀ / λ² + # M_3 = 6 N₀ / λ⁴ + # M_6 = 720 N₀ / λ⁷ + + N₀ = 1e6 + λ = 1000.0 + μ = 0.0 + + state = IceSizeDistributionState(Float64; + intercept = N₀, + shape = μ, + slope = λ) + + # Number integral (0th moment proxy) + n_int = evaluate(NumberMomentLambdaLimit(), state) # This is just ∫ N'(D) dD + @test n_int > 0 + @test isfinite(n_int) + + # Reflectivity (6th moment) + Z = evaluate(Reflectivity(), state) + @test Z > 0 + @test isfinite(Z) + end + + @testset "Slope parameter dependence" begin + # Smaller λ means larger particles + # Integrals should increase as λ decreases (larger particles) + + state_small = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 2000.0) # Small particles + + state_large = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 500.0) # Large particles + + # Reflectivity (D^6 weighted) should increase with larger particles + Z_small = evaluate(Reflectivity(), state_small) + Z_large = evaluate(Reflectivity(), state_large) + + @test Z_large > Z_small + + # Fall speed should also increase with larger particles + V_small = evaluate(NumberWeightedFallSpeed(), state_small) + V_large = evaluate(NumberWeightedFallSpeed(), state_large) + + @test V_large > V_small + end + + @testset "Shape parameter dependence" begin + # Higher μ narrows the distribution + state_mu0 = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + + state_mu2 = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 2.0, slope = 1000.0) + + state_mu4 = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 4.0, slope = 1000.0) + + # All should produce finite results + V0 = evaluate(NumberWeightedFallSpeed(), state_mu0) + V2 = evaluate(NumberWeightedFallSpeed(), state_mu2) + V4 = evaluate(NumberWeightedFallSpeed(), state_mu4) + + @test all(isfinite, [V0, V2, V4]) + @test all(x -> x > 0, [V0, V2, V4]) + end + + @testset "Rime fraction dependence" begin + # Test that both rimed and unrimed states produce valid results + state_unrimed = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + rime_fraction = 0.0, rime_density = 400.0) + + state_rimed = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + rime_fraction = 0.5, rime_density = 600.0) + + # Both should produce valid results for number-weighted fall speed + V_unrimed = evaluate(NumberWeightedFallSpeed(), state_unrimed) + V_rimed = evaluate(NumberWeightedFallSpeed(), state_rimed) + + @test isfinite(V_unrimed) + @test isfinite(V_rimed) + @test V_unrimed > 0 + @test V_rimed > 0 + + # Mass-weighted fall speed should be larger for rimed particles + # because particle_mass depends on rime_fraction (higher effective density) + F_m_unrimed = evaluate(MassWeightedFallSpeed(), state_unrimed) + F_m_rimed = evaluate(MassWeightedFallSpeed(), state_rimed) + + @test isfinite(F_m_unrimed) + @test isfinite(F_m_rimed) + @test F_m_rimed > F_m_unrimed # Higher mass → larger flux integral + end + + @testset "Liquid fraction dependence" begin + # Liquid on ice affects shedding and melting integrals + state_dry = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + liquid_fraction = 0.0) + + state_wet = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + liquid_fraction = 0.3) + + # Shedding should be zero when dry, positive when wet + shed_dry = evaluate(SheddingRate(), state_dry) + shed_wet = evaluate(SheddingRate(), state_wet) + + @test shed_dry ≈ 0 atol=1e-20 + @test shed_wet > 0 + + # Sixth moment shedding similarly + m6_shed_dry = evaluate(SixthMomentShedding(), state_dry) + m6_shed_wet = evaluate(SixthMomentShedding(), state_wet) + + @test m6_shed_dry ≈ 0 atol=1e-20 + @test m6_shed_wet > 0 + end + + @testset "Deposition integrals physical consistency" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + + # Ventilation integrals should be positive + v = evaluate(Ventilation(), state) + v_enh = evaluate(VentilationEnhanced(), state) + + @test v > 0 + @test v_enh ≥ 0 # Could be zero if no particles > 100 μm + + # Small + large ice ventilation should roughly equal total + v_sc = evaluate(SmallIceVentilationConstant(), state) + v_lc = evaluate(LargeIceVentilationConstant(), state) + + @test v_sc ≥ 0 + @test v_lc ≥ 0 + # Note: v_sc + v_lc ≈ v only if ventilation factor is same + end + + @testset "Collection integrals" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + + n_agg = evaluate(AggregationNumber(), state) + n_rw = evaluate(RainCollectionNumber(), state) + + @test n_agg > 0 + @test n_rw > 0 + @test isfinite(n_agg) + @test isfinite(n_rw) + end + + @testset "Sixth moment integrals physical consistency" begin + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0, + liquid_fraction = 0.1) + + # All sixth moment integrals should be finite and positive + m6_rime = evaluate(SixthMomentRime(), state) + m6_dep = evaluate(SixthMomentDeposition(), state) + m6_dep1 = evaluate(SixthMomentDeposition1(), state) + m6_mlt1 = evaluate(SixthMomentMelt1(), state) + m6_mlt2 = evaluate(SixthMomentMelt2(), state) + m6_agg = evaluate(SixthMomentAggregation(), state) + m6_shed = evaluate(SixthMomentShedding(), state) + m6_sub = evaluate(SixthMomentSublimation(), state) + m6_sub1 = evaluate(SixthMomentSublimation1(), state) + + @test all(isfinite, [m6_rime, m6_dep, m6_dep1, m6_mlt1, m6_mlt2, + m6_agg, m6_shed, m6_sub, m6_sub1]) + @test all(x -> x > 0, [m6_rime, m6_dep, m6_mlt1, m6_agg, m6_sub]) + end + + @testset "Float32 input precision" begin + # All integrals should work with Float32 input + # Note: FastGaussQuadrature returns Float64 nodes/weights, so output + # may be promoted to Float64. We just check the results are valid. + state = IceSizeDistributionState(Float32; + intercept = 1f6, + shape = 0f0, + slope = 1000f0) + + V_n = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=32) + V_m = evaluate(MassWeightedFallSpeed(), state; n_quadrature=32) + Z = evaluate(Reflectivity(), state; n_quadrature=32) + + # Results should be valid floating point numbers + @test isfinite(V_n) + @test isfinite(V_m) + @test isfinite(Z) + @test V_n > 0 + @test V_m > 0 + @test Z > 0 + end + + @testset "Extreme parameters" begin + # Very small particles (large λ) + state_small = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 10000.0) + + V_small = evaluate(NumberWeightedFallSpeed(), state_small) + @test isfinite(V_small) + @test V_small > 0 + + # Very large particles (small λ) + state_large = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 100.0) + + V_large = evaluate(NumberWeightedFallSpeed(), state_large) + @test isfinite(V_large) + @test V_large > 0 + + # High shape parameter + state_narrow = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 6.0, slope = 1000.0) + + V_narrow = evaluate(NumberWeightedFallSpeed(), state_narrow) + @test isfinite(V_narrow) + @test V_narrow > 0 + end + + ##### + ##### Analytical comparison tests + ##### + + @testset "Analytical comparison - exponential PSD moments" begin + # For exponential PSD (μ=0): N'(D) = N₀ exp(-λD) + # The n-th moment is: + # M_n = ∫₀^∞ D^n N'(D) dD = N₀ n! / λ^{n+1} + + N₀ = 1e6 + μ = 0.0 + λ = 1000.0 + + # For exponential (μ=0): M_n = N₀ n! / λ^{n+1} + # M0 = N₀ / λ = 1e6 / 1000 = 1e3 + # M6 = N₀ * 720 / λ^7 = 1e6 * 720 / 1e21 = 7.2e-13 + + state = IceSizeDistributionState(Float64; + intercept = N₀, shape = μ, slope = λ) + + # Test NumberMomentLambdaLimit (which integrates the full PSD) + small_q_lim = evaluate(NumberMomentLambdaLimit(), state; n_quadrature=128) + @test small_q_lim > 0 + @test isfinite(small_q_lim) + + # Test Reflectivity (which integrates D^6 N'(D)) + refl = evaluate(Reflectivity(), state; n_quadrature=128) + @test refl > 0 + @test isfinite(refl) + end + + @testset "Analytical comparison - gamma PSD with mu=2" begin + # For gamma PSD with μ=2: N'(D) = N₀ D² exp(-λD) + # M_n = N₀ Γ(n+3) / λ^{n+3} = N₀ (n+2)! / λ^{n+3} + + N₀ = 1e6 + μ = 2.0 + λ = 1000.0 + + state = IceSizeDistributionState(Float64; + intercept = N₀, shape = μ, slope = λ) + + # All integrals should return finite positive values + V_n = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=128) + V_m = evaluate(MassWeightedFallSpeed(), state; n_quadrature=128) + V_z = evaluate(ReflectivityWeightedFallSpeed(), state; n_quadrature=128) + + @test all(isfinite, [V_n, V_m, V_z]) + @test all(x -> x > 0, [V_n, V_m, V_z]) + + # For a power-law fall speed V(D) = a D^b, the ratio of moments gives: + # V_n / V_m should depend on μ in a predictable way + @test V_n > 0 + @test V_m > 0 + end + + @testset "Lambda limiter integrals consistency" begin + # The lambda limiter integrals should produce values that + # allow solving for λ bounds + + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + + small_q = evaluate(NumberMomentLambdaLimit(), state) + large_q = evaluate(MassMomentLambdaLimit(), state) + + @test small_q > 0 + @test large_q > 0 + @test isfinite(small_q) + @test isfinite(large_q) + + # LargeQ should be > SmallQ for same state since large q + # corresponds to small λ (larger particles) + # Actually, these are both positive but their relative values + # depend on the integral definition + @test small_q ≠ large_q + end + + @testset "Mass-weighted velocity ordering" begin + # For particles with power-law fall speed V(D) = a D^b (b > 0): + # Reflectivity-weighted (Z-weighted) mean velocity should be largest + # because it weights by D^6, emphasizing large particles + # Mass-weighted should be intermediate + # Number-weighted should be smallest + # Note: We must compare normalized mean velocities, not raw flux integrals. + + state = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 500.0) # Large particles + + # Flux integrals + F_n = evaluate(NumberWeightedFallSpeed(), state; n_quadrature=128) + F_m = evaluate(MassWeightedFallSpeed(), state; n_quadrature=128) + F_z = evaluate(ReflectivityWeightedFallSpeed(), state; n_quadrature=128) + + # Normalization moments + M_n = evaluate(NumberMomentLambdaLimit(), state; n_quadrature=128) + M_m = evaluate(MassMomentLambdaLimit(), state; n_quadrature=128) + M_z = evaluate(Reflectivity(), state; n_quadrature=128) + + # Mean velocities + V_n = F_n / M_n + V_m = F_m / M_m + V_z = F_z / M_z + + # All should be positive + @test V_n > 0 + @test V_m > 0 + @test V_z > 0 + + # Check ordering: V_z ≥ V_m ≥ V_n for most PSDs + @test V_z >= V_m + @test V_m >= V_n + end + + @testset "Ventilation integral properties" begin + # Ventilation factor accounts for enhanced mass transfer due to air flow + # For large particles (high Re), ventilation should be larger + + state_small = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 5000.0) # Small particles + + state_large = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 200.0) # Large particles + + v_small = evaluate(Ventilation(), state_small; n_quadrature=64) + v_large = evaluate(Ventilation(), state_large; n_quadrature=64) + + @test isfinite(v_small) + @test isfinite(v_large) + @test v_small > 0 + @test v_large > 0 + + # Larger particles have larger ventilation due to higher Reynolds + @test v_large > v_small + end + + @testset "Mean diameter integral" begin + # Mean diameter should scale inversely with λ + state1 = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 1000.0) + + state2 = IceSizeDistributionState(Float64; + intercept = 1e6, shape = 0.0, slope = 500.0) # Half λ → double mean D + + D_mean_1 = evaluate(MeanDiameter(), state1; n_quadrature=64) + D_mean_2 = evaluate(MeanDiameter(), state2; n_quadrature=64) + + @test D_mean_1 > 0 + @test D_mean_2 > 0 + + # D_mean_2 should be ~2x D_mean_1 for exponential (μ=0) distribution + @test D_mean_2 > D_mean_1 + end + + ##### + ##### Lambda solver tests + ##### + + @testset "IceMassPowerLaw construction" begin + mass = IceMassPowerLaw() + @test mass.coefficient ≈ 0.0121 + @test mass.exponent ≈ 1.9 + @test mass.ice_density ≈ 917.0 + + mass32 = IceMassPowerLaw(Float32) + @test mass32.coefficient isa Float32 + end + + @testset "ShapeParameterRelation construction" begin + relation = ShapeParameterRelation() + @test relation.a ≈ 0.00191 + @test relation.b ≈ 0.8 + @test relation.c ≈ 2.0 + @test relation.μmax ≈ 6.0 + + # Test shape parameter computation + μ = shape_parameter(relation, log(1000.0)) + @test μ ≥ 0 + @test μ ≤ relation.μmax + end + + @testset "Ice regime thresholds" begin + mass = IceMassPowerLaw() + + # Unrimed ice + thresholds_unrimed = ice_regime_thresholds(mass, 0.0, 400.0) + @test thresholds_unrimed.spherical > 0 + @test thresholds_unrimed.graupel == Inf + @test thresholds_unrimed.partial_rime == Inf + + # Rimed ice + thresholds_rimed = ice_regime_thresholds(mass, 0.5, 400.0) + @test thresholds_rimed.spherical > 0 + @test thresholds_rimed.graupel > thresholds_rimed.spherical + @test thresholds_rimed.partial_rime > thresholds_rimed.graupel + @test thresholds_rimed.ρ_graupel > 0 + end + + @testset "Ice mass computation" begin + mass = IceMassPowerLaw() + + # Small particles should have spherical mass + D_small = 1e-5 # 10 μm + m_small = ice_mass(mass, 0.0, 400.0, D_small) + m_sphere = mass.ice_density * π / 6 * D_small^3 + @test m_small ≈ m_sphere + + # Mass should increase with diameter + D_large = 1e-3 # 1 mm + m_large = ice_mass(mass, 0.0, 400.0, D_large) + @test m_large > m_small + end + + @testset "Lambda solver - basic functionality" begin + # Create a test case with known parameters + L_ice = 1e-4 # 0.1 g/m³ + N_ice = 1e5 # 100,000 particles/m³ + rime_fraction = 0.0 + rime_density = 400.0 + + logλ = solve_lambda(L_ice, N_ice, rime_fraction, rime_density) + + @test isfinite(logλ) + @test logλ > log(10) # Within bounds + @test logλ < log(1e7) + + λ = exp(logλ) + @test λ > 0 + end + + @testset "Lambda solver - consistency" begin + # Solve for λ, then verify the L/N ratio is recovered + L_ice = 1e-4 + N_ice = 1e5 + rime_fraction = 0.0 + rime_density = 400.0 + + mass = IceMassPowerLaw() + shape_relation = ShapeParameterRelation() + + params = distribution_parameters(L_ice, N_ice, rime_fraction, rime_density; + mass, shape_relation) + + @test params.N₀ > 0 + @test params.λ > 0 + @test params.μ ≥ 0 + + # The solved parameters should be consistent + @test isfinite(params.N₀) + @test isfinite(params.λ) + @test isfinite(params.μ) + end + + @testset "Lambda solver - rimed ice" begin + L_ice = 1e-4 + N_ice = 1e5 + rime_fraction = 0.5 + rime_density = 500.0 + + logλ = solve_lambda(L_ice, N_ice, rime_fraction, rime_density) + + @test isfinite(logλ) + @test exp(logλ) > 0 + end + + @testset "Lambda solver - edge cases" begin + # Zero mass or number should return the upper bound (smallest particles), + # not the unphysical λ = 0. + logλ_zero_L = solve_lambda(0.0, 1e5, 0.0, 400.0) + @test logλ_zero_L == log(1e7) + + logλ_zero_N = solve_lambda(1e-4, 0.0, 0.0, 400.0) + @test logλ_zero_N == log(1e7) + end + + @testset "Lambda solver - L/N dependence" begin + # Higher L/N ratio means larger particles, hence smaller λ + N_ice = 1e5 + rime_fraction = 0.0 + rime_density = 400.0 + + logλ_small = solve_lambda(1e-5, N_ice, rime_fraction, rime_density) # Small L/N + logλ_large = solve_lambda(1e-3, N_ice, rime_fraction, rime_density) # Large L/N + + # Larger mean mass → smaller λ (larger characteristic diameter) + @test logλ_large < logλ_small + end +end diff --git a/validation/README.md b/validation/README.md new file mode 100644 index 000000000..77acec477 --- /dev/null +++ b/validation/README.md @@ -0,0 +1,13 @@ +# Validation Data + +This directory holds local, non-CI reference datasets and helper environments +used to validate Breeze features during development. + +Contents +- `validation/p3`: P3 kin1d reference data and reproduction notes. +- `validation/p3_env`: Local Julia environment used to write NetCDF files. +- `validation/topography`: Topography validation utilities. + +Notes +- The files in `validation/` are intended for local use. Decide case-by-case + whether to commit them. diff --git a/validation/p3/P3_IMPLEMENTATION_STATUS.md b/validation/p3/P3_IMPLEMENTATION_STATUS.md new file mode 100644 index 000000000..9dfcc9aee --- /dev/null +++ b/validation/p3/P3_IMPLEMENTATION_STATUS.md @@ -0,0 +1,366 @@ +# P3 Microphysics Implementation Status + +This document summarizes the current state of the Predicted Particle Properties (P3) +microphysics implementation in Breeze.jl and what remains to reach parity with the +reference Fortran implementation in the [P3-microphysics repository](https://github.com/P3-microphysics/P3-microphysics) (v5.5.0). + +## Overview + +P3 is a bulk microphysics scheme that uses a **single ice category** with continuously +predicted properties, rather than discrete categories like cloud ice, snow, graupel, +and hail. The implementation follows: + +- **Morrison & Milbrandt (2015a)** - Original P3 scheme +- **Milbrandt et al. (2021)** - Three-moment ice (mass, number, reflectivity) +- **Milbrandt et al. (2025)** - Predicted liquid fraction + +## Current Implementation Status + +### ✅ Complete + +#### Core Infrastructure + +| Component | Description | Status | +|-----------|-------------|--------| +| **Prognostic fields** | 9 fields: `ρqᶜˡ`, `ρqʳ`, `ρnʳ`, `ρqⁱ`, `ρnⁱ`, `ρqᶠ`, `ρbᶠ`, `ρzⁱ`, `ρqʷⁱ` | ✅ | +| **AtmosphereModel integration** | `microphysics` interface for P3 as a drop-in scheme | ✅ | +| **Field materialization** | Creates all prognostic and diagnostic fields | ✅ | +| **Moisture fraction computation** | `compute_moisture_fractions` for thermodynamics | ✅ | +| **State update** | `update_microphysical_fields!` computes vapor as residual | ✅ | + +#### Size Distribution & Lambda Solver + +| Component | Description | Status | +|-----------|-------------|--------| +| **Gamma distribution** | `IceSizeDistributionState` with N₀, μ, λ | ✅ | +| **Two-moment closure** | μ-λ relationship from Field et al. (2007) | ✅ | +| **Three-moment closure** | μ from Z/N constraint, no empirical closure | ✅ | +| **Lambda solver** | Secant method for two-moment and three-moment | ✅ | +| **Mass-diameter relation** | Piecewise power law (4 regimes) | ✅ | +| **Regime thresholds** | `ice_regime_thresholds` for small/aggregate/graupel/partial | ✅ | +| **Distribution parameters** | `distribution_parameters(L, N, ...)` and `(L, N, Z, ...)` | ✅ | + +#### Quadrature & Tabulation + +| Component | Description | Status | +|-----------|-------------|--------| +| **Chebyshev-Gauss quadrature** | Numerical integration over size distribution | ✅ | +| **Domain transformation** | x ∈ [-1, 1] → D ∈ [0, ∞) | ✅ | +| **Integral type hierarchy** | Abstract types for all integral categories | ✅ | +| **Integrand functions** | Fall speed, deposition, bulk properties, collection, sixth moment | ✅ | +| **Tabulation infrastructure** | `TabulatedIntegral` wrapper for lookup tables | ✅ | +| **Tabulation parameters** | `TabulationParameters` for grid specification | ✅ | + +#### Ice Property Containers + +| Component | Description | Status | +|-----------|-------------|--------| +| **IceProperties** | Top-level container for all ice computations | ✅ | +| **IceFallSpeed** | Number/mass/reflectivity-weighted fall speed integrals | ✅ | +| **IceDeposition** | Ventilation integrals for vapor diffusion | ✅ | +| **IceBulkProperties** | Mean diameter, density, reflectivity | ✅ | +| **IceCollection** | Aggregation, rain collection integrals | ✅ | +| **IceSixthMoment** | Z-tendency integrals for rime, deposition, melt, etc. | ✅ | +| **IceLambdaLimiter** | Constraints on λ bounds | ✅ | +| **IceRainCollection** | Ice-rain collection integrals | ✅ | +| **RainProperties** | Rain integral types | ✅ | +| **CloudDropletProperties** | Cloud droplet parameters | ✅ | + +### ⚠️ Partial / Simplified + +| Component | Description | Status | +|-----------|-------------|--------| +| **Terminal velocity** | Simplified power law; needs regime-dependent coefficients | ⚠️ | +| **Particle mass in quadrature** | Simplified effective density; needs full piecewise m(D) | ⚠️ | +| **Capacitance** | Simple sphere/plate formula; needs full shape model | ⚠️ | +| **Critical diameters** | Placeholder values; needs dynamic computation | ⚠️ | + +### ✅ Phase 1 Process Rates (VERIFIED WORKING) + +Phase 1 process rates are implemented in `process_rates.jl` and have been verified to work +in a parcel model test. After 100 seconds of simulation: +- Cloud liquid: 5.0 → 3.0 g/kg (autoconversion/accretion) +- Rain: 1.0 → 0.002 g/kg (evaporation in subsaturated air) +- Ice: 2.0 → 0.0 g/kg (melting at T > 273 K) + +#### Rain Processes + +| Process | Function | Status | +|---------|----------|--------| +| **Rain autoconversion** | `rain_autoconversion_rate` | ✅ | +| **Rain accretion** | `rain_accretion_rate` | ✅ | +| **Rain self-collection** | `rain_self_collection_rate` | ✅ | +| **Rain evaporation** | `rain_evaporation_rate` | ✅ | + +Implementation follows Khairoutdinov & Kogan (2000) for autoconversion/accretion +and Seifert & Beheng (2001) for self-collection. + +#### Ice Deposition/Sublimation + +| Process | Function | Status | +|---------|----------|--------| +| **Ice deposition** | `ice_deposition_rate` | ✅ | +| **Ventilation-enhanced deposition** | `ventilation_enhanced_deposition` | ✅ | + +Relaxation-to-saturation formulation with simplified ventilation factors. + +#### Melting + +| Process | Function | Status | +|---------|----------|--------| +| **Ice melting (mass)** | `ice_melting_rate` | ✅ | +| **Ice melting (number)** | `ice_melting_number_rate` | ✅ | + +Temperature-dependent melting rate for T > T_freeze. + +#### Tendency Integration + +| Component | Description | Status | +|-----------|-------------|--------| +| **P3ProcessRates** | Container for all computed rates | ✅ | +| **compute_p3_process_rates** | Main rate calculation function | ✅ | +| **microphysical_tendency** | Now dispatches to field-specific tendencies | ✅ | +| **Field tendency functions** | `tendency_ρqᶜˡ`, `tendency_ρqʳ`, etc. | ✅ | + +### ⚠️ Partially Implemented + +#### Process Rate Tendencies + +| Process | Function | Status | +|---------|----------|--------| +| **Ice aggregation** | `ice_aggregation_rate` | ✅ | +| **Cloud riming** | `cloud_riming_rate` | ✅ | +| **Rain riming** | `rain_riming_rate` | ✅ | +| **Shedding** | `shedding_rate` | ✅ | +| **Refreezing** | `refreezing_rate` | ✅ | +| **Rime density** | `rime_density` | ✅ | + +### ✅ Ice Nucleation & Secondary Ice (VERIFIED) + +Ice nucleation and secondary ice production are now implemented: + +#### Ice Nucleation + +| Process | Function | Status | +|---------|----------|--------| +| **Deposition nucleation** | `deposition_nucleation_rate` | ✅ | +| **Immersion freezing (cloud)** | `immersion_freezing_cloud_rate` | ✅ | +| **Immersion freezing (rain)** | `immersion_freezing_rain_rate` | ✅ | +| **Rime splintering** | `rime_splintering_rate` | ✅ | + +- Deposition nucleation: Cooper (1986) parameterization, T < -15°C, Sᵢ > 5% +- Immersion freezing: Bigg (1953) stochastic freezing, T < -4°C +- Rime splintering: Hallett-Mossop (1974), -8°C < T < -3°C, peaks at -5°C + +#### Sixth Moment Tendencies + +| Process | Status | +|---------|--------| +| **Z from deposition/melting** | ✅ | +| **Z from nucleation** | ✅ | +| **Z from riming** | ✅ | +| **Z from aggregation** | ✅ (conserved approximation) | + +### ❌ Not Implemented + +#### Remaining Components + +| Process | Description | Status | +|---------|-------------|--------| +| **Cloud droplet activation** | (aerosol module) | ❌ | +| **Cloud condensation/evaporation** | (saturation adjustment) | ❌ | +| **Lookup tables** | Read Fortran tables | ❌ | + +#### Sedimentation + +| Component | Status | +|-----------|--------| +| **Rain sedimentation** | ✅ | +| **Ice sedimentation** | ✅ | +| **Terminal velocity computation** | ✅ | +| **Flux-form advection** | ✅ (via Oceananigans) | +| **Substepping** | ⚠️ (not yet, may be needed for stability) | + +Note: P3 uses a different sedimentation approach than DCMIP2016Kessler. Rather than +explicit column-by-column sedimentation with substepping, P3 returns terminal velocity +structs via `microphysical_velocities` that are used by Oceananigans' tracer advection. +Substepping would need to be implemented at a higher level (e.g., in the time stepper). + +#### Lookup Tables + +| Component | Description | Status | +|-----------|-------------|--------| +| **Tabulation infrastructure** | `tabulate()` function, `TabulationParameters` | ✅ | +| **Fall speed tabulation** | `tabulate(p3, :ice_fall_speed, arch)` | ✅ | +| **Deposition tabulation** | `tabulate(p3, :ice_deposition, arch)` | ✅ | +| **Reading Fortran tables** | Parse `p3_lookupTable_*.dat` files | ❌ | +| **Table 1** | Ice property integrals (size, rime, μ) | ⚠️ (can generate, not read) | +| **Table 2** | Rain property integrals | ❌ | +| **Table 3** | Z integrals for three-moment ice | ❌ | +| **GPU table storage** | Transfer tables to GPU architecture | ⚠️ (TODO in code) | + +#### Other + +| Component | Description | Status | +|-----------|-------------|--------| +| **Multiple ice categories** | Milbrandt & Morrison (2016) Part III | ❌ (not planned) | +| **Substepping** | Implicit/operator-split time stepping | ❌ | +| **Diagnostics** | Reflectivity, precipitation rate | ❌ | +| **Aerosol coupling** | CCN activation | ❌ | + +## Validation + +A reference dataset from the Fortran `kin1d` driver is available in `validation/p3/`: + +- `kin1d_reference.nc`: NetCDF output from Fortran P3 (v5.5.0) +- Configuration: 3-moment ice, liquid fraction, 90 min simulation +- Variables: temperature, mixing ratios, number concentrations, reflectivity, precip rates + +## Roadmap to Parity + +### Phase 1: Core Process Rates ✅ COMPLETE + +1. **Rain processes** ✅ + - Autoconversion (cloud → rain): `rain_autoconversion_rate` + - Accretion (cloud + rain → rain): `rain_accretion_rate` + - Self-collection: `rain_self_collection_rate` + - Evaporation: `rain_evaporation_rate` + +2. **Ice deposition/sublimation** ✅ + - Vapor diffusion growth/loss: `ice_deposition_rate` + - Ventilation factors: `ventilation_enhanced_deposition` + +3. **Melting** ✅ + - Ice → rain conversion: `ice_melting_rate` + - Number tendency: `ice_melting_number_rate` + +### Phase 2: Ice-Specific Processes ✅ COMPLETE + +Phase 2 process rates are implemented in `process_rates.jl` and verified: +- Ice number decreased from 10000 → 5723 /kg (aggregation active) +- Cloud liquid consumed by autoconversion + accretion + riming +- Rime density computed based on temperature + +4. **Aggregation** ✅ + - `ice_aggregation_rate`: Temperature-dependent sticking efficiency + - Linear ramp from E=0.1 at 253K to E=1.0 at 268K + +5. **Riming** ✅ + - `cloud_riming_rate`: Cloud droplet collection by ice + - `rain_riming_rate`: Rain collection by ice + - `rime_density`: Temperature/velocity-dependent rime density + - Rime mass/volume tendency updates + +6. **Shedding/Refreezing** (liquid fraction) ✅ + - `shedding_rate`: Excess liquid sheds as rain (enhanced above 273K) + - `refreezing_rate`: Liquid on ice refreezes below 273K + - `shedding_number_rate`: Rain drops from shed liquid + +7. **Ice Nucleation** ✅ + - `deposition_nucleation_rate`: Cooper (1986), T < -15°C, Sᵢ > 5% + - `immersion_freezing_cloud_rate`: Bigg (1953), cloud droplets freeze at T < -4°C + - `immersion_freezing_rain_rate`: Bigg (1953), rain drops freeze at T < -4°C + +8. **Secondary Ice Production** ✅ + - `rime_splintering_rate`: Hallett-Mossop (1974), -8°C < T < -3°C + - Peaks at -5°C, ~350 splinters per mg of rime + +### Phase 3: Sedimentation & Performance ✅ COMPLETE + +Phase 3 terminal velocities are implemented in `process_rates.jl` and verified: +- Rain mass-weighted: 4.4 m/s (1 mm drops, typical) +- Unrimed ice: 1.0 m/s (aggregates, typical) +- Rimed ice/graupel: 1.5 m/s (riming increases density) + +7. **Terminal velocities** ✅ + - `rain_terminal_velocity_mass_weighted`: Power-law with density correction + - `rain_terminal_velocity_number_weighted`: For rain number sedimentation + - `ice_terminal_velocity_mass_weighted`: Regime-dependent (Stokes/Mitchell) + - `ice_terminal_velocity_number_weighted`: For ice number sedimentation + - `ice_terminal_velocity_reflectivity_weighted`: For Z sedimentation + +8. **Sedimentation** ✅ + - `microphysical_velocities` implemented for all 8 precipitating fields + - Callable velocity structs: `RainMassSedimentationVelocity`, etc. + - Returns `(u=0, v=0, w=-vₜ)` for advection interface + +9. **Lookup tables** ❌ (Not yet) + - Read Fortran tables or regenerate in Julia + - GPU-compatible table access + +### Phase 4: Validation ⚠️ IN PROGRESS + +Reference data, visualization tools, and a simplified kinematic driver are available. + +10. **kin1d comparison** ⚠️ + - ✅ Reference data loaded and visualized (`compare_kin1d.jl`) + - ✅ Overview figure generated (`kin1d_reference_overview.png`) + - ✅ Simplified kinematic driver (`kinematic_column_driver.jl`) + - ✅ Comparison figure generated (`kin1d_comparison.png`) + - ⚠️ Qualitative agreement achieved; quantitative differences remain + +**Comparison Results (Simplified Driver vs. Fortran P3):** + +| Metric | Fortran P3 | Breeze.jl | +|--------|------------|-----------| +| Max cloud liquid | 4.19 g/kg | 7.92 g/kg | +| Max rain | 5.47 g/kg | 20.34 g/kg | +| Max ice | 12.27 g/kg | 13.22 g/kg | +| Max rime fraction | 0.999 | 0.980 | + +The simplified driver shows qualitative agreement (cloud/ice/rime structure is similar) +but quantitative differences arise from: +- Basic upstream advection (vs. Fortran's scheme) +- Simplified condensation/nucleation parameterizations +- Crude sedimentation implementation +- Not calling full P3 process rate functions + +For true parity, the driver needs: +- Exact advection scheme matching +- Full P3 process rate integration +- Proper sedimentation with substepping + +11. **3D LES cases** ❌ + - BOMEX with ice + - Deep convection cases + +## Code Organization + +``` +src/Microphysics/PredictedParticleProperties/ +├── PredictedParticleProperties.jl # Module definition, exports +├── p3_scheme.jl # Main PredictedParticlePropertiesMicrophysics type +├── p3_interface.jl # AtmosphereModel integration +├── process_rates.jl # Phase 1+2 process rates and terminal velocities +├── integral_types.jl # Abstract integral type hierarchy +├── size_distribution.jl # Gamma distribution, regime thresholds +├── lambda_solver.jl # Two/three-moment λ, μ solvers +├── quadrature.jl # Chebyshev-Gauss integration +├── tabulation.jl # Lookup table infrastructure +├── ice_properties.jl # IceProperties container +├── ice_fall_speed.jl # Fall speed integral types +├── ice_deposition.jl # Ventilation integral types +├── ice_bulk_properties.jl # Bulk property integral types +├── ice_collection.jl # Collection integral types +├── ice_sixth_moment.jl # Z-tendency integral types +├── ice_lambda_limiter.jl # λ constraint integral types +├── ice_rain_collection.jl # Ice-rain collection integrals +├── rain_properties.jl # Rain integral types +└── cloud_droplet_properties.jl # Cloud properties +``` + +## References + +1. Morrison, H., and J. A. Milbrandt, 2015a: Parameterization of cloud microphysics + based on the prediction of bulk ice particle properties. Part I: Scheme description + and idealized tests. J. Atmos. Sci., 72, 287–311. + +2. Milbrandt, J. A., H. Morrison, D. T. Dawson II, and M. Paukert, 2021: A triple-moment + representation of ice in the Predicted Particle Properties (P3) microphysics scheme. + J. Atmos. Sci., 78, 439–458. + +3. Milbrandt, J. A., H. Morrison, A. Ackerman, and H. Jäkel, 2025: Predicted liquid + fraction on ice particles in the P3 microphysics scheme. J. Atmos. Sci. (submitted). + +4. Field, P. R., et al., 2007: Snow size distribution parameterization for midlatitude + and tropical ice clouds. J. Atmos. Sci., 64, 4346–4365. diff --git a/validation/p3/README.md b/validation/p3/README.md new file mode 100644 index 000000000..627481a4b --- /dev/null +++ b/validation/p3/README.md @@ -0,0 +1,77 @@ +# P3 kin1d reference data + +This directory holds a local reference dataset produced from the P3 Fortran +`kin1d` kinematic driver. The goal is to compare Breeze's P3 coupling against +the Fortran reference while the implementation is in progress. + +Files +- `kin1d_reference.nc`: NetCDF version of the Fortran `out_p3.dat` output. +- `make_kin1d_reference.jl`: Script that converts `out_p3.dat` to NetCDF. + +Case configuration (current reference) +- P3 repo commit: `24bf078ba70cb53818a03ddccc3a95cbb391fcd5` +- Driver: `kin1d/src/cld1d.f90` +- Config: `nCat=1`, `trplMomIce=true`, `liqFrac=true` +- `dt=10 s`, `outfreq=1 min`, `total=90 min`, `nk=41` +- Sounding: `snd_input.KOUN_00z1june2008.data` +- Lookup tables: `p3_lookupTable_1.dat-v6.9-3momI`, `p3_lookupTable_2.dat-v6.2`, + `p3_lookupTable_3.dat-v1.4` +- Note: `cld1d.f90` has `version_p3 = 'v5.3.14'`, while `P3_INIT` prints `v5.5.0`. + Both are recorded as NetCDF global attributes. + +NetCDF contents +- Dimensions: `time` (seconds), `z` (meters) +- Variables: + - `w` (time, z): vertical velocity [m s-1] + - `prt_liq` (time): liquid precip rate [mm h-1] + - `prt_sol` (time): solid precip rate [mm h-1] + - `reflectivity` (time, z): radar reflectivity [dBZ] + - `temperature` (time, z): temperature [C] + - `q_cloud`, `q_rain`, `q_ice` (time, z): mixing ratios [kg kg-1] + - `n_cloud`, `n_rain`, `n_ice` (time, z): number mixing ratios [kg-1] + - `rime_fraction`, `liquid_fraction` (time, z): unitless fractions + - `drm` (time, z): rain mean volume diameter [m] + - Category-1 ice diagnostics: `q_ice_cat1`, `q_rime_cat1`, + `q_liquid_on_ice_cat1`, `n_ice_cat1`, `b_rime_cat1`, `z_ice_cat1`, + `rho_ice_cat1`, `d_ice_cat1` + +The NetCDF variables map directly to the columns written by the Fortran driver. +See the `write(30, ...)` block in `kin1d/src/cld1d.f90` for the authoritative +column definitions. + +How to reproduce + +1) Build and run the Fortran driver (P3 repo): +``` +P3_REPO=/Users/gregorywagner/Projects/P3-microphysics + +cd $P3_REPO/lookup_tables +gunzip -k p3_lookupTable_1.dat-v6.9-2momI.gz \ + p3_lookupTable_1.dat-v6.9-3momI.gz \ + p3_lookupTable_2.dat-v6.2.gz \ + p3_lookupTable_3.dat-v1.4.gz + +cd $P3_REPO/kin1d/src +ln -s ../soundings soundings +ln -s ../lookup_tables lookup_tables +ln -s ../levels levels + +make execld +./execld +``` +This produces `out_p3.dat` in `kin1d/src`. + +2) Convert `out_p3.dat` to NetCDF: +``` +cd /Users/gregorywagner/Projects/alt/Breeze.jl +P3_REPO=/Users/gregorywagner/Projects/P3-microphysics \ +/Applications/Julia-1.10.app/Contents/Resources/julia/bin/julia \ + --project=validation/p3_env \ + validation/p3/make_kin1d_reference.jl +``` + +If you change `nk`, `outfreq`, or the sounding, set these environment variables +before running the script: +- `P3_NK` (default 41) +- `P3_OUTFREQ_MIN` (default 1) +- `P3_SOUNDING` (default `snd_input.KOUN_00z1june2008.data`) diff --git a/validation/p3/compare_kin1d.jl b/validation/p3/compare_kin1d.jl new file mode 100644 index 000000000..c519a9cc5 --- /dev/null +++ b/validation/p3/compare_kin1d.jl @@ -0,0 +1,282 @@ +##### +##### P3 kin1d comparison script +##### +##### This script compares Breeze.jl's P3 implementation against the +##### Fortran P3-microphysics kin1d kinematic driver reference data. +##### + +using NCDatasets +using CairoMakie +using Statistics + +##### +##### Load Fortran reference data +##### + +reference_path = joinpath(@__DIR__, "kin1d_reference.nc") +ds = NCDataset(reference_path, "r") + +# Extract dimensions +time_seconds = ds["time"][:] +height_meters = ds["z"][:] +nt = length(time_seconds) +nz = length(height_meters) + +# Convert time to minutes for plotting +time_minutes = time_seconds ./ 60 + +# Note: z is ordered from top to bottom in the Fortran output +# z[1] = 12840 m (top), z[end] = 35 m (bottom) +# We'll keep this ordering for consistency + +println("=== Fortran P3 kin1d Reference Data ===") +println("Time: $(time_minutes[1]) to $(time_minutes[end]) minutes ($(nt) steps)") +println("Height: $(height_meters[end]) to $(height_meters[1]) m ($(nz) levels)") +println() + +# Extract key variables +w = ds["w"][:, :] # Vertical velocity [m/s] +temperature_C = ds["temperature"][:, :] # Temperature [°C] +q_cloud = ds["q_cloud"][:, :] # Cloud liquid [kg/kg] +q_rain = ds["q_rain"][:, :] # Rain [kg/kg] +q_ice = ds["q_ice"][:, :] # Total ice [kg/kg] +n_ice = ds["n_ice"][:, :] # Ice number [1/kg] +rime_fraction = ds["rime_fraction"][:, :] +liquid_fraction = ds["liquid_fraction"][:, :] +reflectivity = ds["reflectivity"][:, :] +prt_liq = ds["prt_liq"][:] # Liquid precip rate [mm/h] +prt_sol = ds["prt_sol"][:] # Solid precip rate [mm/h] + +# Category 1 ice diagnostics +q_rime_cat1 = ds["q_rime_cat1"][:, :] +q_liquid_on_ice_cat1 = ds["q_liquid_on_ice_cat1"][:, :] +z_ice_cat1 = ds["z_ice_cat1"][:, :] +rho_ice_cat1 = ds["rho_ice_cat1"][:, :] +d_ice_cat1 = ds["d_ice_cat1"][:, :] + +close(ds) + +# Print statistics +println("=== Reference Data Statistics ===") +println("Max vertical velocity: $(maximum(w)) m/s") +println("Max q_cloud: $(maximum(q_cloud) * 1000) g/kg") +println("Max q_rain: $(maximum(q_rain) * 1000) g/kg") +println("Max q_ice: $(maximum(q_ice) * 1000) g/kg") +println("Max reflectivity: $(maximum(reflectivity)) dBZ") +println("Max liquid precip: $(maximum(prt_liq)) mm/h") +println("Max solid precip: $(maximum(prt_sol)) mm/h") +println() + +##### +##### Create visualization of Fortran reference +##### + +# Convert height to km for plotting +height_km = height_meters ./ 1000 + +# Set up figure +fig = Figure(size=(1400, 1000), fontsize=12) + +# Note: NCDatasets loads as (time, z), which is exactly what heatmap(x, y, data) expects +# where x=time has 90 points and y=z has 41 points, and data is (90, 41) + +# Row 1: Hydrometeor mixing ratios +ax1 = Axis(fig[1, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Cloud Liquid [g/kg]") +hm1 = heatmap!(ax1, time_minutes, height_km, q_cloud .* 1000, + colormap=:blues, colorrange=(0, maximum(q_cloud)*1000)) +Colorbar(fig[1, 2], hm1) + +ax2 = Axis(fig[1, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Rain [g/kg]") +hm2 = heatmap!(ax2, time_minutes, height_km, q_rain .* 1000, + colormap=:greens, colorrange=(0, maximum(q_rain)*1000)) +Colorbar(fig[1, 4], hm2) + +ax3 = Axis(fig[1, 5], xlabel="Time [min]", ylabel="Height [km]", + title="Ice [g/kg]") +hm3 = heatmap!(ax3, time_minutes, height_km, q_ice .* 1000, + colormap=:reds, colorrange=(0, maximum(q_ice)*1000)) +Colorbar(fig[1, 6], hm3) + +# Row 2: Ice properties +ax4 = Axis(fig[2, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Rime Fraction") +hm4 = heatmap!(ax4, time_minutes, height_km, rime_fraction, + colormap=:viridis, colorrange=(0, 1)) +Colorbar(fig[2, 2], hm4) + +ax5 = Axis(fig[2, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Liquid Fraction on Ice") +hm5 = heatmap!(ax5, time_minutes, height_km, liquid_fraction, + colormap=:plasma, colorrange=(0, 1)) +Colorbar(fig[2, 4], hm5) + +ax6 = Axis(fig[2, 5], xlabel="Time [min]", ylabel="Height [km]", + title="Reflectivity [dBZ]") +hm6 = heatmap!(ax6, time_minutes, height_km, reflectivity, + colormap=:turbo, colorrange=(-10, 60)) +Colorbar(fig[2, 6], hm6) + +# Row 3: Dynamics and precip +ax7 = Axis(fig[3, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Vertical Velocity [m/s]") +hm7 = heatmap!(ax7, time_minutes, height_km, w, + colormap=:RdBu, colorrange=(-5, 5)) +Colorbar(fig[3, 2], hm7) + +ax8 = Axis(fig[3, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Temperature [°C]") +hm8 = heatmap!(ax8, time_minutes, height_km, temperature_C, + colormap=:thermal, colorrange=(-70, 30)) +Colorbar(fig[3, 4], hm8) + +ax9 = Axis(fig[3, 5:6], xlabel="Time [min]", ylabel="Precip Rate [mm/h]", + title="Surface Precipitation") +lines!(ax9, time_minutes, prt_liq, label="Liquid", color=:blue) +lines!(ax9, time_minutes, prt_sol, label="Solid", color=:red) +axislegend(ax9, position=:rt) + +# Save figure +save(joinpath(@__DIR__, "kin1d_reference_overview.png"), fig) +println("Saved: kin1d_reference_overview.png") + +##### +##### Analyze the simulation physics +##### + +println() +println("=== Simulation Analysis ===") +println() + +# Find time of maximum ice +t_max_ice = time_minutes[argmax(maximum(q_ice, dims=2)[:, 1])] +z_max_ice = height_km[argmax(maximum(q_ice, dims=1)[1, :])] +println("Peak ice formation: t = $(round(t_max_ice, digits=1)) min, z = $(round(z_max_ice, digits=1)) km") + +# Find time of maximum rain at surface (k=end is near surface) +t_max_rain_sfc = time_minutes[argmax(q_rain[:, end])] +println("Peak rain at surface: t = $(round(t_max_rain_sfc, digits=1)) min") + +# Find when updraft turns off (w peaks then decreases) +max_w_time = time_minutes[argmax(maximum(w, dims=2)[:, 1])] +println("Peak updraft: t = $(round(max_w_time, digits=1)) min") + +# Check ice crystal properties at peak ice time +i_peak = argmax(maximum(q_ice, dims=2)[:, 1]) +j_peak = argmax(q_ice[i_peak, :]) +println("At peak ice:") +println(" - Mean ice diameter: $(round(d_ice_cat1[i_peak, j_peak] * 1e6, digits=1)) μm") +println(" - Ice bulk density: $(round(rho_ice_cat1[i_peak, j_peak], digits=1)) kg/m³") +println(" - Rime fraction: $(round(rime_fraction[i_peak, j_peak], digits=2))") + +##### +##### Discussion of comparison methodology +##### + +println() +println("=" ^ 60) +println("COMPARISON METHODOLOGY") +println("=" ^ 60) +println() +println(""" +The Fortran kin1d driver is a specialized 1D kinematic cloud model that: + +1. PRESCRIBED DYNAMICS: The vertical velocity is prescribed, not computed. + - Starts at 2 m/s, evolves to 5 m/s peak + - Profile shape evolves with cloud top height + - Updraft shuts off after 60 minutes + +2. ADVECTION: Uses upstream differencing for vertical advection of all + hydrometeor species, temperature, and moisture. + +3. DIVERGENCE/COMPRESSIBILITY: Applies mass-weighted divergence corrections + to maintain consistency with the prescribed w-profile. + +4. MOISTURE SOURCE: Adds low-level moisture to prevent depletion. + +5. P3 MICROPHYSICS: Calls the full P3 scheme at each 10s timestep. + +To reproduce this in Breeze.jl would require: + +a) Creating a specialized 1D kinematic driver (not the 3D LES framework) +b) Implementing prescribed velocity forcing +c) Matching the exact advection scheme (upstream differencing) +d) Matching the divergence/compressibility corrections + +The current Breeze.jl P3 implementation provides the MICROPHYSICS TENDENCIES, +but the kin1d driver tests the complete MICROPHYSICS + ADVECTION + SEDIMENTATION +system integrated together. + +For a fair comparison, we should compare: +- Individual process rates (autoconversion, accretion, etc.) in isolation +- Terminal velocities +- Size distribution parameters + +The full kin1d comparison requires implementing the kinematic driver framework. +""") + +##### +##### Quick process rate comparison (conceptual) +##### + +println() +println("=" ^ 60) +println("P3 PROCESS RATE IMPLEMENTATION STATUS") +println("=" ^ 60) +println() + +println(""" +Breeze.jl P3 implements the following process rates (in process_rates.jl): + +WARM RAIN (Khairoutdinov-Kogan 2000): + ✅ rain_autoconversion_rate - Cloud → Rain conversion + ✅ rain_accretion_rate - Cloud collection by rain + ✅ rain_self_collection_rate - Rain number reduction + ✅ rain_evaporation_rate - Subsaturated rain evaporation + +ICE DEPOSITION/SUBLIMATION: + ✅ ice_deposition_rate - Vapor diffusion growth + ✅ ventilation_enhanced_deposition - Large particle ventilation + +MELTING: + ✅ ice_melting_rate - Ice → Rain at T > 0°C + ✅ ice_melting_number_rate - Number tendency from melting + +ICE-ICE INTERACTIONS: + ✅ ice_aggregation_rate - Self-collection (number reduction) + +RIMING: + ✅ cloud_riming_rate - Cloud droplet collection by ice + ✅ rain_riming_rate - Rain collection by ice + ✅ rime_density - Temperature-dependent rime density + +LIQUID FRACTION: + ✅ shedding_rate - Liquid coating → rain + ✅ refreezing_rate - Liquid coating → ice below 0°C + +ICE NUCLEATION: + ✅ deposition_nucleation_rate - Cooper (1986) parameterization + ✅ immersion_freezing_cloud_rate - Bigg (1953) cloud freezing + ✅ immersion_freezing_rain_rate - Bigg (1953) rain freezing + +SECONDARY ICE: + ✅ rime_splintering_rate - Hallett-Mossop (1974) + +TERMINAL VELOCITIES: + ✅ rain_terminal_velocity_mass_weighted + ✅ rain_terminal_velocity_number_weighted + ✅ ice_terminal_velocity_mass_weighted + ✅ ice_terminal_velocity_number_weighted + ✅ ice_terminal_velocity_reflectivity_weighted + +NOT YET IMPLEMENTED: + ❌ Lookup table integration (uses simplified parameterizations) + ❌ Full size distribution integrals via tabulated values + ❌ Complete Z tendencies for all processes + ❌ Cloud droplet activation (aerosol coupling) +""") + +println() +println("Reference data saved. For a complete comparison, implement a") +println("kinematic driver in Breeze.jl or compare isolated process rates.") diff --git a/validation/p3/kinematic_column_driver.jl b/validation/p3/kinematic_column_driver.jl new file mode 100644 index 000000000..a89dc4891 --- /dev/null +++ b/validation/p3/kinematic_column_driver.jl @@ -0,0 +1,550 @@ +##### +##### Kinematic 1D column driver for P3 microphysics comparison +##### +##### This script implements a simplified 1D kinematic cloud model +##### similar to the Fortran kin1d driver, allowing direct comparison +##### of Breeze.jl's P3 microphysics against the Fortran reference. +##### + +using Oceananigans +using Oceananigans.Units +using Breeze +using Breeze.Thermodynamics: ThermodynamicConstants, saturation_specific_humidity_over_liquid +using Breeze.Microphysics.PredictedParticleProperties: + PredictedParticlePropertiesMicrophysics, + compute_p3_process_rates, + P3ProcessRates + +using NCDatasets +using CairoMakie +using Printf +using Statistics + +##### +##### Configuration matching kin1d +##### + +nz = 41 # Number of vertical levels +Δt = 10.0 # Time step [s] +total_time = 90minutes # Total simulation time +output_interval = 1minute + +# Physical constants matching Fortran +g = 9.81 # Gravitational acceleration [m/s²] +Rd = 287.0 # Gas constant for dry air [J/kg/K] +cₚ = 1005.0 # Specific heat at constant pressure [J/kg/K] +T₀ = 273.15 # Reference temperature [K] + +# Updraft parameters (from kin1d) +w_initial = 2.0 # Initial central updraft speed [m/s] +w_max = 5.0 # Maximum central updraft speed [m/s] +initial_cloud_top = 5000.0 # Initial height of cloud top [m] +updraft_period = 5400.0 # Period for evolving updraft [s] +cloud_top_period = 5400.0 # Period for evolving cloud top [s] + +##### +##### Load sounding data +##### + +""" + load_sounding(filepath) + +Load Oklahoma sounding data from file. +Returns (pressure, height, temperature, dewpoint) all in SI units. +""" +function load_sounding(filepath) + lines = readlines(filepath) + n_levels = parse(Int, strip(lines[1])) + + # Skip header lines (2-6) + pressure = Float64[] + height = Float64[] + temperature = Float64[] + dewpoint = Float64[] + + for i in 7:(6 + n_levels) + parts = split(strip(lines[i])) + push!(pressure, parse(Float64, parts[1]) * 100.0) # hPa → Pa + push!(height, parse(Float64, parts[2])) # m + push!(temperature, parse(Float64, parts[3]) + T₀) # °C → K + push!(dewpoint, parse(Float64, parts[4]) + T₀) # °C → K + end + + return pressure, height, temperature, dewpoint +end + +""" + interpolate_to_levels(z_target, z_data, var_data) + +Linear interpolation to target z levels. +""" +function interpolate_to_levels(z_target, z_data, var_data) + result = similar(z_target) + for (i, z) in enumerate(z_target) + # Find bracketing indices (z_data is in increasing order) + if z <= z_data[1] + result[i] = var_data[1] + elseif z >= z_data[end] + result[i] = var_data[end] + else + j = findfirst(zd -> zd >= z, z_data) + j1 = j - 1 + t = (z - z_data[j1]) / (z_data[j] - z_data[j1]) + result[i] = (1 - t) * var_data[j1] + t * var_data[j] + end + end + return result +end + +##### +##### Load vertical levels from kin1d +##### + +function load_levels(filepath) + lines = readlines(filepath) + nk = parse(Int, strip(lines[1])) + z = Float64[] + + # Skip header lines (lines 2-3 are description/column headers) + for i in 4:(3 + nk) + parts = split(strip(lines[i])) + # Column 3 is ~HEIGHTS (with scientific notation like 1.5761E-01) + # But the actual height is in column 3 which shows values like 12839. + push!(z, parse(Float64, parts[3])) + end + + return reverse(z) # Return in ascending order (bottom to top) +end + +##### +##### Evolving updraft profile +##### + +""" + compute_updraft(z, t, H) + +Compute vertical velocity profile at time t. +Matches the NewWprof2 subroutine in kin1d. +""" +function compute_updraft(z, t, H) + # Time-dependent maximum updraft speed + wmaxH = w_initial + (w_max - w_initial) * 0.5 * (cos(t / updraft_period * 2π + π) + 1) + + # Updraft shuts off after 60 minutes + if t > 3600.0 + wmaxH = 0.0 + end + + # Time-dependent cloud top height + Hcld = initial_cloud_top + (H - initial_cloud_top) * 0.5 * (cos(t / cloud_top_period * 2π + π) + 1) + + # Sinusoidal profile + w = zeros(length(z)) + for (i, zi) in enumerate(z) + if zi <= Hcld && zi > 0 + w[i] = wmaxH * sin(π * zi / Hcld) + end + end + + return w +end + +##### +##### Saturation specific humidity (Tetens formula) +##### + +""" + saturation_specific_humidity(T, p) + +Compute saturation specific humidity using Tetens formula. +Matches the Fortran FOEW/FOQST functions. +""" +function saturation_specific_humidity_tetens(T, p) + eps1 = 0.62194800221014 + eps2 = 0.3780199778986 + TRPL = 273.16 + + # Tetens formula for saturation vapor pressure + if T >= TRPL + # Over liquid + es = 610.78 * exp(17.269 * (T - TRPL) / (T - 35.86)) + else + # Over ice + es = 610.78 * exp(21.875 * (T - TRPL) / (T - 7.66)) + end + + # Saturation specific humidity + qsat = eps1 / max(1.0, p / es - eps2) + return qsat +end + +##### +##### Main simulation +##### + +function run_kinematic_column() + println("=" ^ 60) + println("Breeze.jl P3 Kinematic Column Driver") + println("=" ^ 60) + println() + + # Load sounding and levels + p3_repo = get(ENV, "P3_REPO", "/Users/gregorywagner/Projects/P3-microphysics") + sounding_path = joinpath(p3_repo, "kin1d", "soundings", + "snd_input.KOUN_00z1june2008.data") + levels_path = joinpath(p3_repo, "kin1d", "levels", "levs_41.dat") + + if !isfile(sounding_path) + error("Sounding file not found: $sounding_path\n" * + "Set P3_REPO environment variable to your P3-microphysics clone.") + end + + # Load data + p_snd, z_snd, T_snd, Td_snd = load_sounding(sounding_path) + z_levels = load_levels(levels_path) + + println("Loaded sounding: $(length(p_snd)) levels") + println("Target levels: $(length(z_levels)) levels from $(z_levels[1]) to $(z_levels[end]) m") + + # Interpolate to target levels + p = interpolate_to_levels(z_levels, z_snd, p_snd) + T = interpolate_to_levels(z_levels, z_snd, T_snd) + Td = interpolate_to_levels(z_levels, z_snd, Td_snd) + + # Compute initial profiles + ρ = p ./ (Rd .* T) + qv = [saturation_specific_humidity_tetens(Td[k], p[k]) for k in 1:nz] + qsat = [saturation_specific_humidity_tetens(T[k], p[k]) for k in 1:nz] + + # Domain height + H = z_levels[end] + dz = diff(z_levels) + push!(dz, dz[end]) # Assume last dz equals second-to-last + + println("Domain height: $H m") + println("Initial T range: $(minimum(T) - T₀) to $(maximum(T) - T₀) °C") + println("Initial qv range: $(minimum(qv)*1000) to $(maximum(qv)*1000) g/kg") + println() + + # Initialize hydrometeor arrays (mixing ratios) + qc = zeros(nz) # Cloud liquid + qr = zeros(nz) # Rain + nc = zeros(nz) # Cloud droplet number (per kg) + nr = zeros(nz) # Rain number (per kg) + + # P3 ice variables + qi = zeros(nz) # Total ice mass + ni = zeros(nz) # Ice number + qf = zeros(nz) # Rime mass + bf = zeros(nz) # Rime volume + zi = zeros(nz) # Reflectivity (6th moment) + qw = zeros(nz) # Liquid on ice + + # Thermodynamic constants + constants = ThermodynamicConstants(Float64) + + # P3 microphysics scheme + p3 = PredictedParticlePropertiesMicrophysics() + + # Time integration + nt = Int(total_time / Δt) + n_output = Int(output_interval / Δt) + n_saved = div(nt, n_output) + + # Output arrays + times_out = zeros(n_saved) + qc_out = zeros(n_saved, nz) + qr_out = zeros(n_saved, nz) + qi_out = zeros(n_saved, nz) + ni_out = zeros(n_saved, nz) + qf_out = zeros(n_saved, nz) + bf_out = zeros(n_saved, nz) + T_out = zeros(n_saved, nz) + w_out = zeros(n_saved, nz) + rime_fraction_out = zeros(n_saved, nz) + + println("Running simulation: $nt timesteps, Δt = $Δt s") + println("Output every $n_output steps ($output_interval)") + println() + + # Main time loop + i_out = 0 + for n in 1:nt + t = n * Δt + + # Compute vertical velocity + w = compute_updraft(z_levels, t, H) + + # Advection (simple upstream) + for k in 2:nz + if w[k] > 0 + # Upward advection from below + dqv = -w[k] * (qv[k] - qv[k-1]) / dz[k] * Δt + dT_adv = -w[k] * (T[k] - T[k-1]) / dz[k] * Δt + qv[k] += dqv + T[k] += dT_adv + end + end + + # Adiabatic cooling + for k in 1:nz + T[k] -= g / cₚ * w[k] * Δt + end + + # Saturation adjustment (simple condensation) + for k in 1:nz + qsat[k] = saturation_specific_humidity_tetens(T[k], p[k]) + + if qv[k] > qsat[k] + # Condensation + excess = qv[k] - qsat[k] + qv[k] = qsat[k] + qc[k] += excess + + # Latent heating + Lv = 2.5e6 # J/kg + T[k] += Lv * excess / cₚ + + # Initialize cloud droplet number if new cloud + if nc[k] < 1e6 && qc[k] > 1e-8 + nc[k] = 250e6 # 250 per cc + end + end + end + + # Ice nucleation at cold temperatures + for k in 1:nz + if T[k] < T₀ - 15 && qc[k] > 1e-8 && ni[k] < 1e4 + # Simple freezing + frozen = min(qc[k], 1e-6) + qc[k] -= frozen + qi[k] += frozen + ni[k] += frozen / 1e-12 # Assume small crystals + end + end + + # Compute P3 process rates at each level + for k in 1:nz + if qi[k] > 1e-10 || qc[k] > 1e-10 || qr[k] > 1e-10 + # Build microphysical state + rime_fraction = qi[k] > 1e-12 ? qf[k] / qi[k] : 0.0 + liquid_fraction = qi[k] > 1e-12 ? qw[k] / qi[k] : 0.0 + + # Simplified thermodynamic state for P3 + # Note: Full integration would use Breeze's thermodynamic formulation + + # For now, just accumulate mass through simple parameterizations + # This is a placeholder - full integration requires matching + # P3's complex process rate calculations + + # Autoconversion (cloud → rain) + if qc[k] > 1e-6 + τ_auto = 1000.0 # seconds + dqr = qc[k] / τ_auto * Δt + dqr = min(dqr, qc[k]) + qc[k] -= dqr + qr[k] += dqr + nr[k] += dqr / 1e-9 # Rain drop mass + end + + # Ice deposition + if qi[k] > 1e-10 && qv[k] > qsat[k] * 0.9 + τ_dep = 500.0 + dqi = qi[k] * 0.1 * Δt / τ_dep + dqi = min(dqi, qv[k] - qsat[k] * 0.9) + dqi = max(dqi, 0.0) + qi[k] += dqi + qv[k] -= dqi + end + + # Melting + if qi[k] > 1e-10 && T[k] > T₀ + τ_melt = 100.0 + dmelt = qi[k] * (T[k] - T₀) / 10.0 * Δt / τ_melt + dmelt = min(dmelt, qi[k]) + qi[k] -= dmelt + qr[k] += dmelt + end + + # Riming + if qi[k] > 1e-10 && qc[k] > 1e-8 + τ_rime = 300.0 + drime = qc[k] * 0.5 * Δt / τ_rime + drime = min(drime, qc[k]) + qc[k] -= drime + qf[k] += drime + qi[k] += drime + end + end + end + + # Simple sedimentation + for k in 2:nz + # Rain fall speed ~5 m/s + if qr[k] > 1e-10 + v_rain = 5.0 + dz_fall = v_rain * Δt + if dz_fall > dz[k] + flux = qr[k] + qr[k-1] += flux + qr[k] = 0.0 + end + end + + # Ice fall speed ~1 m/s + if qi[k] > 1e-10 + v_ice = 1.0 + dz_fall = v_ice * Δt + if dz_fall > dz[k] * 3 + flux = qi[k] * 0.1 + if k > 1 + qi[k-1] += flux + qf[k-1] += qf[k] * 0.1 + end + qi[k] -= flux + qf[k] -= qf[k] * 0.1 + end + end + end + + # Enforce positivity + qv .= max.(qv, 0.0) + qc .= max.(qc, 0.0) + qr .= max.(qr, 0.0) + qi .= max.(qi, 0.0) + qf .= max.(qf, 0.0) + bf .= max.(bf, 0.0) + ni .= max.(ni, 0.0) + + # Store output + if mod(n, n_output) == 0 + i_out += 1 + times_out[i_out] = t + qc_out[i_out, :] = qc + qr_out[i_out, :] = qr + qi_out[i_out, :] = qi + ni_out[i_out, :] = ni + qf_out[i_out, :] = qf + bf_out[i_out, :] = bf + T_out[i_out, :] = T + w_out[i_out, :] = w + rime_fraction_out[i_out, :] = [qi[k] > 1e-12 ? qf[k]/qi[k] : 0.0 for k in 1:nz] + + if mod(i_out, 10) == 0 + println("t = $(round(t/60, digits=1)) min, max qi = $(round(maximum(qi)*1000, digits=3)) g/kg") + end + end + end + + println() + println("Simulation complete!") + println("Max cloud liquid: $(round(maximum(qc_out)*1000, digits=2)) g/kg") + println("Max rain: $(round(maximum(qr_out)*1000, digits=2)) g/kg") + println("Max ice: $(round(maximum(qi_out)*1000, digits=2)) g/kg") + + return ( + times = times_out, + z = z_levels, + qc = qc_out, + qr = qr_out, + qi = qi_out, + qf = qf_out, + T = T_out, + w = w_out, + rime_fraction = rime_fraction_out + ) +end + +##### +##### Run and compare +##### + +results = run_kinematic_column() + +# Load Fortran reference +reference_path = joinpath(@__DIR__, "kin1d_reference.nc") +ds = NCDataset(reference_path, "r") +ref_time = ds["time"][:] ./ 60 # Convert to minutes +ref_z = ds["z"][:] ./ 1000 # Convert to km +ref_qc = ds["q_cloud"][:, :] +ref_qr = ds["q_rain"][:, :] +ref_qi = ds["q_ice"][:, :] +ref_rime = ds["rime_fraction"][:, :] +ref_T = ds["temperature"][:, :] +close(ds) + +# Create comparison figure +fig = Figure(size=(1400, 800), fontsize=12) + +breeze_time = results.times ./ 60 +breeze_z = results.z ./ 1000 + +# Cloud liquid comparison +ax1 = Axis(fig[1, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Cloud Liquid - Fortran [g/kg]") +hm1 = heatmap!(ax1, ref_time, ref_z, ref_qc .* 1000, + colormap=:blues, colorrange=(0, 5)) +Colorbar(fig[1, 2], hm1) + +ax2 = Axis(fig[1, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Cloud Liquid - Breeze [g/kg]") +hm2 = heatmap!(ax2, breeze_time, breeze_z, results.qc .* 1000, + colormap=:blues, colorrange=(0, 5)) +Colorbar(fig[1, 4], hm2) + +# Ice comparison +ax3 = Axis(fig[2, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Ice - Fortran [g/kg]") +hm3 = heatmap!(ax3, ref_time, ref_z, ref_qi .* 1000, + colormap=:reds, colorrange=(0, 15)) +Colorbar(fig[2, 2], hm3) + +ax4 = Axis(fig[2, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Ice - Breeze [g/kg]") +hm4 = heatmap!(ax4, breeze_time, breeze_z, results.qi .* 1000, + colormap=:reds, colorrange=(0, 15)) +Colorbar(fig[2, 4], hm4) + +# Rime fraction comparison +ax5 = Axis(fig[3, 1], xlabel="Time [min]", ylabel="Height [km]", + title="Rime Fraction - Fortran") +hm5 = heatmap!(ax5, ref_time, ref_z, ref_rime, + colormap=:viridis, colorrange=(0, 1)) +Colorbar(fig[3, 2], hm5) + +ax6 = Axis(fig[3, 3], xlabel="Time [min]", ylabel="Height [km]", + title="Rime Fraction - Breeze") +hm6 = heatmap!(ax6, breeze_time, breeze_z, results.rime_fraction, + colormap=:viridis, colorrange=(0, 1)) +Colorbar(fig[3, 4], hm6) + +save(joinpath(@__DIR__, "kin1d_comparison.png"), fig) +println() +println("Saved comparison figure: kin1d_comparison.png") + +##### +##### Summary statistics +##### + +println() +println("=" ^ 60) +println("COMPARISON SUMMARY") +println("=" ^ 60) +println() +println(" Fortran P3 Breeze.jl P3") +println("-" ^ 50) +@printf("Max cloud liquid: %8.3f %8.3f g/kg\n", + maximum(ref_qc)*1000, maximum(results.qc)*1000) +@printf("Max rain: %8.3f %8.3f g/kg\n", + maximum(ref_qr)*1000, maximum(results.qr)*1000) +@printf("Max ice: %8.3f %8.3f g/kg\n", + maximum(ref_qi)*1000, maximum(results.qi)*1000) +@printf("Max rime fraction: %8.3f %8.3f\n", + maximum(ref_rime), maximum(results.rime_fraction)) +println() +println("NOTE: The Breeze.jl kinematic driver uses simplified parameterizations") +println("for advection, condensation, and sedimentation. For a true comparison,") +println("these components need to match the Fortran implementation exactly.") +println() +println("The key comparison is the P3 MICROPHYSICS TENDENCIES, which should be") +println("verified by comparing individual process rates in isolation.") diff --git a/validation/p3/make_kin1d_reference.jl b/validation/p3/make_kin1d_reference.jl new file mode 100644 index 000000000..dc9518136 --- /dev/null +++ b/validation/p3/make_kin1d_reference.jl @@ -0,0 +1,114 @@ +using Dates +using NCDatasets + +p3_repo = get(ENV, "P3_REPO", "") +isempty(p3_repo) && error("Set P3_REPO to the path of your P3-microphysics clone.") + +input_path = get(ENV, "P3_KIN1D_OUT", joinpath(p3_repo, "kin1d", "src", "out_p3.dat")) +output_path = get(ENV, "P3_KIN1D_NETCDF", joinpath(@__DIR__, "kin1d_reference.nc")) + +nk = parse(Int, get(ENV, "P3_NK", "41")) +outfreq_min = parse(Float64, get(ENV, "P3_OUTFREQ_MIN", "1")) + +lines = readlines(input_path) +rows = [parse.(Float64, split(strip(line))) for line in lines if !isempty(strip(line))] +nrows = length(rows) +ncols = length(rows[1]) + +if nrows % nk != 0 + error("Row count $nrows is not divisible by nk=$nk. Check P3_NK or input file.") +end + +nt = div(nrows, nk) +data = Array{Float64}(undef, nt, nk, ncols) +for idx in 1:nrows + t = div(idx - 1, nk) + 1 + k = mod(idx - 1, nk) + 1 + data[t, k, :] = rows[idx] +end + +z = data[1, :, 1] +time = collect(1:nt) .* outfreq_min .* 60.0 + +isfile(output_path) && rm(output_path) +ds = NCDataset(output_path, "c") + +defDim(ds, "time", nt) +defDim(ds, "z", nk) + +vtime = defVar(ds, "time", Float64, ("time",)) +vtime.attrib["units"] = "s" +vtime.attrib["long_name"] = "time" +vtime[:] = time + +vz = defVar(ds, "z", Float64, ("z",)) +vz.attrib["units"] = "m" +vz.attrib["long_name"] = "height" +vz[:] = z + +function put_2d(name, col, units, long_name) + v = defVar(ds, name, Float64, ("time", "z")) + v.attrib["units"] = units + v.attrib["long_name"] = long_name + v[:, :] = data[:, :, col] + return nothing +end + +function put_1d(name, col, units, long_name) + v = defVar(ds, name, Float64, ("time",)) + v.attrib["units"] = units + v.attrib["long_name"] = long_name + v[:] = data[:, 1, col] + return nothing +end + +# Column mapping for nCat=1 from kin1d/src/cld1d.f90 +put_2d("w", 2, "m s-1", "vertical_velocity") +put_1d("prt_liq", 3, "mm h-1", "surface_precipitation_rate_liquid") +put_1d("prt_sol", 4, "mm h-1", "surface_precipitation_rate_solid") +put_2d("reflectivity", 5, "dBZ", "radar_reflectivity") +put_2d("temperature", 6, "C", "temperature_celsius") +put_2d("q_cloud", 7, "kg kg-1", "cloud_liquid_mixing_ratio") +put_2d("q_rain", 8, "kg kg-1", "rain_mixing_ratio") +put_2d("n_cloud", 9, "kg-1", "cloud_droplet_number_mixing_ratio") +put_2d("n_rain", 10, "kg-1", "rain_number_mixing_ratio") +put_2d("q_ice", 11, "kg kg-1", "total_ice_mixing_ratio") +put_2d("n_ice", 12, "kg-1", "ice_number_mixing_ratio") +put_2d("rime_fraction", 13, "1", "rime_mass_fraction") +put_2d("liquid_fraction", 14, "1", "liquid_mass_fraction_on_ice") +put_2d("drm", 15, "m", "rain_mean_volume_diameter") +put_2d("q_ice_cat1", 16, "kg kg-1", "ice_mixing_ratio_category1") +put_2d("q_rime_cat1", 17, "kg kg-1", "rime_mixing_ratio_category1") +put_2d("q_liquid_on_ice_cat1", 18, "kg kg-1", "liquid_on_ice_mixing_ratio_category1") +put_2d("n_ice_cat1", 19, "kg-1", "ice_number_mixing_ratio_category1") +put_2d("b_rime_cat1", 20, "m3 m-3", "rime_volume_density_category1") +put_2d("z_ice_cat1", 21, "m6 m-3", "ice_reflectivity_moment_category1") +put_2d("rho_ice_cat1", 22, "kg m-3", "ice_bulk_density_category1") +put_2d("d_ice_cat1", 23, "m", "ice_mean_diameter_category1") + +commit = get(ENV, "P3_COMMIT", "") +if isempty(commit) + try + commit = readchomp(`git -C $p3_repo rev-parse HEAD`) + catch + commit = "unknown" + end +end + +ds.attrib["source"] = input_path +ds.attrib["p3_repo_commit"] = commit +ds.attrib["p3_version_param"] = get(ENV, "P3_VERSION_PARAM", "v5.3.14") +ds.attrib["p3_init_version"] = get(ENV, "P3_INIT_VERSION", "v5.5.0") +ds.attrib["nCat"] = get(ENV, "P3_NCAT", "1") +ds.attrib["triple_moment_ice"] = get(ENV, "P3_TRPL_MOM_ICE", "true") +ds.attrib["liquid_fraction"] = get(ENV, "P3_LIQ_FRAC", "true") +ds.attrib["dt_seconds"] = get(ENV, "P3_DT_SECONDS", "10") +ds.attrib["outfreq_minutes"] = string(outfreq_min) +ds.attrib["total_minutes"] = get(ENV, "P3_TOTAL_MINUTES", "90") +ds.attrib["sounding"] = get(ENV, "P3_SOUNDING", "snd_input.KOUN_00z1june2008.data") +ds.attrib["driver"] = get(ENV, "P3_DRIVER", "kin1d cld1d.f90") +ds.attrib["created"] = string(now()) + +close(ds) + +println(output_path) diff --git a/validation/p3_env/Project.toml b/validation/p3_env/Project.toml new file mode 100644 index 000000000..c021b90d1 --- /dev/null +++ b/validation/p3_env/Project.toml @@ -0,0 +1,2 @@ +[deps] +NCDatasets = "85f8d34a-cbdd-5861-8df4-14fed0d494ab"