Skip to content

Add two-stream radiation model with simplified surface energy balance (force-restore) - #3950

Open
hgopalan wants to merge 207 commits into
erf-model:developmentfrom
hgopalan:ERF-Radiation
Open

Add two-stream radiation model with simplified surface energy balance (force-restore)#3950
hgopalan wants to merge 207 commits into
erf-model:developmentfrom
hgopalan:ERF-Radiation

Conversation

@hgopalan

@hgopalan hgopalan commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a two-stream atmospheric radiation model as an alternative to RRTMGP, together with an optional simplified surface energy balance (SEB) module that evolves surface temperature and moisture by a force-restore method where Noah-MP is not driving them. The model is selected with erf.radiation_type = "TwoStream" and is independent of the RRTMGP path, which is still selected with erf.radiation_model.

Everything new is off by default, so a run that does not ask for two-stream radiation is unaffected.

Shortwave

  • Direct beam by Beer-Lambert attenuation, F_dir(z) = S0 cos(zenith) exp(-tau_cum(z) / cos(zenith)).
  • Diffuse field from the Meador-Weaver (1980) two-stream solution with Zdunkowski PIFM coefficients, parameterized by single-scattering albedo and asymmetry factor, with independent clear-sky and cloud values.
  • The layer solutions are combined by the adding method, so the surface albedo reflects the beam back into the column rather than being applied only as an absorbed fraction. Direct and diffuse albedo can differ (surface_albedo_sw_diffuse).
  • Optional Earth-Sun distance factor (Spencer 1971) scaling S0 by day of year.

Longwave

  • Gray-gas two-stream sweep on layer interfaces (Toon et al. 1989), with Stefan-Boltzmann surface emission at a configurable emissivity and reflection of the downwelling flux at the surface.
  • Cooling to space is set by the flux divergence, with no isothermal override.

Optical depth

Two models, selected by erf.radiation.tau_model:

  • per_layer (default): a fixed optical depth per layer, so the column value scales with the number of vertical cells.
  • mass: built from each layer's mass path rho dz, so the column optical depth is set by the atmosphere rather than by the grid. Shortwave mixes dry absorption, Rayleigh scattering, water vapour and cloud water with extinction-weighted single-scattering albedo and asymmetry factor. Longwave uses the corresponding mass absorption coefficients, with the cloud term expressed as the Stephens (1978) emissivity.

On top of either model: a prescribed cloud band, moisture- and cloud-aware dynamic optical depth diagnosed from qv and qc, prognostic cloud fraction diagnosed from relative humidity and cloud water, and a prescribed bulk aerosol optical depth that is constant or exponential with height.

Coupling

  • Heating rates are stored as potential-temperature tendencies, dtheta/dt = (dT/dt) / pi, matching the convention the RhoTheta source term and the RRTMGP path already use. They are written to qheating_rates and exposed as the qsrc_sw and qsrc_lw plot variables.
  • Applied once per slow step, during slow-RHS construction only, so the tendency is consistent across all fast substeps.
  • Time-varying solar zenith angle from simulation time, latitude, longitude and day of year, with a static fallback.
  • Per-column surface albedo, emissivity and temperature from an LSM when present, falling back to scalars.
  • Non-uniform vertical grids through the physical cell-centre heights.
  • Optional smoothing and limiting of the radiative tendency handed to YSUNew.

Simplified surface energy balance

  • Allocates the surface flux fields, passing Noah-MP values through when it is active and using scalar fallbacks otherwise.
  • Diagnostic mode reports the residual R_net - H - LE - G.
  • Prognostic mode evolves surface temperature and moisture by force-restore with bounded explicit Euler updates, and switches itself off at any level where Noah-MP is already driving the surface state.

Grid requirement

The column sweep integrates a whole atmospheric column in one pass, so each grid must span the domain in the vertical. ERF only decomposes in z when amr.max_grid_size_z is smaller than the number of cells in z; setting it at least that large is sufficient. A vertically decomposed grid aborts with a message naming the input rather than returning heating rates that look plausible and are wrong. Results do not depend on the horizontal decomposition or on the MFIter tile size.

Input validation

