Developing and Testing Your Code
Initial Notes for the Included Examples
For the purposes of this guide, we will employ the working example
of adding a new atmosphere processes
to the MAM4xx aerosol library, specifically
the not-real, example process "Ash-Injection."
The source files for this process will be found at src/physics/mam/
.
Note that here, and for the remainder of this guide, we will denote all paths
relative to the EAMxx root directory E3SM/components/eamxx/
(see Code Structure
for additional details on how EAMxx code is organized).
Example: Adding a New Atmosphere Process
The minimum files required for a new process will be those defining the
interface to EAMxx.
For our case, and following the current informal naming standard, these will
be the eamxx_inject_ash_process_interface.<x>pp
files, located at
src/physics/mam/
, and trivial skeleton examples are provided below.
#ifndef EAMXX_MAM_INJECT_ASH_HPP
#define EAMXX_MAM_INJECT_ASH_HPP
#include "share/atm_process/atmosphere_process.hpp"
namespace scream {
class MAMInjectAsh : public AtmosphereProcess {
public:
MAMInjectAsh(const ekat::Comm &comm, const ekat::ParameterList ¶ms);
AtmosphereProcessType type() const override {
return AtmosphereProcessType::Physics;
}
std::string name() const override { return "MAMInjectAsh"; }
void
set_grids(const std::shared_ptr<const GridsManager> grids_manager) override;
protected:
void initialize_impl(const RunType run_type) override;
void run_impl(const double dt) override;
void finalize_impl() override { /* Nothing to do */ }
Field source_mask;
Field lat;
Field lon;
}
} // namespace scream
#endif // EAMXX_MAM_INJECT_ASH_HPP
#include "ash_source_calculation.hpp"
#include "eamxx_inject_ash_process_interface.hpp"
namespace scream {
MAMInjectAsh::MAMInjectAsh(const ekat::Comm &comm,
const ekat::ParameterList ¶ms)
: AtmosphereProcess(comm, params) {
[...]
}
void MAMInjectAsh::set_grids(
const std::shared_ptr<const GridsManager> grids_manager) {
constexpr auto kg = ekat::units::kg;
auto grid = grids_manager->get_grid("Physics");
add_tracer<Updated>("ash", grid, kg / kg);
lat = grid->get_geometry_data("lat");
lon = grid->get_geometry_data("lon");
}
void MAM_AshInjection::initialize_impl(const RunType run_type) {
source_mask = get_field_out("ash").clone("source_mask");
[...] // set source_mask=1 only on area of interest, using lat/lon field
}
void MAM_AshInjection::run_impl(const double dt) {
auto t = this->timestamp() + dt; // timestamp() is the START of step time
auto ash = get_field_out("ash");
auto rate = compute_ash_injection_rate(
t); // defined in ash_source_calculation.hpp header
ash.update(source_mask, rate * dt,
decay); // ash = decay*ash + rate*dt*source_mask
}
} // namespace scream
Add the New Process to CMake Build
In order to ensure the code you've added is configured and built by CMake,
we need to add to the relevant CMakeLists.txt
file.
At minimum, we need to add eamxx_inject_ash_process_interface.cpp
to the EAMxx
library named mam
, as shown below.
Of course, there may be additional configuration commands that are required
for a proper build, but this is beyond the scope of this guide.
[...]
# EAMxx mam4xx-based atmospheric processes
add_library(mam
eamxx_mam_microphysics_process_interface.cpp
eamxx_mam_optics_process_interface.cpp
[...]
eamxx_inject_ash_process_interface.cpp)
[...]
Adding Verification and Validation Tests
All new code that is added to EAMxx is required to be tested. At minimum, a process must be Validation Tested. That is, tested against known, trusted data, and this data can be samples taken by real-world instruments or comparable results generated by other code. Ideally, any new and sufficiently complex functions should also have Verification Tests that test for mathematical or scientific "correctness".1
Single-process Validation Test
First, we add a validation test for (only) our new process to tests/single-process/mam/inject_ash/
.
Defining the test behavior and pass/fail criteria requires 3 files:
CMakeLists.txt
- Largely reusable boilerplate code but should be tailored to the proper test parameters.
- Defines build and run behavior.
- Enumerates the required NetCDF input files.
input.yaml
- Defines key parameters for the model and test run.
- Enumerates the keys (
std::string
) used to access the input files from within the process source code. - Provides information about the test's initial condition.
- Input file names/paths.
- Other required input values.
output.yaml
- Defines the output fields used as pass/fail criteria.
- Defines the tests runtime output behavior.
include (EAMxxUtils)
set (TEST_BASE_NAME mam4_inject_ash_standalone)
set (FIXTURES_BASE_NAME ${TEST_BASE_NAME}_generate_output_nc_files)
# Create the test
CreateADUnitTest(${TEST_BASE_NAME}
LABELS mam4_inject_ash physics
LIBS mam
MPI_RANKS ${TEST_RANK_START} ${TEST_RANK_END}
FIXTURES_SETUP_INDIVIDUAL ${FIXTURES_BASE_NAME}
)
# Set AD configurable options
set (ATM_TIME_STEP 1800)
SetVarDependingOnTestSize(NUM_STEPS 2 5 48) # 1h 2.5h 24h
set (RUN_T0 2021-10-12-45000)
# Copy (and configure) yaml files needed by tests
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/input.yaml
${CMAKE_CURRENT_BINARY_DIR}/input.yaml)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/output.yaml
${CMAKE_CURRENT_BINARY_DIR}/output.yaml)
# Ensure test input files are present in the data dir
set (TEST_INPUT_FILES
eamxx/init/${inject_ash_IC_file}
eamxx/mam4xx/.../inject_ash_input_data_file_ABC.nc
eamxx/mam4xx/.../inject_ash_input_data_file_XYZ.nc
[...]
<path-to-required-input-file>
)
foreach (file IN ITEMS ${TEST_INPUT_FILES})
GetInputFile(${file})
endforeach()
# Compare results between runs employing differing numbers of MPI ranks
# to ensure they are bfb
include (CompareNCFiles)
CompareNCFilesFamilyMpi (
TEST_BASE_NAME ${TEST_BASE_NAME}
FILE_META_NAME ${TEST_BASE_NAME}_output.INSTANT.nsteps_x1.npMPIRANKS.${RUN_T0}.nc
MPI_RANKS ${TEST_RANK_START} ${TEST_RANK_END}
LABELS mam4_inject_ash physics
META_FIXTURES_REQUIRED ${FIXTURES_BASE_NAME}_npMPIRANKS_omp1
)
# Compare one of the output files with the baselines.
# Note: one is enough, since we already check that np1 is BFB with npX
if (EAMXX_ENABLE_BASELINE_TESTS)
set (OUT_FILE ${TEST_BASE_NAME}_output.INSTANT.nsteps_x1.np${TEST_RANK_END}.${RUN_T0}.nc)
CreateBaselineTest(${TEST_BASE_NAME} ${TEST_RANK_END} ${OUT_FILE} ${FIXTURES_BASE_NAME})
endif()
%YAML 1.1
---
driver_options:
atmosphere_dag_verbosity_level: 5
time_stepping:
time_step: ${ATM_TIME_STEP}
run_t0: ${RUN_T0} # YYYY-MM-DD-XXXXX
number_of_steps: ${NUM_STEPS}
atmosphere_processes:
atm_procs_list: [mam4_inject_ash]
mam4_inject_ash:
inject_ash_ABC_data: ${EAMXX_DATA_DIR}/mam4xx/.../inject_ash_input_data_file_ABC.nc
inject_ash_XYZ_data: ${EAMXX_DATA_DIR}/mam4xx/.../inject_ash_input_data_file_XYZ.nc
<identifying-string-used-by-inject_ash_xyz.xpp>: /path/to/.../data_file.nc
grids_manager:
Type: Mesh Free
geo_data_source: IC_FILE
grids_names: [Physics GLL]
Physics GLL:
type: point_grid
aliases: [Physics]
number_of_global_columns: 218
number_of_vertical_levels: 72
initial_conditions:
# The name of the file containing the initial conditions for this test.
Filename: ${EAMXX_DATA_DIR}/init/${inject_ash_IC_file}
topography_filename: ${TOPO_DATA_DIR}/${EAMxx_tests_TOPO_FILE}
# other variables to pass as input
num_ABC : 42.0
num_XYZ : 1.618
<input_variable> : <value>
# The parameters for I/O control
Scorpio:
output_yaml_files: ["output.yaml"]
...
%YAML 1.1
---
filename_prefix: mam4_inject_ash_standalone_output
Averaging Type: Instant
Fields:
Physics:
Field Names:
- answer_field
output_control:
Frequency: 1
frequency_units: nsteps
...
Function-level Verification Tests
Next, because of the highly-technical nature of our new
compute_ash_injection_rate()
function, we add a verification test to the
'tests/' subdirectory, co-located with the source code (src/physics/mam/
).
This subdirectory contains a CMakeLists.txt
that we modify to add our new
test to the build system.
Any required data files should be placed here, and the preferred format for
data is yaml,
though NetCDF can also be used.
#include "catch2/catch.hpp"
#include "ash_source_calculation.hpp"
namespace {
using namespace scream;
TEST_CASE("verify compute_ash_injection_rate", "mam")
{
// initialize the data and test variables
tol = 1.0e-12;
util::TimeStamp t1 = ...
util::TimeStamp t2 = t1 + 86400 * 100; // 100 days later
[...]
// call the function we are testing
auto rate1 = compute_ash_injection_rate(t1);
auto rate2 = compute_ash_injection_rate(t2);
// apply the REQUIRE() function from catch2/catch.hpp to test
// whether the resulting test answer is within a set error tolerance
// from the reference answer. Check all properties that are meaningful
REQUIRE (rate1 >= 0);
REQUIRE (rate2 >= 0);
REQUIRE (rate2 < rate1);
[...]
}
Multi-process Validation Tests
Finally, because the Ash-Injection process is not called in isolation,
we must validate the results generated when Ash-Injection is called in
combination with other processes.
At time of writing, these tests are divided into the self-describing directories
physics_only
and dynamics_physics
.
The subdirectories and test directories are named according to the convention of
listing the names of included processes and potentially relevant grid information.
Generally speaking, these tests are configured in the same manner as the
single-process tests, via the 3 files CMakeLists.txt
, input.yaml
,
output.yaml
, and for this reason we omit further details
Code, Build, Test... Repeat
Now that we've written all of the necessary code and there are no errors, we build EAMxx, run our tests, and watch them pass with flying colors. :white_check_mark:
However, for those of us that are mere mortals, we may need to iterate on this
process a bit until everything works perfectly.
In this case, the automated build and test workflow provided by
scripts/test-all-eamxx
is designed to make this cycle as painless as
possible.
Suggested (Very Thorough and Conservative) Testing Progression
To fully cover all the potential interactions of the Ash-Injection process, a reasonable series of tests is provided in the example script below.
Note: There is no reason all of this could not be done at once, but we write the script in favor of being clear and explicit.
Thorough and Careful Testing Script
For the purposes of this example, we assume we are on a non-supported local
workstation named "bingo-pajama" that has 64 CPU cores and an NVIDIA GPU.
We also assume that we have working configuration files in ~/.cime/
for
both CPU and GPU build configurations.
#!/bin/bash
# machine-related variables
machine_name="bingo-pajama"
total_ncores=64
id_cpu="${machine_name}-cpu"
id_gpu="${machine_name}-gpu"
# name of log file for conveniently capturing output
outfile_prefix="ash_injection_buildNtest"
ofile_cpu="${outfile_prefix}-${id_cpu}.log"
ofile_gpu="${outfile_prefix}-${id_gpu}.log"
# parallelism variables
make_ncores=60
test_ncores=60
pbuild="-p --make-parallel-level ${make_ncores}"
ptest="--ctest-parallel-level ${test_ncores}"
# flags to assist with testing/debugging
vflag="--extra-verbose"
# limit test configuration to double-precision debug
test_config="-t dbg"
bl_loc="${HOME}/eamxx-baselines"
# navigate to the root directory of eamxx
cd "${eamxx_root}"
# switch to the master branch
git checkout master
# pull so we are at the latest commit (origin/HEAD)
git pull origin master
# update the submodules (the below is typically unnecessary, but thorough)
git submodule sync --recursive && \
git submodule update --init --recursive --progress
# flags common to all tests
base_flags="${pbuild} ${vflag} ${test_config}"
# flags common to this stage of tests
test_base_flags="${base_flags} --baseline-dir ${bl_loc} --generate"
# general test flags per architecture
cpu_flags="--work-dir ${PWD}/${id_cpu} --local ${id_cpu} ${ptest}"
gpu_flags="--work-dir ${PWD}/${id_gpu} --local ${id_gpu}"
# specific flags for this test per architecture
cpu_test_flags="${test_base_flags} ${cpu_flags}"
gpu_test_flags="${test_base_flags} ${gpu_flags}"
# generate baselines for cpu and gpu
echo "TEST FLAGS = ${cpu_test_flags}"
./scripts/test-all-eamxx ${cpu_test_flags}
echo "TEST FLAGS = ${gpu_test_flags}"
./scripts/test-all-eamxx ${gpu_test_flags}
# Verify the 'compute_ash_injection_rate()' function works as expected by
running only that test
test_choice="[^\s]*compute_ash_injection_rate_test[^\s]*"
test_base_flags="${base_flags} --limit-test-regex ${test_choice}"
cpu_test_flags="${test_base_flags} ${cpu_flags}"
gpu_test_flags="${test_base_flags} ${gpu_flags}"
# run verification tests
echo "TEST FLAGS = ${cpu_test_flags}"
# now that we're running tests that may fail, use 'tee' to
# duplicate stdout and stderr to file so debugging is slightly easier
./scripts/test-all-eamxx ${cpu_test_flags} |& tee "${ofile_cpu}"
echo "TEST FLAGS = ${gpu_test_flags}"
./scripts/test-all-eamxx ${gpu_test_flags} |& tee "${ofile_gpu}"
# Validate the Ash-Injection process generates the solutions we expect
test_choice="[^\s]*mam4_ash_injection_standalone[^\s]*"
test_base_flags="${base_flags} --limit-test-regex ${test_choice}"
cpu_test_flags="${test_base_flags} ${cpu_flags}"
gpu_test_flags="${test_base_flags} ${gpu_flags}"
# run single-process verification tests
echo "TEST FLAGS = ${cpu_test_flags}"
./scripts/test-all-eamxx ${cpu_test_flags} |& tee "${ofile_cpu}"
echo "TEST FLAGS = ${gpu_test_flags}"
./scripts/test-all-eamxx ${gpu_test_flags} |& tee "${ofile_gpu}"
# validate all the processes that interact with ash_injection generate
# expected solutions
ash_injection_multiproc_tests=(
# physics_only
shoc_mam4_ash_injection
p3_mam4_ash_injection
# dynamics_physics
homme_shoc_cld_p3_mam4_ash_injection_rrtmgp
homme_shoc_cld_spa_p3_rrtmgp_mam4_ash_injection
)
for tchoice in ${ash_injection_multiproc_tests[@]}; do
test_choice="[^\s]*${tchoice}[^\s]*"
test_base_flags="${base_flags} --limit-test-regex ${test_choice}"
cpu_test_flags="${test_base_flags} ${cpu_flags}"
gpu_test_flags="${test_base_flags} ${gpu_flags}"
# run single-process verification tests
echo "TEST FLAGS = ${cpu_test_flags}"
./scripts/test-all-eamxx ${cpu_test_flags} |& tee "${ofile_cpu}"
echo "TEST FLAGS = ${gpu_test_flags}"
./scripts/test-all-eamxx ${gpu_test_flags} |& tee "${ofile_gpu}"
done
# NOTE: just setting 'test_choice="[^\s]*_mam4_ash_injection^\s]*"' would
# also do the trick in this case
# run full eamxx test suite against pre-Ash-Injection baselines for all
# test configurations (not just 'dbg')
base_flags="${pbuild} ${vflag} --baseline-dir ${bl_loc}"
cpu_test_flags="${base_flags} ${cpu_flags}"
gpu_test_flags="${base_flags} ${gpu_flags}"
# run the full suite of eamxx baseline tests
echo "TEST FLAGS = ${cpu_test_flags}"
./scripts/test-all-eamxx ${cpu_test_flags} |& tee "${ofile_cpu}"
echo "TEST FLAGS = ${gpu_test_flags}"
./scripts/test-all-eamxx ${gpu_test_flags} |& tee "${ofile_gpu}"
Testing Modifications to Existing Code
Note that if you are merely modifying or augmenting existing code, you may not need to create additional tests, provided there is already a test that targets the piece of code that was edited. However, one case for which you should create a test is when adding a new function to existing code. In this case, a verification test should be added that checks the new functionality for correctness.
Submit Code to E3SM Repository for Review and Integration
Whew!
Now that we have added some exciting new functionality to EAMxx and confirmed that:
- The process sub-components (functions) work correctly.
- Verification testing of the functions
- The process gives us expected answers both in isolation and when interacting
with other processes.
- Single- and multi-process Validation testing
- We have not introduced regressions from the baseline results.
- Baseline testing
Now it's time to submit the code!
Getting your new code integrated into E3SM requires several steps involving git and completed using E3SM's GitHub repository. This process is collectively referred to as a Pull Request or PR.2
At a very high level, the steps involved are:
- Fork the E3SM repository (referred to as a repo by the cool kids).
- Push your development branch to your own E3SM fork.
- Submit the Pull Request from your fork to the
E3SM-Project/E3SM
repository. - Respond to Reviews and comments provided by other developers, and make the necessary modifications to your code.
- Once all reviewers are satisfied with the changes, your code will be Merged into the master branch of E3SM. :partying_face:
-
Admittedly, "correctness" is a slippery concept, but at the very least we can always test for properties like mass conservation, non-negativity, etc. ↩
-
Here is an E3SM-based reference about Submitting a Pull Request (PR) (along with a lot of other good info for developers), and here is another reference provided by GitHub. ↩