Manabe radiative-convective equilibrium with ecCKD
What surface temperature does an atmospheric column choose, and how much does it warm when CO₂ doubles? Manabe and Wetherald answered those questions in 1967 (J. Atmos. Sci., doi: 10.1175/1520-0469(1967)024<0241:TEOTAW>2.0.CO;2). This page runs a Manabe-style version of that calculation: radiation heats and cools each level, convection instantly clamps the column onto a critical lapse-rate profile anchored at the surface, the surface temperature is solved from top-of-atmosphere energy balance, and — their famous choice — relative humidity stays fixed, so water vapor rises and falls with temperature. The formulation follows the RRTMGP.jl Manabe tutorial: level temperatures are prognostic, convective adjustment is a one-line clamp at a fixed trial surface temperature, and the surface temperature is solved externally from top-of-atmosphere balance. RRTMGP is a source reference only — nothing here imports or calls it.
Configuration
The experiment's headline parameters and physical constants:
using NumericalRadiation
using NCDatasets # activates the NetCDF loader for the ecCKD file
using Printf
Γ = 6.5e-3 # critical lapse rate, K m⁻¹ (Manabe-Wetherald)
μ₀ = cosd(47.9) # cosine of the solar zenith angle
S₀ = 509 # solar constant, W m⁻² (Manabe-Wetherald daily mean)
ℐꜜ_toa = S₀ * μ₀ # horizontal TOA shortwave flux, W m⁻²
α = 0.3 # surface albedo
surface_relative_humidity = 0.77 # Manabe-Wetherald humidity profile parameter
water_vapor_floor = 4.8e-6 # stratospheric χH₂O floor, mole fraction
χCH₄ = 1.9e-6 # present-day global means for the
χN₂O = 3.4e-7 # well-mixed trace gases
Nz = 60 # physical layers, uniform in altitude
zₜ = 60e3 # m; one isothermal lookup-boundary layer above
pˢ = 101_325 # Pa
constants = PhysicalConstants() # Earth defaults; every constant below is one of its fields
g = constants.gravity # gravitational acceleration, m s⁻²
cᵖ = constants.heat_capacity # isobaric heat capacity, J kg⁻¹ K⁻¹
σ = constants.stefan_boltzmann # Stefan-Boltzmann constant, W m⁻² K⁻⁴
Rᵈ = constants.dry_air_gas_constant # dry-air gas constant, J kg⁻¹ K⁻¹
mᵈ = constants.dry_air_molar_mass # dry-air molar mass, kg mol⁻¹
mᵛ = constants.water_molar_mass # water molar mass, kg mol⁻¹
freezing_temperature = ThermodynamicConstants().freezing_temperature # K
day = 86_400 # sThe atmospheric profile and grid
The initial state is an analytic midlatitude-summer standard atmosphere, transcribed locally from RRTMGP.jl's standard_atmosphere source (AFGL-style two-segment temperature, exact hydrostatic pressure, and log-pressure-Gaussian ozone). Transcribed, not imported.
T₀ = 294 # K, midlatitude-summer surface temperature
z_tropopause = 13e3 # m, idealized tropopause height
Γ_stratosphere = 2e-3 # K m⁻¹, idealized stratospheric warming rate
standard_temperature(z) = z <= z_tropopause ? T₀ - Γ * z : (T₀ - Γ * z_tropopause) + Γ_stratosphere * (z - z_tropopause)
function standard_pressure(z)
T_tropopause = T₀ - Γ * z_tropopause
z <= z_tropopause && return pˢ * (standard_temperature(z) / T₀)^(g / (Rᵈ * Γ))
p_tropopause = pˢ * (T_tropopause / T₀)^(g / (Rᵈ * Γ))
return p_tropopause * (standard_temperature(z) / T_tropopause)^(-g / (Rᵈ * Γ_stratosphere))
end
standard_ozone(p) = 3e-8 + 7.5e-6 * exp(-(log(p / 1_200))^2 / (2 * 1.2^2))Levels are uniform in altitude from the surface to zₜ, stored TOA-first (our column convention, pressure increasing downward). One separate isothermal lookup-boundary layer spans from the gas-optics table's minimum pressure down to the top physical level; it is excluded from all prognostic, adjustment, and convergence arrays — its temperature and gas state are copied from the top physical level before every radiation call and its heating is discarded. Extension-column arrays carry the suffix _extended; index 1 is the extension layer and physical layer j sits at j + 1.
gas_optics = read_reference_ecckd_gas_optics("32x32"; names=(:composite, :h2o, :o3, :co2, :ch4, :n2o))
zᵢ = collect(range(0, zₜ; length=Nz + 1))
z = 0.5 .* (zᵢ[1:Nz] .+ zᵢ[2:Nz+1])
pᵢ = reverse(standard_pressure.(zᵢ)) # TOA-first, increasing downward
p = reverse(standard_pressure.(z)) # standard_pressure at altitude midpoints
table_minimum_pressure = first(gas_optics.pressure_grid)
table_minimum_pressure < pᵢ[1] || error("lookup-table minimum pressure does not lie above the physical top")
Nz_extended = Nz + 1 # extension layer + physical layers
p_extended = vcat(0.5 * (table_minimum_pressure + pᵢ[1]), p) # arithmetic midpoint
pᵢ_extended = vcat(table_minimum_pressure, pᵢ) # for the extension only
χO₃ = standard_ozone.(p)
χO₃_extended = vcat(χO₃[1], χO₃) # extension copies the top layerThe humidity closure
Fixed relative humidity on layer temperatures — Manabe and Wetherald's profile shape, Magnus saturation vapor pressure, and a stratospheric floor — applied to the full radiation column including the extension layer:
saturation_vapor_pressure(T) = 610.94 * exp(17.625 * (T - freezing_temperature) / (T - 30.11))
function fixed_relative_humidity!(χH₂O_extended, T_extended)
for k in eachindex(χH₂O_extended)
relative_humidity = max(surface_relative_humidity * (p_extended[k] / pˢ - 0.02) / 0.98, 0)
pᵛ⁺ = saturation_vapor_pressure(T_extended[k])
χH₂O_extended[k] = max(relative_humidity * pᵛ⁺ / max(p_extended[k] - relative_humidity * pᵛ⁺, 1),
water_vapor_floor)
end
return χH₂O_extended
endRadiation work arrays
The staged runtime operates on caller-owned arrays: per-g-point optical properties for each band, and broadband fluxes on the interfaces.
function radiation_work_arrays(gas_optics, Nz)
Ngˡʷ = length(gas_optics.longwave_weights)
Ngˢʷ = length(gas_optics.shortwave_weights)
longwave = LongwaveOptics(zeros(Ngˡʷ, Nz),
zeros(Ngˡʷ, Nz);
source_top = zeros(Ngˡʷ, Nz),
source_bottom = zeros(Ngˡʷ, Nz),
weights = zeros(Ngˡʷ))
shortwave = ShortwaveOptics(zeros(Ngˢʷ, Nz);
rayleigh_optical_depth = zeros(Ngˢʷ, Nz),
scattering_asymmetry = zeros(Ngˢʷ, Nz),
weights = zeros(Ngˢʷ))
fluxes = RadiativeFluxes(longwave_up = zeros(Nz + 1),
longwave_down = zeros(Nz + 1),
shortwave_up = zeros(Nz + 1),
shortwave_down = zeros(Nz + 1))
return longwave, shortwave, fluxes
endThe radiative-convective march
equilibrate! marches the column at a fixed trial surface temperature, in the RRTMGP tutorial's exact order and stopping rule: clamp levels onto the critical profile $T_c(p) = Tˢ (p/pˢ)^{Γ R^{\mathrm{d}}/g}$; set layer temperatures to adjacent-level means; update humidity and fluxes; stop when one successive adjusted level profile changes by less than tolerance (kelvin); otherwise map layer heating to level tendencies (interior levels take the mean of the adjacent layer rates, the end levels take the edge rate) and march with the tutorial's ±2 K increment clamp, up to max_steps steps.
function equilibrate!(Tᵢ, Tˢ; χCO₂, ozone = χO₃_extended, fixed_water_vapor = nothing,
Δt = 8 * 3_600, max_steps = 20_000, tolerance = 1e-4)
T_extended = zeros(Nz_extended)
Tᵢ_extended = zeros(Nz_extended + 1)
χH₂O_extended = zeros(Nz_extended)
nᵈ = zeros(Nz_extended)
gases = (composite = zeros(Nz_extended), h2o = zeros(Nz_extended), o3 = zeros(Nz_extended),
co2 = zeros(Nz_extended), ch4 = zeros(Nz_extended), n2o = zeros(Nz_extended))
atmosphere = ColumnAtmosphere(; pressure_layers = p_extended,
pressure_interfaces = pᵢ_extended,
temperature_layers = T_extended,
temperature_interfaces = Tᵢ_extended,
gases, surface = nothing,
geometry = (cos_zenith=μ₀,),
constants)
longwave, shortwave, fluxes = radiation_work_arrays(gas_optics, Nz_extended)
shortwave_boundary = ShortwaveBoundaryConditions(toa_shortwave_down=ℐꜜ_toa, surface_albedo=α)
surface_emission = surface_longwave_emission(gas_optics, Tˢ)
longwave_boundary = LongwaveBoundaryConditions(surface_longwave_up=surface_emission)
Q = zeros(Nz_extended)
Ṫᵢ = zeros(Nz + 1)
previous = zeros(Nz + 1)
solve_radiation!() = begin
# extension layer: isothermal with, and gas state tied to, the top level
T_extended[1] = Tᵢ[1]
@views @. T_extended[2:Nz_extended] = (Tᵢ[1:Nz] + Tᵢ[2:Nz+1]) / 2
Tᵢ_extended[1] = Tᵢ[1]
@views Tᵢ_extended[2:Nz_extended+1] .= Tᵢ
if isnothing(fixed_water_vapor)
fixed_relative_humidity!(χH₂O_extended, T_extended)
else
χH₂O_extended .= fixed_water_vapor
end
χH₂O_extended[1] = χH₂O_extended[2] # extension copies the top physical layer
for k in 1:Nz_extended
nᵈ[k] = (pᵢ_extended[k + 1] - pᵢ_extended[k]) / (g * (mᵈ + mᵛ * χH₂O_extended[k]))
gases.composite[k] = nᵈ[k]
gases.h2o[k] = χH₂O_extended[k] * nᵈ[k]
gases.o3[k] = ozone[k] * nᵈ[k]
gases.co2[k] = χCO₂ * nᵈ[k]
gases.ch4[k] = χCH₄ * nᵈ[k]
gases.n2o[k] = χN₂O * nᵈ[k]
end
optical_properties!(longwave, shortwave, gas_optics, atmosphere)
radiative_fluxes!(fluxes, CloudlessLongwave(), longwave, atmosphere, longwave_boundary)
radiative_fluxes!(fluxes, CloudlessShortwave(), shortwave, atmosphere, shortwave_boundary)
end
critical = Tˢ .* (pᵢ ./ pˢ) .^ (Γ * Rᵈ / g)
fill!(previous, 0)
final_difference = Inf
steps = max_steps
converged = false
for step in 1:max_steps
@. Tᵢ = max(Tᵢ, critical)
solve_radiation!()
final_difference = maximum(abs, Tᵢ .- previous)
if final_difference < tolerance
steps = step
converged = true
break
end
previous .= Tᵢ
heating_rates!(Q, fluxes, atmosphere) # g and cᵖ from atmosphere.constants
# discard the extension tendency Q[1]; physical layer j is Q[j + 1]
Ṫᵢ[1] = Q[2]
Ṫᵢ[Nz+1] = Q[Nz_extended]
@views @. Ṫᵢ[2:Nz] = (Q[2:Nz_extended-1] + Q[3:Nz_extended]) / 2
@. Tᵢ += clamp(Δt * Ṫᵢ, -2, 2)
end
return (; Tᵢ = copy(Tᵢ), Tˢ, converged, final_difference, steps,
days = steps * Δt / day,
χH₂O = copy(χH₂O_extended),
T_extended = copy(T_extended),
extension_temperature = T_extended[1],
olr = fluxes.longwave_up[1],
asr = fluxes.shortwave_down[1] - fluxes.shortwave_up[1])
endThe energy-balance solve
rce wraps the march in a safeguarded root solve for ASR − OLR = 0. The generic bookkeeping expands the initial guesses (doubling, hard-bounded to [150, 350] K) until the imbalance changes sign, then iterates a secant step with a bisection fallback:
function bracketed_secant(toa_imbalance, guesses; imbalance_tolerance=0.1, max_expansions=8, max_iterations=12)
lower, upper = min(guesses...), max(guesses...)
ΔF_lower, state_lower = toa_imbalance(lower)
ΔF_upper, state_upper = toa_imbalance(upper)
expansion = 8
expansions = 0
while sign(ΔF_lower) == sign(ΔF_upper)
expansions < max_expansions || error("no sign-changing Tˢ bracket after $max_expansions expansions")
saturated = lower == 150 && upper == 350
saturated && error("Tˢ bracket saturated the [150, 350] K domain without a sign change")
lower = max(lower - expansion, 150)
upper = min(upper + expansion, 350)
expansion *= 2
expansions += 1
ΔF_lower, state_lower = toa_imbalance(lower)
ΔF_upper, state_upper = toa_imbalance(upper)
end
Tˢ, ΔF, state = abs(ΔF_lower) < abs(ΔF_upper) ? (lower, ΔF_lower, state_lower) : (upper, ΔF_upper, state_upper)
a, b, ΔF_a, ΔF_b = lower, upper, ΔF_lower, ΔF_upper
iterations = 0
step_size = Inf
while abs(ΔF) > imbalance_tolerance || step_size > 0.01
iterations < max_iterations || error("surface-temperature solve exceeded $max_iterations iterations")
candidate = ΔF_b == ΔF_a ? (a + b) / 2 : b - ΔF_b * (b - a) / (ΔF_b - ΔF_a)
(a < candidate < b) || (candidate = (a + b) / 2)
ΔF_c, state_c = toa_imbalance(candidate)
if sign(ΔF_c) == sign(ΔF_a)
a, ΔF_a = candidate, ΔF_c
else
b, ΔF_b = candidate, ΔF_c
end
step_size = abs(candidate - Tˢ)
Tˢ, ΔF, state = candidate, ΔF_c, state_c
iterations += 1
end
return Tˢ, state, iterations, expansions
end
function rce(χCO₂; ozone = χO₃_extended, fixed_water_vapor = nothing,
Tˢ_guesses = (285, 295), warm_start = nothing,
imbalance_tolerance = 0.1, max_expansions = 8, max_iterations = 12)
Tᵢ = isnothing(warm_start) ? standard_temperature.(reverse(zᵢ)) : copy(warm_start)
toa_imbalance(Tˢ) = begin
state = equilibrate!(Tᵢ, Tˢ; χCO₂, ozone, fixed_water_vapor)
state.converged || error("inner equilibration at trial Tˢ = $Tˢ did not converge")
ΔF = state.asr - state.olr
isfinite(ΔF) || error("nonfinite TOA residual at trial Tˢ = $Tˢ")
(ΔF, state)
end
Tˢ, state, iterations, expansions = bracketed_secant(toa_imbalance, Tˢ_guesses; imbalance_tolerance,
max_expansions, max_iterations)
return (; state..., Tˢ, secant_iterations=iterations, bracket_expansions=expansions)
endThe experiments, and the answer
The main experiment runs 1×, 2×, and 4× CO₂ with the fixed-RH closure, plus the discriminating control: 4× CO₂ with water vapor frozen at the control field. Every equilibration must pass the verification gates at the end of this page, or the page fails to build.
χCO₂ = 420e-6
control = rce(χCO₂)
doubled = rce(2χCO₂; warm_start=control.Tᵢ)
quadrupled = rce(4χCO₂; warm_start=control.Tᵢ)
frozen_vapor = rce(4χCO₂; warm_start=control.Tᵢ, fixed_water_vapor=control.χH₂O)
for (name, state) in (("control, 420 ppm CO₂", control),
("2× CO₂, fixed RH", doubled),
("4× CO₂, fixed RH", quadrupled),
("4× CO₂, frozen water vapor", frozen_vapor))
@printf("%-28s Tˢ = %7.2f K ΔTˢ = %+6.2f K\n", name, state.Tˢ, state.Tˢ - control.Tˢ)
endcontrol, 420 ppm CO₂ Tˢ = 288.09 K ΔTˢ = +0.00 K
2× CO₂, fixed RH Tˢ = 290.72 K ΔTˢ = +2.63 K
4× CO₂, fixed RH Tˢ = 293.85 K ΔTˢ = +5.77 K
4× CO₂, frozen water vapor Tˢ = 291.11 K ΔTˢ = +3.02 K
Comparing the two 4× equilibria isolates the fixed-RH water-vapor amplification in this configuration: the same quadrupled CO₂ warms the surface nearly twice as much when the vapor is allowed to rise with temperature as when it is frozen at the control field.
The solver marches level temperatures; the figures show the derived layer-center profiles, where gas optical properties and heating rates are evaluated, as the current RRTMGP tutorial does.
using CairoMakie
pressure_ticks = ([0.3, 1, 3, 10, 30, 100, 300, 1000], ["0.3", "1", "3", "10", "30", "100", "300", "1000"])
function profile_axis(fig; title)
return Axis(fig[1, 1]; xlabel = "Temperature (K)", ylabel = "Pressure (hPa)",
yscale = log10, yreversed = true,
yticks = pressure_ticks, title)
end
fig = Figure(size=(700, 660))
ax = profile_axis(fig; title="RCE response to CO₂")
for (state, label, color, style) in
((control, "1× CO₂ (420 ppm), fixed RH", :steelblue4, :solid),
(doubled, "2× CO₂, fixed RH", :darkorange3, :solid),
(quadrupled, "4× CO₂, fixed RH", :firebrick, :solid),
(frozen_vapor, "4× CO₂, frozen water vapor", :firebrick, :dash))
lines!(ax, state.T_extended[2:Nz_extended], p ./ 100; color, label, linewidth=2, linestyle=style)
scatter!(ax, [state.Tˢ], [pˢ / 100]; color, markersize=10)
end
Legend(fig[2, 1], ax; orientation=:horizontal, nbanks=2, framevisible=false)
A troposphere on the critical lapse-rate profile anchored at the solved surface temperature (marked at the bottom), a tropopause, and a stratosphere in radiative equilibrium. CO₂ warms the coupled surface-troposphere state while cooling the stratosphere, and the dashed frozen-vapor state shows how much smaller the same 4× forcing leaves the response when vapor cannot rise with temperature.
What each absorber contributes
A companion RCE experiment removes one absorber at a time and re-solves the full equilibrium — water vapor frozen to zero, CO₂ removed, or ozone removed, each through the same solver and gates:
without_H₂O = rce(χCO₂; warm_start=control.Tᵢ, fixed_water_vapor=zero(control.χH₂O), Tˢ_guesses=(255, 275))
without_CO₂ = rce(0; warm_start=control.Tᵢ, Tˢ_guesses=(265, 285))
without_O₃ = rce(χCO₂; warm_start=control.Tᵢ, ozone=zero(χO₃_extended), Tˢ_guesses=(275, 292))
for (name, state) in (("without water vapor", without_H₂O), ("without CO₂", without_CO₂), ("without O₃", without_O₃))
@printf("%-28s Tˢ = %7.2f K ΔTˢ = %+6.2f K\n", name, state.Tˢ, state.Tˢ - control.Tˢ)
end
fig = Figure(size=(700, 660))
ax = profile_axis(fig; title="Absorber contributions")
for (state, label, color) in
((control, "all modeled absorbers", :steelblue4),
(without_H₂O, "no H₂O", :darkorange3),
(without_CO₂, "no CO₂", :firebrick),
(without_O₃, "no O₃", :seagreen))
lines!(ax, state.T_extended[2:Nz_extended], p ./ 100; color, label, linewidth=2)
scatter!(ax, [state.Tˢ], [pˢ / 100]; color, markersize=10)
end
Legend(fig[2, 1], ax; orientation=:horizontal, framevisible=false)without water vapor Tˢ = 263.47 K ΔTˢ = -24.62 K
without CO₂ Tˢ = 272.75 K ΔTˢ = -15.33 K
without O₃ Tˢ = 284.37 K ΔTˢ = -3.72 K