Several misconfigurations used to be accepted in silence. None of them aborts, so existing inputs keep running, but each now prints a warning naming the input, the valid values, and what the run will actually do instead:

  • an unrecognized tau_model, tau_profile_type, aerosol_profile_type or diag_callsite_mode falls back to its default, which is stated in the warning;
  • aerosol_profile_type = table is not implemented and contributes no aerosol optical depth;
  • seb_prognostic_enable reports that it also switches on seb_enable and seb_diagnostic_enable, which it depends on;
  • erf.radiation_type = RRTMGP is not wired to a solver, so no radiation is computed. RRTMGP is selected with erf.radiation_model.

Cost and scaling

Exec/CanonicalTests/Radiation/TwoStream_vs_RRTMGP_Timing/ measures what each solver costs per radiation call and how that cost scales, with everything except the solver held fixed.

Radiation solver cost and scaling

Measured on one CPU core, marginal cost per call:

Grid Cells Two-stream RRTMGP
42 x 4 x 42 7,056 1.08 ms 433 ms
84 x 4 x 42 14,112 2.07 ms 881 ms
84 x 4 x 84 28,224 4.27 ms 1,812 ms
168 x 4 x 84 56,448 8.55 ms 3,581 ms

The two solvers compute different things: RRTMGP resolves 224 shortwave and 256 longwave g-points with gas optics from lookup tables, while the two-stream solver does a single gray sweep per column. The difference is the cost of spectral detail, so this is a guide to choosing a solver for a given run rather than a ranking. Cost per cell is flat for both across a factor of eight in problem size, which means either one extrapolates predictably to a production grid.

Both radiation paths previously had no BL_PROFILE region, so radiation cost could not be separated from the dycore for either solver. One is now added at each entry point, and the measurement reads exclusive time from AMReX's TinyProfiler.

Keeping it fair: both inputs pull in one shared settings block through AMReX's FILE include, pinning the grid, timestep, sounding, moisture model and surface temperature; radiation is called every slow step on both sides; plotfiles and both diagnostic logs are off so file I/O stays outside the measured region. The reported figure is the marginal cost across two step counts, which cancels one-time work, notably the roughly 150 ms RRTMGP spends reading its lookup tables on the first call. check_timing_fairness.py enforces those invariants and needs no RRTMGP build.

Caveats: a single CPU core, RRTMGP targets GPUs, and cost per call is not cost per unit of accuracy.

Testing

  • 34 GoogleTest cases covering the shortwave and longwave kernels and the column sweep, including energy-budget closure, the Exner convention, surface reflection, and resolution independence of the mass model.
  • Two ctest regressions: a column-heating check that verifies the vertical structure and orientation of the heating rates, and a plotfile check that qsrc_sw and qsrc_lw are written.
  • 17 canonical cases under Exec/CanonicalTests/Radiation/, each with a Python checker, covering clear-sky and cloudy shortwave, scattering, longwave, non-uniform spacing, dynamic optical depth, prognostic cloud fraction, aerosol, diurnal solar geometry, surface heterogeneity, the mass optical-depth model, RhoTheta and YSUNew coupling, and all three SEB modes.

Documentation

  • Docs/sphinx_doc/theory/Radiation.rst covers the shortwave and longwave formulations, the optical-depth models, the potential-temperature convention, the SEB equations and the grid requirement.
  • Docs/sphinx_doc/Inputs.rst documents every input.
  • Exec/CanonicalTests/Radiation/inputs_twostream_master is the reference input file and lists all 79 erf.radiation.* parameters with defaults, units and descriptions.

Copilot AI and others added 30 commits August 6, 2026 23:51
…nd Solvers

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…m-radiation

Phase 1: Two-stream atmospheric radiation module—infrastructure and clear-sky solver
Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
- Add ERF_RadiationDiagnostics.cpp and ERF_AdvanceTwoStreamRadiation.cpp to CMake build
- Add Source/Radiation include directory to CMake
- Add ERF_AdvanceTwoStreamRadiation.cpp to GNUmake Make.package
- Add compute_twostream_radiation_diagnostics() function declaration to ERF.H
- Implement compute_twostream_radiation_diagnostics() for Phase 1 diagnostic output
- Call compute_twostream_radiation_diagnostics() from ERF::Advance() at end of timestep
- radChoice member and init_params() call already in place (Phase 1 PR)

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…iation-module

