Skip to content

Add 5-bin sea salt aerosol tracer#4713

Draft
ziglaser wants to merge 1 commit into
mainfrom
zg/ssa-emission-transport
Draft

Add 5-bin sea salt aerosol tracer#4713
ziglaser wants to merge 1 commit into
mainfrom
zg/ssa-emission-transport

Conversation

@ziglaser

@ziglaser ziglaser commented Jul 19, 2026

Copy link
Copy Markdown

PR Dependency

  • Ocean fraction wiring requires ClimaCoupler.jl #2049, AMIP runs or outdated Coupler runs currently default to all 100% ocean coverage

New feature

  • Sea salt aerosol emitted via Gong 2003 wind-speed dependent scheme (with toggled sea surface temperature dependence as per Jaegle 2011) in proportion to ocean_fraction of cells
  • Emission fluxes are precomputed per bin in set_sea_salt_emission_flux! (aerosols/sea_salt.jl) during set_explicit_precomputed_quantities!, using a 10 m wind extrapolated from the lowest model level via MOST, and applied as a bottom boundary flux by sea_salt_emission_tendency! in surface_flux_tendency!
  • Aerosols are treated as $\rho \chi$ standard passive trated into horizontal advection viahorizontal_tracer_advection_tendency! and vertical transport via vertical_transport in explicit_vertical_advection_tendency! (both in prognostic_equations/advection.jl)
  • Sea salt aerosol is removed through a tendency enacting a 0.55 day half life for all bins and vertical levels (sea_salt_deposition_tendency!,
    called from additional_tendency!), a placeholder until weional
  • Created diagnostics in tracer_diagnostics.jl for individual aerosol size bins (mmrsslt01mmrsslt05, and analogous mmrdst0X for dust); new diagnostics for SSA emissions: total flux emiss and per-bin emisslt01emisslt05

Config changes

  • New optional interactive_aerosols config option, takes list of (currently sea salt aerosol) i.e. ["SSLT01", ...], defaults empty. Stored on AtmosModel as a Val of names so tracer sets are inferrable at compile time.