Verification
Every equilibrium on this page must pass these build-failing gates: inner convergence, top-of-atmosphere closure, layer/level consistency, the isothermal lookup-boundary layer, a tropospheric inversion limit, and surface-emission consistency.
function verify_equilibria(states; imbalance_gate=0.1)
for (name, state) in states
state.converged || error("$name: final equilibration not converged")
abs(state.asr - state.olr) < imbalance_gate || error("$name: TOA imbalance $(state.asr - state.olr) W/m²")
layer_means = (state.Tᵢ[1:Nz] .+ state.Tᵢ[2:Nz+1]) ./ 2
isequal(layer_means, state.T_extended[2:Nz_extended]) ||
error("$name: layer temperatures are not adjacent-level means")
state.extension_temperature == state.Tᵢ[1] ||
error("$name: lookup-boundary layer is not isothermal with the top level")
inversion_limit = minimum(diff(state.Tᵢ)[pᵢ[1:Nz] .> 40_000])
inversion_limit > -0.05 || error("$name: tropospheric inversion-limit gate: $(inversion_limit) K per level")
end
reference_state = states[1][2]
weighted_emission = sum(gas_optics.longwave_weights .* surface_longwave_emission(gas_optics, reference_state.Tˢ))
abs(weighted_emission - σ * reference_state.Tˢ^4) < 0.2 || error("weighted surface emission inconsistent with σTˢ⁴")
return nothing
end
verify_equilibria([("control", control), ("2×", doubled),
("4×", quadrupled), ("4× frozen vapor", frozen_vapor),
("no H₂O", without_H₂O), ("no CO₂", without_CO₂),
("no O₃", without_O₃)])Three ideas behind the calculation
Radiative equilibrium alone would make each level's temperature settle where its absorption of solar and terrestrial radiation balances its own emission — producing an unstably steep profile in the lower atmosphere. Convective adjustment is the instantaneous limit of the convection that such a profile would trigger: any level colder than the critical profile $T_c(p) = Tˢ (p/pˢ)^{Γ R^{\mathrm{d}}/g}$ anchored at the surface is clamped onto it, so the troposphere rides the critical lapse rate over a radiatively balanced stratosphere while the surface temperature itself is solved from top-of-atmosphere balance. The fixed-RH humidity closure is Manabe and Wetherald's third ingredient: holding relative humidity fixed means a warmer column holds more vapor — itself a greenhouse gas — so any CO₂-driven warming is amplified, which the frozen-vapor control isolates by turning exactly that closure off.
This is a Manabe-style calculation, not a reproduction of Manabe and Wetherald or of any modern rerun: it uses ecCKD correlated-k gas optics, a clear sky, an analytic midlatitude-summer initial state and idealized ozone, prescribed insolation over an α = 0.3 surface, 60 altitude-uniform layers to 60 km, and one isothermal lookup-boundary layer above that supplies the downwelling flux a truncated column would miss. Manabe and Wetherald reported roughly 2–3 K per doubling depending on cloud treatment, and a modern RRTMGP.jl calculation — whose formulation this page follows — reports 2.9 K; the numbers above are outcomes of this configuration. Build-time gates assert inner convergence at every trial surface temperature, final top-of-atmosphere closure, level/layer consistency, the isothermal top, a tropospheric inversion limit, and surface-emission consistency.
This page was generated using Literate.jl.