Wire Phase 1 Radiation Module into Build System and Timestep Loop
…n test folders

Previously sounding_us_standard_atm lived at Exec/CanonicalTests/Radiation/
(outside both case folders) and was shared by reference across
SW_ClearSky_Analytical and LW_Isothermal. Per repo convention (see
Exec/CanonicalTests/ABL/*, e.g. input_sounding_GABLS1, mrf_sounding_unstable),
sounding files must live inside their own case folder and be named after
that case. This adds input_sounding_sw_clearsky and
input_sounding_lw_isothermal inside their respective folders and updates
the inputs files to reference them. The old shared file is removed.
Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
… kernel

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…AD_MPI_SKILLS.md

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…radiation

Phase 2: Real per-column vertical integration for two-stream radiation
…plementation

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…olumn-kernel

Wire per-column radiation kernel into diagnostics driver
… real dz

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…nown issues

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…completion

Phase 2c: GPU-safe ParallelFor, real LW downward sweep, and documentation updates
…raction masking)

- Add erf.radiation.tau_profile_type (constant|cloud_layer) to RadChoice
- Add cloud_base_height_m, cloud_top_height_m, cloud_tau_per_layer params
- Add erf.radiation.cloud_fraction (default 0.0, byte-identical when 0)
- Extend vertical_two_stream_sweep() to compute clear-sky and cloudy-column
  SW/LW fluxes and blend via F = (1-cf)*F_clear + cf*F_cloudy
- Add SW_Cloud_Layer RegTest
- Update RAD_DEVELOPMENT.md and RAD_MPI_SKILLS.md with Phase 3 + Phase 2d sections
Copilot AI and others added 30 commits September 1, 2026 13:01
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…IFICATION.md

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
…n-up-canonical-tests

# Conflicts:
#	PHASE_12_MANUAL_VERIFICATION.md
#	PHASE_13_IMPLEMENTATION_SUMMARY.md
#	Source/Radiation/RAD_DEVELOPMENT.md
#	Source/Radiation/RAD_MPI_SKILLS.md

Co-authored-by: hgopalan <1108371+hgopalan@users.noreply.github.com>
Professionalize Radiation test suite structure and documentation
…put, and canonical cases

The two-stream column sweep treated the lowest vertical index as the top
of the atmosphere and the highest as the surface, opposite to ERF's
convention (and to the cloud-band helper in the same file). The sweep now
uses k = kmin as the surface layer and k = kmax as the top layer: SW
sweeps downward from kmax, and LW stores interface fluxes (m = 0 surface,
m = nlev TOA) from a downward then an upward sweep. The per-layer optical
depth assembly is factored into diagnose_layer_tau(). The LW heating-rate
kernel also had the wrong sign for its stated convention and now returns
-(dF_net/dz)/(rho cp).

Layer temperature is now obtained from rho*theta through the equation of
state (Exner function) instead of using theta directly, for LW emission
and for the pressure/RH used by the prognostic cloud fraction.

The plotfile capability gate only recognized the RRTMGP path, so qsrc_sw
and qsrc_lw were dropped for TwoStream runs; it now matches the
qheating_rates allocation.

Canonical radiation cases: checkers parse the diagnostics CSV by header
name (the call_site column broke positional parsing), SW checkers account
for the surface albedo in the SW_surface diagnostic, the nonuniform-dz
and SEB checkers accept pre/post-dycore rows and the extended column set,
two soundings that carried water vapor without a moisture model are dried,
the dynamic diurnal case is shortened to a 2-hour sunset window, a checker
is added for SW_Cloud_Layer, README script names are corrected, and stale
reference diagnostics are regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fix two-stream radiation vertical orientation and Exner temperature; repair canonical cases
…flected LW surface term

The TwoStream ctest entries were gated behind ERF_ENABLE_RRGMTP (a typo
for RRTMGP), had no test_files inputs, and needed gold plotfiles that do
not exist, so they never ran. Replace them with gold-free tests:
TwoStream_ColumnHeating runs a short SW+LW column case and a new C++
checker (Tests/TwoStreamRadiationCheck.cpp) verifies from the plotfile
that SW heating is strongest at the top layer and LW cooling to space is
strongest at the top layer; Plotfile3D_TwoStreamHeatingSelection checks
that qsrc_sw/qsrc_lw are written for the TwoStream path.

Move the per-column two-stream helpers and vertical_two_stream_sweep()
into Source/Radiation/ERF_TwoStreamColumn.H so unit tests can call them,
and add Tests/Unit/Radiation with kernel tests (Beer-Lambert, diffuse
source gating, gray-gas fixed points, LW heating sign) and column tests
(Exner temperature, surface at k = 0, absorbed SW surface flux, cooling
to space, reflected surface term, isothermal mode, night, disabled bands).

The column test exposed that PR #325 was merged without its second
commit, so the reflected (1 - eps) F_down(0) term in the LW surface
boundary condition is re-applied here.

Also fix Tests/Unit/IO/ERF_GTestPlotfileSelection.cpp, which called
make_capabilities with a stale five-argument signature and prevented
erf_unit_tests from compiling on this branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wire two-stream radiation into unit and regression tests; re-apply reflected LW surface term
Remove dead code and unused options:
- ERF_RadiationParams.H and ERF_RadiationCoupling.H were never included
  (a no-op stub and a duplicate of RadChoice); ERF_TwoStreamColumn.H is
  listed in Make.package in their place.
- erf.radiation.cloud_fraction_smooth_enable / cloud_fraction_smooth_alpha
  were parsed and documented but never applied (the sweep has no
  persistent per-level state for an EMA). The inputs, the unused
  smooth_cloud_fraction_ema() helper and the docs entries are removed.

Moisture handling in the column kernels:
- The SW/LW dynamic-tau helpers read RhoQ1/RhoQ2 without checking the
  state carried them and contained a duplicated block after an always-true
  #if. They are replaced by get_qv_from_state()/get_qc_from_state()
  (guarded on nComp) and one diagnose_tau_dynamic() shared by both bands.
- diagnose_cloud_fraction_prognostic() uses the same helpers and drops
  its unused Geometry argument.

Cloud band on stretched grids:
- The cloud band was located with the uniform geom.CellSize(2) even on
  stretched grids while the aerosol profile used z_phys_cc. Both now use
  one layer-center height above the surface, so is_cloud_level,
  tau_layer_value and select_scattering_props take a height instead of
  (k, kmin, dz). Identical on uniform grids.

Constants: cp uses ERF's Cp_d and the Stefan-Boltzmann constant is
defined once (stefan_boltzmann in ERF_TwoStreamLW.H).

Unit tests cover the cloud band by height, the linear dynamic optical
depth with clamping, and the moisture helpers on dry and moist states.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Clean up the two-stream radiation driver
…olumns

Shortwave: the diffuse field is now a two-stream solution with upward and
downward streams. compute_sw_layer_two_stream() replaces the downward-only
single-scatter source with the Meador-Weaver (1980) layer solution
(reflectance/transmittance for diffuse and direct incidence) using the
practical-improved-flux-method gamma coefficients. The column sweep
combines the layers with the surface by the adding method (A_0 = albedo,
S_0 = albedo * F_dir(0)), so reflected sunlight is scattered and absorbed
on its way up, and takes the heating rate from the divergence of
F_dir + F_diff_down - F_diff_up. With omega = 0 the absorbed surface flux
still reduces exactly to (1 - albedo) times Beer-Lambert. The theory doc
and the SW_Scattering_Cloud Python replica describe/implement the same
scheme.

Diagnostics: the columns F_up_surface and F_down_toa held the net LW at
the surface and a constant zero. They are renamed LW_net_surface and
LW_up_TOA (now the outgoing longwave), and SW_up_TOA (reflected SW leaving
the top) is added, giving
  step,time,call_site,SW_surface,SW_TOA,SW_up_TOA,LW_net_surface,LW_up_TOA,heating_rate_max
The column sweep returns the two new quantities. All checkers, READMEs
and reference .dat files are updated; the LW_Isothermal checker verifies
LW_net_surface = 0 and LW_up_TOA = sigma T^4.

Unit tests cover the layer solution (non-scattering, conservative and
partly absorbing layers) and the column energy budget with surface
reflection, including a conservative scattering column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two-stream SW adding method with surface albedo; honest diagnostics columns
…meter struct

The RhoTheta source term adds rho * qheating to the rho-theta equation,
so qheating_rates must hold dtheta/dt; the RRTMGP interface divides its
dT/dt by the Exner function before storing, but the two-stream sweep
stored the raw dT/dt, leaving its heating too weak by pi (about 3% at
1 km, 25% at 10 km). The sweep now multiplies the SW and LW flux-
divergence tendencies by 1/pi from the equation of state before writing
them, matching the RRTMGP convention. A unit test derives the top-layer
dT/dt analytically and checks the stored value is dT/dt divided by pi.

RadChoice carries std::string members and was captured by value in the
driver's device reduction lambda and passed into the column kernels,
which CUDA/HIP reject. TwoStreamParams is the trivially copyable subset
the kernels need (static_assert-guarded); the driver builds it once per
call with make_two_stream_params() and the column helpers and sweep
take it instead of RadChoice.

Reference diagnostics are regenerated (heating_rate_max changes by the
Exner factor).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two-stream: store theta tendencies (Exner) and use a device-safe parameter struct
…etire isothermal_test

lw_mass_absorption_enable computes the clear-sky LW optical depth of a
layer from its mass path, rho dz (k_dry + k_vapor qv + k_cloud qc),
instead of the fixed tau_lw_per_layer. The column optical depth becomes
independent of the vertical resolution, water vapor produces a
greenhouse response, and the cloud term is the Stephens (1978)
emissivity 1 - exp(-0.158 LWP[g/m^2]). Off by default.

surface_albedo_sw_diffuse gives the surface a separate albedo for the
diffuse stream (A_0 of the adding method; the direct-beam albedo still
starts S_0). earth_sun_distance_enable scales S0 by Spencer's (1971)
(d0/d)^2 for day_of_year.

The isothermal_test / T_iso_K override, which forced F_up = F_down and
zero heating instead of computing them, is removed from the inputs,
the parameter struct, the sweep and the driver. LW_Isothermal is now a
physically isothermal column: theta(z) = 300 exp(g z / (c_p 300)) gives
T = 300 K everywhere over a black surface at 300 K, and the checker
verifies LW_up_TOA = sigma T^4 (to 1e-5 in practice), a negligible
surface net LW and a non-zero cooling rate.

The benchmark-suite case directories were symlinks to the canonical
cases; they are now plain copies so the tree carries no symlinks.

Unit tests cover the resolution independence of the mass-based LW, the
mass-path formula and Stephens emissivity, the diffuse albedo, and the
Earth-Sun factor. Reference diagnostics are regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two-stream: mass-based gray LW, diffuse albedo, Earth-Sun distance; retire isothermal_test
…rd compatible

The default per_layer model assigns tau_per_layer / tau_lw_per_layer to
every layer regardless of thickness, so the column optical depth scales
with the number of vertical cells. The new opt-in tau_model = mass
builds each layer's optical properties from its mass path rho dz:

- Shortwave: dry absorption (sw_kabs_dry), Rayleigh scattering
  (sw_kscat_dry, omega = 1, g = 0), water vapor (sw_kabs_vapor) and cloud
  water (sw_kext_cloud with sw_cloud_omega / sw_cloud_g), mixed by
  extinction weighting into the layer tau, omega and g; the prescribed
  cloud band, moisture coefficients and aerosol are added on top.
- Longwave: the mass path rho dz (lw_kabs_dry + lw_kabs_vapor qv +
  lw_kabs_cloud qc), also selected by tau_model = mass.

diagnose_layer_optics() returns (tau, omega, g) per layer and reduces
to the previous diagnose_layer_tau() + select_scattering_props() pair in
the per-layer model, which is unchanged bit for bit.

New canonical case TwoStream_MassOpticalDepth runs the same moist column
on 32 and 64 layers and checks that the surface and top-of-atmosphere
fluxes agree (they do to 3e-6). Unit tests cover the resolution
independence, the extinction-weighted layer optics, a Rayleigh-only
column, cloud-water brightening and the per-layer path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two-stream: mass-based optical depth model (tau_model = mass), backward compatible
…ep's box handling

CI failed on every platform. Four build breaks, plus a correctness bug in
how the sweep chooses its vertical extent that the regression test was
configured to hide.

Build breaks

- M_PI is not declared by <cmath> on MSVC. Use ERF's own PI from
  ERF_Constants.H, which is amrex::Real rather than double.
- amrex::min/max and std::min/max were called with a double literal and an
  amrex::Real. Template deduction fails whenever amrex::Real is float, so
  every SINGLE-precision job broke (GCC, Clang, HIP, SYCL), as did MSVC in
  both precisions. Five call sites.
- nvcc rejected the AMREX_GPU_DEVICE-only helpers in ERF_SolarGeometry.H and
  ERF_AerosolOpticalDepth.H, which are called from the host driver and from
  AMREX_GPU_HOST_DEVICE helpers. Those headers, and the equivalents in
  ERF_SimplifiedSEB.H and ERF_TwoStreamColumn.H, are now
  AMREX_GPU_HOST_DEVICE, matching ERF_TwoStreamSW.H and ERF_TwoStreamLW.H.
- Trailing whitespace in 18 files.

Column sweep

vertical_two_stream_sweep() took its surface and top-of-atmosphere indices
from mfi.tilebox(). AMReX's default CPU tile size splits z, so each 8-cell
tile restarted the sweep at its own bottom as if that were the ground:
surface shortwave heating came out 35% high. All three MFIter loops in the
driver are now untiled and use validbox(). That also stops the in-place
force-restore surface update from advancing a column once per z tile.

A box that does not span the domain in z gives this solver a truncated
atmosphere, so it now aborts naming amr.max_grid_size_z instead of
returning heating rates that look plausible and are wrong.

Inputs and tests

- 30 input files set amrex.max_grid_size_z, which ParmParse ignores. The
  option is amr.max_grid_size_z.
- TwoStream_ColumnHeating no longer disables MFIter tiling, so it covers the
  sweep bug above.
- RunTwoStreamRadiation.cmake echoes the simulation and checker logs when a
  step fails, and runs the executables directly in a build without MPI
  instead of invoking an empty launcher.
- TwoStreamRadiationCheck names the cell holding a non-finite value, and no
  longer lets AMReX parse the plotfile directory as an inputs file.

Verified: erf_unit_tests (485), ctest TwoStream regressions, and all 30
canonical radiation cases pass; results are unchanged on 1, 2 and 4 MPI
ranks and with tiling on or off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…red inputs

Review feedback on the comments, plus an audit for configurations that fail
without saying anything.

Comments and documentation

The comments carried internal development-phase numbering ("Phase 12",
"Step 4") that means nothing outside the branch it was written on. All of it
is gone from the radiation code, its canonical tests and its docs, along with
14 phase-numbered files and directories, three internal verification
documents, and some commented-out code. Only the pre-existing phase
references in ERF_ComputeDiffusivityYSUNew.cpp are left alone; they came from
upstream.

The specific inconsistency raised in review was a comment that referred to
both solverChoice.rad_type and solverChoice.radChoice.rad_type without saying
they are different things. They are: erf.radiation_model sets
SolverChoice::rad_type (a RadiationType) and selects RRTMGP, while
erf.radiation_type sets RadChoice::rad_type (a RadType) and selects
two-stream. Every comment that mentions them now says which input drives
which. ERF_RadiationDiagnostics.H also documented a "[RAD][Phase1]" log tag
that the code stopped emitting; the doc now matches what is printed.

Configurations that failed silently

Seven ways to misconfigure radiation produced no message at all:

- erf.radiation_type = RRTMGP parsed, then ran with no radiation, because
  nothing reads that enum value.
- Setting erf.radiation_model and erf.radiation_type together silently
  discarded the two-stream configuration: advance_radiation() takes the
  RRTMGP branch first.
- A misspelled tau_model, tau_profile_type, aerosol_profile_type or
  diag_callsite_mode fell through to the default, so a typo quietly changed
  the physics or the diagnostics.
- aerosol_profile_type = table was accepted but contributes nothing, which
  is indistinguishable from a typo.
- seb_prognostic_enable without seb_enable did nothing.

Each is now an abort naming the input and the valid values. The existing
seb_diagnostic_enable warning is the model for these.

One input file also set seb_grdflux_default where the code reads
seb_grdflx_default, so the SEB diagnostic baseline silently ran with a
different ground heat flux than the case it is compared against.

Test checkers

check_progcf.py and check_aero.py verified nothing. Both split a
comma-separated diagnostics file on whitespace, so every row became a single
token that float() rejected and "except ValueError: pass" swallowed, and both
returned success when the file was missing entirely. They now parse by column
name, fail on a missing or empty file, require the expected columns, and
check that the shortwave fluxes are physically ordered and the column heats.
Both were confirmed to fail on an injected NaN and on a missing file.

TwoStream_ProgCloudFraction ran with cloud_fraction_prog_enable = false while
its README said the feature was enabled, so the feature had no coverage. It
is enabled now, which moves surface shortwave from 160 to 319 W/m^2. The
README also promised temporal smoothing, whose inputs were removed earlier.
TwoStream_DynamicTau_MoistCloud is left feature-off: it is documented as the
backward-compatibility baseline.

Verified: erf_unit_tests (485), ctest TwoStream (37), and all 17 canonical
radiation cases with all 17 Python checkers pass. The new aborts were each
confirmed to fire with the intended message, and no existing case trips one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… config

Drop the cross-solver check that was added to SolverChoice::init_params. It
aborted when erf.radiation_model and erf.radiation_type both selected a
solver, which is a gate on the RRTMGP path, and this work is meant to stay
within the two-stream model.

Setting both again resolves the way it always has: advance_radiation() takes
the RRTMGP branch and the two-stream configuration is ignored.

ERF_DataStruct.H is now unchanged by this branch. Every remaining validation
sits in RadChoice::init_params, the two-stream configuration struct, and no
RRTMGP source file is touched.

Verified: erf_unit_tests (485), ctest TwoStream (37), and all 17 canonical
radiation cases with their Python checkers pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The input validation added in the previous commit aborted. Existing inputs
may still carry some of these settings, so nothing aborts any more: each case
prints a message and the run continues with a stated fallback.

- An unrecognized tau_model, tau_profile_type, aerosol_profile_type or
  diag_callsite_mode falls back to its documented default, and the warning
  names the input, the valid values and the fallback actually taken.
- aerosol_profile_type = table keeps that selection rather than quietly
  substituting a different profile, and the warning says it contributes no
  aerosol optical depth.
- erf.radiation_type = RRTMGP warns that the value is not wired to a solver,
  so no radiation is computed, and points at erf.radiation_model.

Every message prints once, on the IO processor, so a multi-rank run does not
repeat it. A correctly configured run prints none of them.

The seb_prognostic_enable check from the previous commit is removed: it was
dead code. init_params already auto-enables seb_enable and
seb_diagnostic_enable when the prognostic mode is requested, above the point
where the check sat, so its condition could never be true. That auto-enable
now reports itself instead, since it switches on two inputs the user did not
set.

Verified against the executable: all seven paths print their message and exit
0, and a clean run prints nothing. erf_unit_tests (485), ctest TwoStream (37)
and all 17 canonical radiation cases with their Python checkers pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither radiation path had a single BL_PROFILE region, so there was no way to
separate radiation cost from the dycore for either solver. Adds one at each
entry point and a canonical case that uses them to measure what a radiation
update costs and how that cost scales.

The case is a measurement, not a ranking. RRTMGP resolves 224 shortwave and
256 longwave g-points with gas optics from lookup tables; the two-stream
solver does one gray sweep per column. The gap is the price of spectral
detail, and the useful output is a cost model for choosing between them.

Measured on one CPU core, marginal cost per call:

    grid          cells    two-stream    RRTMGP
    42x4x42       7056       1.08 ms     433 ms
    84x4x42      14112       2.07 ms     881 ms
    84x4x84      28224       4.27 ms    1812 ms
    168x4x84     56448       8.55 ms    3581 ms

Cost per cell is flat for both across a factor of eight in problem size, so
either solver extrapolates predictably to a production grid.

Keeping the comparison honest

Both inputs pull in shared_settings through AMReX's FILE include, so the
shared configuration is the same bytes for both runs rather than two lists
that happen to agree. It pins the grid, timestep, sounding, moisture model and
surface temperature, calls radiation every slow step so the comparison is per
call rather than per schedule, and switches off plotfiles and both diagnostic
logs so file I/O stays outside the measured region.

The reported figure is the marginal cost, (T_long - T_short) divided by the
difference in call counts, measured at two step counts. That cancels one-time
work, which matters here: RRTMGP reads about 45 MB of lookup tables on its
first call, worth roughly 150 ms, and a naive average would have charged that
to its per-call cost. The two-stream solver is also called twice per step
against RRTMGP's once, so normalising by the profiler's own call count rather
than by step count keeps that from skewing the result.

check_timing_fairness.py enforces those invariants and fails if either input
shadows a shared setting, strays outside its solver's namespace, drops the
include, changes the surface temperature on one side only, stops calling
radiation every step, or re-enables plotfiles. It needs no RRTMGP build.

Caveats, stated in the README and on the figure: single CPU core, RRTMGP
targets GPUs, and the two solvers do not compute the same thing, so cost per
call is not cost per unit of accuracy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CUDA

vertical_two_stream_sweep() read its uniform cell size from
amrex::Geometry::CellSize(), which is host-only, so nvcc rejected the call
once the sweep became AMREX_GPU_HOST_DEVICE:

    error: calling a __host__ function from a __host__ __device__ function
    is not allowed

The Geometry was only ever used for that one value, so it is gone from the
device signature and the cell size is passed in instead, read on the host at
the call site. That also stops a Geometry being copied into every kernel
launch. Three call sites updated, including the column unit test.

Warnings

The CI Report step exits with the number of warnings, so any warning fails the
job. Two came from this PR, both -Wshadow on HIP:

- the surface-energy-balance kernel declared locals t_deep and q_deep, which
  shadow the ERF members of the same names; renamed to t_deep_val, q_deep_val;
- the surface-property allocation declared IntVect ng, shadowing an outer ng
  in init_stuff(); renamed to ng_sfc.

One did not come from this PR but fails GCC, ubuntu, HIP and SYCL on its own:
an unused HOL_abs in ERF_ComputeDiffusivityMRF.cpp. Two other sites in that
file compute HOL_abs and use it as pow(1 + 16*HOL_abs, -1/4) for the unstable
arm, and the comment above this third site describes that idiom, but the site
itself writes pow(max(1 - 16*HOL_bounded, 0.01), -1/4) and never reads the
variable. The two forms agree where it matters: the branch is only reached
when obuk_val <= 0, so HOL_bounded <= 0, the max never binds, and the base
equals 1 + 16*|HOL|. The unused variable and its stale comment are removed and
the arithmetic is untouched.

That third site being written differently from its two siblings looks
unintentional and is worth a look by the author of the change that added it,
but it is not this PR's to decide.

Leftover internal markers

Seven "Phase N" labels survived the earlier comment cleanup, in canonical test
banners, two inputs files and one README. They are internal scheduling labels
with no meaning to a reader of the repository, so they are dropped. Only the
labels changed; no test logic, no input values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TwoStream_SEB_Diagnostic is a pair: the feature case turns the SEB diagnostic
on, the baseline leaves it at its default off, and check_seb_diagnostic.py
compares the two diagnostic files to confirm the baseline is unchanged by the
feature. The pair only means something if both halves cover the same interval,
and they did not:

    inputs_seb_diagnostic_enabled    stop_time = 36.0      72 steps
    inputs_seb_diagnostic_disabled   stop_time = 3600.0  7200 steps

So the comparison could only ever reach the first 72 steps, and the baseline's
remaining 7128 steps were work nothing read - about 4000 s of a core, which is
most of the cost of running the canonical radiation cases at all.

The baseline is set to 36.0 to match, and a comment above it says the two have
to stay equal. Nothing else about either case changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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