Wiring changes

  • Ocean fraction is passed from ClimaCoupler.jl via Intcluded in AtmosSimulation` struct
  • Prescribed and interactive aerosols stored in tracer_cache in tracer_cache.jl
  • Interactive aerosol bins are added to the PrognosticEDMFX updraft state as specific tracers (interactive_aerosol_sgs_variables in prognostic_variables.jl), zero-initialized following wiring of ClimaAtmos #4629
  • radiation/update_inputs.jl: update_aerosol_concentrations! split into update_prescribed_aerosol_concentrations! (evaluates MERRA-2 time-varying inputs) and update_radiation_aerosol_concentrations! (packages per-layer column mass densities for RRTMGP). For each species, the interactive (prognostic) mass is used when active, falling back to the prescribed climatology otherwise, so interactive sea salt feeds aerosol radiation directly. The RRTMGP↔MERRA-2 name mapping is hoisted into a shared RRTMGP_AEROSOL_NAMES_PAIR constant.
  • radiation/radiation.jl: sea-salt bin radii passed to RRTMGP now come from ClimaParams (MERRA2_seasalt_aerosol_bin0X_radius). Aerosol check in radiation_model_cache is derived from RRTMGP_AEROSOL_NAMES_PAIR rather than a duplicated name list, and now accepts interactive as well as prescribed aerosol names.

Renames

  • Uses of "aerosol_[VAR]" renamed to the following scheme: "prescribed_aerosol_[VAR]" when only applicable to MERRA-2 prescribed aerosols, "interactive_aerosol_[VAR]" only applicable to interactive aerosols, "aerosol_[VAR]" when applicable to both. This includes the AtmosSimulation kwarg aerosol_names -> prescribed_aerosol_names.

Future Work

  • More thoughtful treatment of 10m wind estimate with MOST — current formulation assumes bottom level center wind value is point value, not layer averaged.
  • Replace Gong 2003 parameterization with more physically supported parameterization (likely Deike 2022)
  • Replace half-life decay with wet and dry deposition tendencies
  • Implementation of hygroscopic growth: wet/dry deposition and optics are dependent on RH-influenced particle size
  • Interactions with cloud microphysics

  • I have read and checked the items on the review checklist.

@ziglaser
ziglaser requested review from tapios and trontrytel July 19, 2026 08:57
@github-actions

Copy link
Copy Markdown
Contributor

📖 Docs preview for this PR: https://clima.github.io/ClimaAtmos.jl/previews/PR4713/

Comment on lines +241 to +250
center_dust1_radius = 0.73,
center_dust2_radius = 1.4,
center_dust3_radius = 2.4,
center_dust4_radius = 4.5,
center_dust5_radius = 8,
center_ss1_radius = 0.55,
center_ss2_radius = 1.4,
center_ss3_radius = 2.4,
center_ss4_radius = 4.5,
center_ss5_radius = 8,
center_ss1_radius = aero_params.SSLT01_radius * FT(1e6),
center_ss2_radius = aero_params.SSLT02_radius * FT(1e6),
center_ss3_radius = aero_params.SSLT03_radius * FT(1e6),
center_ss4_radius = aero_params.SSLT04_radius * FT(1e6),
center_ss5_radius = aero_params.SSLT05_radius * FT(1e6),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not change the radius here. It depends on how RRTMGP creates the lookup table for aerosol optics, and I need to double check it. But I think what we have before is correct.

Comment thread src/cache/cache.jl
precomputed = precomputed_quantities(Y, atmos)
tracers = tracer_cache(
Y,
prescribed_aerosol_names,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to support prescribed and prognostic aerosol at the same time? To allow having prognostic sea salt but prescribed everything else? This is appealing for testing but may result in complicated code. The alternative would be to allow for either fully prescribed or fully interactive.

Comment thread src/cache/cache.jl
prescribed_aerosol_names,
time_varying_trace_gas_names,
start_date,
_interactive_aerosol_names(atmos.interactive_aerosols),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add it right after prescribed aerosol names, drop the underscore and name in prognostic aerosol names. "Interactive" is a new way to say prognostic and will be confusing if we start to use it interchangeably in the model.

prescribed_aerosols:
help: "Which aerosols to add. List of keys from the data file (e.g., CB1, CB2)."
value: []
interactive_aerosols:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prognostic_aerosols

Comment thread src/cache/cache.jl
ORGW,
RAD,
TRAC,
OFRAC,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR, but we should re-think this pattern of adding new fields to this struct whenever we add new functionality to the model

Comment thread src/cache/cache.jl
start_date,
_interactive_aerosol_names(atmos.interactive_aerosols),
)
ocean_fraction = ones(axes(Fields.level(Y.f, Fields.half))) # Populated via Interfacer.update_field! before first timestep

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What to do in purely Atmos runs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, do we really have to cache it?

Comment thread src/cache/cache.jl

set_precomputed_quantities!(Y, precomputing_arguments, FT(0))

radiation_aerosol_names = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used anywhere else? Maybe we can add both categories directly to radiation_args without specifying another entity

Comment thread src/cache/cache.jl
non_orographic_gravity_wave = non_orographic_gravity_wave_cache(Y, atmos)
orographic_gravity_wave = orographic_gravity_wave_cache(Y, atmos)
radiation = radiation_model_cache(Y, atmos, radiation_args...)
tracers = tracer_cache(Y, aerosol_names, time_varying_trace_gas_names, start_date)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need the trace_gas_names here anyomre?

SurfaceConditions.update_surface_conditions!(Y, p, FT(t))
end

set_sea_salt_emission_flux!(Y, p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to always apply it, or only under some user defined option?

aod_sw_scattering = NaN,
# assuming fixed aerosol radius
center_dust1_radius = 0.55,
center_dust1_radius = 0.73,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this needs to be changed?

center_ss2_radius = aero_params.SSLT02_radius * FT(1e6),
center_ss3_radius = aero_params.SSLT03_radius * FT(1e6),
center_ss4_radius = aero_params.SSLT04_radius * FT(1e6),
center_ss5_radius = aero_params.SSLT05_radius * FT(1e6),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not change those numbers by accident. They are consistent with RRTMGP assumptions, I think

turbconv_model::PrognosticEDMFX,
microphysics_model,
chemistry_model,
interactive_aerosols,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aerosol_model ?

# tracer_diagnostics.jl
"loadss", "mmrbcpi", "mmrbcpo", "mmrdust", "mmrocpi", "mmrocpo",
"mmrso4", "mmrss", "o3",
"mmrdst01", "mmrdst02", "mmrdst03", "mmrdst04", "mmrdst05",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we document anywhere what thise acronyms stand for?

# at the cost of water vapor.
tracer_nonnegativity_vapor_tendency!(Yₜ, Y, p, t, microphysics_model)

sea_salt_deposition_tendency!(Yₜ, Y, p, t)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we dispatch on it based on the aerosol_model type?

end
end

sea_salt_emission_tendency!(Yₜ, Y, p, t)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider dispatching based on aerosol model type?

# assuming one updraft
ᶜχʲₜ = MatrixFields.get_field(Yₜ.c, get_χʲ_name_from_ρχ_name(ρχ_name))
@. ᶜχʲₜ -= specific(btt, p.precomputed.ᶜρʲs.:(1))
ᶜχʲ_name = get_χʲ_name_from_ρχ_name(ρχ_name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain why this needs the additional if now?

function get_tracers(parsed_args)
aerosol_names = Tuple(parsed_args["prescribed_aerosols"])
prescribed_aerosol_names = Tuple(parsed_args["prescribed_aerosols"])
interactive_aerosol_names = Tuple(parsed_args["interactive_aerosols"])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prognostic_aerosol_names ?

species is interactive; otherwise the prescribed (climatology) concentration
is used.
"""
function update_radiation_aerosol_concentrations!((; u, p, t)::I) where {I}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the naming is a bit confusing here. We have the update_prescribed_aerosol_concentrations! followed by update_radiation_aerosol_concentrations! To me this reads as prescribed aerosol do not affect radiation. There is a separate group of aerosol that do affect radiation that gets updated later.


# BIN DEFNITIONS
const SEA_SALT_BIN_BOUNDS = (
(0.03, 0.1), # μm, rep size 0.079 μm, SSLT01,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we ok with hard coding those, or could this be a parameter?

UF.dimensionless_profile(uf_params, z_target, ζ_target, z₀, UF.MomentumTransport())
F_anchor =
UF.dimensionless_profile(uf_params, z_anchor, ζ_anchor, z₀, UF.MomentumTransport())
return max(u_anchor * F_target / F_anchor, FT(0))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for F_anchor to be zero?

Precomputable radius-dependent part of the Gong (2003) integrand
"""
function _gong2003_r_integrand(r, theta)
A = 4.7 * (1 + theta * r)^(-0.017 * r^(-1.44))

@trontrytel trontrytel Jul 21, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those should not be hardcoded. We should put them in ClimaParams

return 1.373 * r^(-A) * (1 + 0.057 * r^3.45) * 10^(1.607 * exp(-B^2))
end

function _gong_bin_integral(r_lo, r_hi, theta = 30.0, N = 512)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is theta and N?

function sea_salt_emission_flux(u_10, T_sfc, bin_index; SST_adj = false)
FT = typeof(u_10)
bin_integral = FT(SEA_SALT_BIN_R_INTEGRALS[bin_index])
number_flux = bin_integral * abs(u_10)^FT(3.41)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those parameters should be part of Clima Params

)

"""
sea_salt_emission_flux(u_10, T_sfc, bin_index)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a short entry to ClimaAtmos documentation?


const SEA_SALT_BIN_R_INTEGRALS = ntuple(
i -> _gong_bin_integral(SEA_SALT_BIN_BOUNDS[i]...),
Val(5),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we ever want to support more than 5 bins?


Called during `set_explicit_precomputed_quantities!'. `sea_salt_emission_tendency!` reads the cached values rather than recomputing.
"""
function set_sea_salt_emission_flux!(Y, p)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could dispatch based on the aerosol_model and avoid the isempty. I think its a cleaner pattern. Thats what we do in most other tendencies

surface_fluxes_params = CAP.surface_fluxes_params(p.params)
uf_params = SFP.uf_params(surface_fluxes_params)
κ = SFP.von_karman_const(surface_fluxes_params)
ustar_p = parent(sfc_conditions.ustar)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the parent here?

roughness_spec = SF.COARE3RoughnessParams{FT}()

z0_eff = p.scratch.temp_field_level
z₀_p = parent(z0_eff)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the parent is needed here?


z0_eff = p.scratch.temp_field_level
z₀_p = parent(z0_eff)
z₀_p .= SF.momentum_roughness.(roughness_spec, ustar_p, surface_fluxes_params, nothing)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe

Suggested change
z₀_p .= SF.momentum_roughness.(roughness_spec, ustar_p, surface_fluxes_params, nothing)
@. z₀_p = SF.momentum_roughness(roughness_spec, ustar_p, surface_fluxes_params, nothing)

z₀_p = parent(z0_eff)
z₀_p .= SF.momentum_roughness.(roughness_spec, ustar_p, surface_fluxes_params, nothing)

z_c1_p = parent(Fields.level(Fields.coordinate_field(axes(Y.c)).z, 1))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

)
mass_per_particle = ntuple(
i -> FT(4 / 3 * π * bin_radii[i]^3 * aero_params.seasalt_density),
Val(5),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its safer to make the number of bins a parameter. If we ever change it it'll be hard to trace all the places it is being hardcoded

aero_params.SSLT05_radius,
)
mass_per_particle = ntuple(
i -> FT(4 / 3 * π * bin_radii[i]^3 * aero_params.seasalt_density),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect we don't need FT here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants