diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f36427972..d2d735c390 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,8 +50,8 @@ add_subdirectory(${HLM_ROOT}/src/fates/fire fates_fire) add_subdirectory(${HLM_ROOT}/src/fates/radiation fates_radiation) # Testing directories -add_subdirectory(${HLM_ROOT}/src/fates/testing/testing_shr test_share) -add_subdirectory(${HLM_ROOT}/src/fates/testing/functional_testing/fire/shr fire_share) +add_subdirectory(${HLM_ROOT}/src/fates/testing/tests/fortran_shr test_share) +add_subdirectory(${HLM_ROOT}/src/fates/testing/tests/functional/fire/shr fire_share) # Remove shr_mpi_mod from share_sources. # This is needed because we want to use the mock shr_mpi_mod in place of the real one diff --git a/main/FatesUtilsMod.F90 b/main/FatesUtilsMod.F90 index 1bd644bf4c..c7e4aef398 100644 --- a/main/FatesUtilsMod.F90 +++ b/main/FatesUtilsMod.F90 @@ -244,9 +244,9 @@ subroutine QuadraticRootsNSWC(a,b,c,root1,root2,err) endif if ( e<0.0_r8 ) then ! complex conjugate zeros - write (fates_log(),*)'error, imaginary roots detected in quadratic solve' err = .true. - call endrun(msg=errMsg(sourcefile, __LINE__)) + call endrun(msg="imaginary roots detected in quadratic solve", & + additional_msg=errMsg(sourcefile, __LINE__)) else ! real zeros if ( b1>=0.0_r8 ) d = -d diff --git a/testing/CMakeLists.txt b/testing/CMakeLists.txt index 3b788d51fa..99814f9e75 100644 --- a/testing/CMakeLists.txt +++ b/testing/CMakeLists.txt @@ -1,18 +1,19 @@ # This is where you add specific test directories ## Functional tests -add_subdirectory(functional_testing/allometry fates_allom_ftest) -add_subdirectory(functional_testing/math_utils fates_math_ftest) -add_subdirectory(functional_testing/fire/fuel fates_fuel_ftest) -add_subdirectory(functional_testing/fire/ros fates_ros_ftest) -add_subdirectory(functional_testing/patch fates_patch_ftest) -add_subdirectory(functional_testing/fire/mortality fates_firemort_ftest) +add_subdirectory(tests/functional/allometry fates_allom_ftest) +add_subdirectory(tests/functional/fire/fuel fates_fuel_ftest) +add_subdirectory(tests/functional/fire/ros fates_ros_ftest) +add_subdirectory(tests/functional/patch fates_patch_ftest) +add_subdirectory(tests/functional/fire/mortality fates_firemort_ftest) ## Unit tests -add_subdirectory(unit_testing/fire_weather_test fates_fire_weather_utest) -add_subdirectory(unit_testing/fire_fuel_test fates_fire_fuel_utest) -add_subdirectory(unit_testing/sort_cohorts_test fates_sort_cohorts_utest) -add_subdirectory(unit_testing/insert_cohort_test fates_insert_cohort_utest) -add_subdirectory(unit_testing/validate_cohorts_test fates_validate_cohorts_utest) -add_subdirectory(unit_testing/count_cohorts_test fates_count_cohorts_utest) -add_subdirectory(unit_testing/fire_equations_test fates_fire_equations_utest) +add_subdirectory(tests/unit/fire_weather_test fates_fire_weather_utest) +add_subdirectory(tests/unit/fire_fuel_test fates_fire_fuel_utest) +add_subdirectory(tests/unit/sort_cohorts_test fates_sort_cohorts_utest) +add_subdirectory(tests/unit/insert_cohort_test fates_insert_cohort_utest) +add_subdirectory(tests/unit/validate_cohorts_test fates_validate_cohorts_utest) +add_subdirectory(tests/unit/count_cohorts_test fates_count_cohorts_utest) +add_subdirectory(tests/unit/fire_equations_test fates_fire_equations_utest) +add_subdirectory(tests/unit/quadratic_roots_test fates_quadratic_roots_utest) +add_subdirectory(tests/unit/great_circle_test fates_great_circle_utest) diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000000..932ee8ccf6 --- /dev/null +++ b/testing/README.md @@ -0,0 +1,116 @@ +# FATES Testing Framework + +This directory contains the infratstructure to set up, build, and execute FATES functional +and unit tests. + +## Test Definitions + +* **Functional Tests**: Standalone Fortran programs that exercse specific modules of the +FATES production code. They often output NetCDF data and include a Python class for +automated plotting and science validation. These are "hands-on" tests for developers. + +* **Unit Tests**: Discrete tests with a clear Pass/Fail status, utilizing **pfUnit** or +**CTest**. These are best for testing edge cases, error handling, and logical branches +within individual subroutines. + +## Environment Setup + +### Python Requirements + +The framework requires Python 3.12+ with `xarray`, `pandas`, `scipy`, `netCDF`, `numpy`, +and `matplotlib`. You can create a compatible environment using the provided file: + +```bash +conda env create --file=environment.yml +conda activate fates_testing +``` + +### System Requirements + +While these tests do not require a host land model (e.g., CTSM/ELM), they require: + +* **CIME and shr**: These must be present in your source tree. +* **Libraries**: NetCDF (C and Fortran), ESMF, and pfUnit. +* **Machine Tags**: Valid machine and compiler configurations (e.g., NCAR Derecho/Izumi). +If FATES is already running on your machine, these scripts should work out of the box. + +See `docs/cime_setup.md` for a step-by-step guide to setting up the environment on your +own machine (Mac/Linux). + +## Execution + +### Running Functional Tests + +```bash +# Run all functional tests using default parameters +./run_functional_tests.py + +# Run specific tests with a custom parameter file +./run_functional_tests.py --test-list allometry,fire --param-file my_params.json +``` + +### Running Unit Tests + +```bash +# Run all unit tests +./run_unit_tests.py +``` + +*Note: Use the `--help` flag on either script to see full options for build directories, +skipping runs, and saving figures.* + +## Creating New Tests + +To maintain consistency, always use the boilerplate generator to start a new test. + +### Step 1. Generate Scaffolding + +Run the generator script. This updates the CMake system, adds config entries, and creates +your test directory. + +```bash +# Example: Create a new functional test named 'hydro_stress' +./generate_empty_test.py functional --test-name hydro_stress +``` + +### Step 2: Implement the Fortran Logic + +Navigate to `tests/[unit|functional]/[test_name]` and edit the generated `.F90` or `.pf` +file. + +* **Functional**: Write a standard Fortran program. +* **Unit**: Write a pfUnit-compatible test module. + +### Step 3: Configure Metadata + +Edit `config/functional.cfg` or `config/unit.cfg`. The generator provides defaults, but +you may need to update: + +* `out_file`: The name of the NetCDF file your Fortran code generates. +* `use_param_file`: Set to `True` to pass the FATES JSON parameter file to your binary. +* `datm_file`: (Optional) Driver file name. All driver data should be placed in `testing/tests/data` + +### Step 4: Python Analysis (Functional Only) + +The generator creates a Python file (`[test_name]_test.py`) in your test directory. Edit +the `plot_output` method to define how your test results should be visualized or validated. + +## Directory Structure + +* `framework/`: Core logic for loading, building, and executing tests. +* `config/`: Registry of all active tests and their arguments. +* `tests/`: The source code for individual test cases. +* `templates/`: Boilerplate files used by the generator. + +## Troubleshooting + +* **Missing Drivers**: If a functional tests requires a `datm_file`, ensure the file name0 in +the `.cfg` is correct and the data is in `testing/tests/data`. +The script will perform a pre-flight check and fail if the file is missing. + +* **Build Failures**: Ensure your environment modules (compiler, netcdf, esmf) match the +configuration in your CIME machine tags. Also, if you are using a Fortran module that is +in a file that is not currently listed in one of the `CMakeLists.txt` files, you will +need to add it. There should be a `CMakeLists.txt` file in each subdirectory of the FATES +source code. Add the file to the corresponding `CMakeLists.txt` inside the `fates_sources` +list. diff --git a/testing/README.testing.md b/testing/README.testing.md deleted file mode 100644 index 812891d2a0..0000000000 --- a/testing/README.testing.md +++ /dev/null @@ -1,97 +0,0 @@ -# FATES Testing - -These scripts set up, build, and run FATES functional tests and unit tests. - -By "functional" test, we mean a standalone Fortran program that runs pieces of the FATES -production code, potentially outputs results (i.e. to a netcdf file), and potentially -plots or runs some other test on the output in python. These tests do not necessarily -have a pass/fail status, but are meant to be more hands-on for the user. - -Unit tests do have a pass/fail outcome, and are written as such. We accommodate both Ctest -and pfunit tests. - -## How to run - -To run the testing scripts, `run_functional_tests.py` or `run_unit_tests.py`, you will -need a few python packages. You can create a conda environment with these packages -using the `testing.yml` file: `conda env create --file=testing.yml` - -Though these tests do not need host land model code, you will need the `cime` and `shr` -repositories, as well as a machine configuration file. If you are already set up to run -FATES on a machine (e.g. derecho at NCAR), you should be able to run these scripts out -of the box on that machine. - -Additionally, these tests require netcdf and netcdff, as well as a fortran compiler (e.g. gnu), -esmf, and pfunit. See `cime_setup.md` for tips on how to do this. - -Once you are set up, you should be able to just run the scripts. For the functional test -script, you can point to your own parameter file (cdl or nc; `./run_functional_tests -f my_param_file.nc`). -If none is supplied the script will use the default cdl file in `parameter_files`. - -You can run an individual set of tests by passing the script a comma-separated list of -test names. See the `functional_tests.cfg` or `unit_tests.cfg` for the test names. If you -do not supply a list, the script will run all tests. - -## How to create new tests - -First, determine if you are going to create a functional or unit test. Remember, -unit tests must have a pass/fail outcome. These are best for testing edgecases, error -handling, or where we know exactly what the result should be from a method. - -First, add your test to either the `functional_tests.cfg` or `unit_tests.cfg` config file, -depending on the test you want to create. - -### Config file information - -The `test_dir` is where cmake will place relevant libraries created from your test. -The convention is to call this directory `fates_{testname}_ftest` for functional tests -and `fates_{testname}_utest` for unit tests. - -The `test_exe` (only applicable for functional tests) is the executable the script will -create based on your test program. The convention is to call it `FATES_{testname}_exe`. - -The `out_file` (only applicable for functional tests) is the output file name that your test -may or may not create. Set the value to `None` if your test does not create one. - -Set `use_param_file` to `True` if your test uses the FATES parameter file, and `False` -otherwise. This is only applicable for functional tests. - -Add any other arguments your test needs in the `other_args` list. -This is only applicable for functional tests. - -### Cmake setup - -Under the `testing/functional_testing` or `testing/unit_testing` directory, depending -on your test type, create a new directory for your test, e.g. "my_new_test". - -In the file `testing/CMakeLists.txt` add your test directory to the list of tests, e.g.: - -`add_subdirectory(functional_testing/my_new_test fates_new_test_ftest)` - -The first argument must match your directory name, and the second must match the -`test_dir` value you set up in the config file above. - -Inside your new testing directory create a new `CMakeLists.txt` file to tell cmake -how to build and compile your program. It may be easiest to copy an existing one -from another similar test and then update the relevant information. - -Importantly, the sources must be set to tell the program which file(s) contain your -test program. Additionally, for functional tests, the executable name must match what -you set up in the config file above. - -### Fortran program - -Write your Fortran tests. For functional tests, this should be an actual Fortran `program`. -For unit tests this will be either a ctest or a pfunit test. See existing tests for examples. - -For functional tests, if you output an output file, the name must match what you set up -in the config file above. - -### Python setup - functional tests - -For functional tests, you will need to add your test as a new concrete class based on -the abstract FunctionalTest class. See examples in the `functional_testing` directory. -Most of the work involves creating a plotting function for your test. - -You will then need to add this class as an import statment at the top of the -`run_functional_tests.py` script. diff --git a/testing/build_fortran_tests.py b/testing/build_fortran_tests.py deleted file mode 100644 index 16dea06721..0000000000 --- a/testing/build_fortran_tests.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Builds/compiles any tests within the FATES repository -""" -import os -import shutil -from path_utils import add_cime_lib_to_path - -add_cime_lib_to_path() - -from CIME.utils import get_src_root, run_cmd_no_fail, expect, stringify_bool # pylint: disable=wrong-import-position,import-error,wrong-import-order -from CIME.build import CmakeTmpBuildDir # pylint: disable=wrong-import-position,import-error,wrong-import-order -from CIME.XML.machines import Machines # pylint: disable=wrong-import-position,import-error,wrong-import-order -from CIME.BuildTools.configure import configure, FakeCase # pylint: disable=wrong-import-position,import-error,wrong-import-order -from CIME.XML.env_mach_specific import EnvMachSpecific # pylint: disable=wrong-import-position,import-error,wrong-import-order - -# constants for this script -_CIMEROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../cime") -_MPI_LIBRARY = "mpi-serial" - -def build_tests(build_dir:str, cmake_directory:str, make_j:int, clean:bool=False, - verbose:bool=False): - """Builds the test executables - - Args: - build_dir (str): build directory - cmake_directory (str): directory where the make CMakeLists.txt file is - make_j (int): number of processes to use for make - clean (bool, optional): whether or not to clean the build first. Defaults to False. - verbose (bool, optional): whether or not to run make with verbose output. Defaults to False. - """ - # create the build directory - full_build_path = prep_build_dir(build_dir, clean=clean) - - # get cmake args and the pfunit and netcdf paths - - cmake_args = get_extra_cmake_args(full_build_path, _MPI_LIBRARY) - pfunit_path = find_library(full_build_path, cmake_args, "PFUNIT_PATH") - - if not "NETCDF" in os.environ: - netcdf_c_path = find_library(full_build_path, cmake_args, "NETCDF_C_PATH") - netcdf_f_path = find_library(full_build_path, cmake_args, "NETCDF_FORTRAN_PATH") - else: - netcdf_c_path = None - netcdf_f_path = None - - # change into the build dir - os.chdir(full_build_path) - - # run cmake and make - run_cmake(cmake_directory, pfunit_path, netcdf_c_path, netcdf_f_path, cmake_args) - run_make(make_j, clean=clean, verbose=verbose) - -def prep_build_dir(build_dir:str, clean:bool) -> str: - """Creates (if necessary) build directory and cleans contents (if asked to) - - Args: - build_dir (str): build directory name - clean (bool): whether or not to clean contents - Returns: - str: full build path - """ - - # create the build directory - build_dir_path = os.path.abspath(build_dir) - if not os.path.isdir(build_dir_path): - os.mkdir(build_dir_path) - - # change into that directory - os.chdir(build_dir_path) - - # clean up any files if we want to - if clean: - clean_cmake_files() - - return build_dir_path - -def clean_cmake_files(): - """Deletes all files related to build - - """ - if os.path.isfile("CMakeCache.txt"): - os.remove("CMakeCache.txt") - if os.path.isdir("CMakeFiles"): - shutil.rmtree("CMakeFiles") - - cwd_contents = os.listdir(os.getcwd()) - - # clear contents to do with cmake cache - for file in cwd_contents: - if ( - file in ("Macros.cmake", "env_mach_specific.xml") - or file.startswith("Depends") - or file.startswith(".env_mach_specific") - ): - os.remove(file) - -def get_extra_cmake_args(build_dir:str, mpilib:str) -> str: - """Makes a fake case to grab the required cmake arguments - Args: - build_dir (str): build directory name - mpilib (str): MPI library name - Returns: - str: space-separated list of cmake arguments - """ - # get the machine objects file - machobj = Machines() - - # get compiler - compiler = machobj.get_default_compiler() - - # get operating system - os_ = machobj.get_value("OS") - - # create the environment, and the Macros.cmake file - configure( - machobj, - build_dir, - ["CMake"], - compiler, - mpilib, - True, - "nuopc", - os_, - unit_testing=True, - ) - machspecific = EnvMachSpecific(build_dir, unit_testing=True) - - # make a fake case - fake_case = FakeCase(compiler, mpilib, True, "nuopc", threading=False) - machspecific.load_env(fake_case) - - # create cmake argument list with information from the fake case and machine object - cmake_args_list = [ - f"-DOS={os_}", - f"-DMACH={machobj.get_machine_name()}", - f"-DCOMPILER={compiler}", - f"-DDEBUG={stringify_bool(True)}", - f"-DMPILIB={mpilib}", - f"-Dcompile_threaded={stringify_bool(False)}", - f"-DCASEROOT={build_dir}" - ] - - cmake_args = " ".join(cmake_args_list) - - return cmake_args - -def find_library(caseroot:str, cmake_args:str, lib_string:str) -> str: - """Find the library installation we'll be using, and return its path - - Args: - caseroot (str): Directory with pfunit macros - cmake_args (str): The cmake args used to invoke cmake - (so that we get the correct makefile vars) - Returns: - str: full path to library installation - """ - with CmakeTmpBuildDir(macroloc=caseroot) as cmaketmp: - all_vars = cmaketmp.get_makefile_vars(cmake_args=cmake_args) - - all_vars_list = all_vars.splitlines() - for all_var in all_vars_list: - if ":=" in all_var: - expect(all_var.count(":=") == 1, f"Bad makefile: {all_var}") - varname, value = [item.strip() for item in all_var.split(":=")] - if varname == lib_string: - return value - - expect(False, f"{lib_string} not found for this machine and compiler") - - return None - -def run_cmake(test_dir:str, pfunit_path:str, netcdf_c_path:str, netcdf_f_path:str, cmake_args:str): - """Run cmake for the fortran unit tests - Arguments: - test_dir (str) - directory to run Cmake in - pfunit_path (str) - path to pfunit - netcdf_c_path (str) - path to netcdf - netcdf_f_path (str) - path to netcdff - cmake_args (str) - extra arguments to Cmake - """ - if not os.path.isfile("CMakeCache.txt"): - - # directory with cmake modules - cmake_module_dir = os.path.abspath(os.path.join(_CIMEROOT, "CIME", "non_py", - "src", "CMake")) - # directory with genf90 - genf90_dir = os.path.join(_CIMEROOT, "CIME", "non_py", "externals", "genf90") - - cmake_command = [ - "cmake", - "-C Macros.cmake", - test_dir, - f"-DCIMEROOT={_CIMEROOT}", - f"-DSRC_ROOT={get_src_root()}", - f"-DCIME_CMAKE_MODULE_DIRECTORY={cmake_module_dir}", - "-DCMAKE_BUILD_TYPE=CESM_DEBUG", - f"-DCMAKE_PREFIX_PATH={pfunit_path}", - "-DUSE_MPI_SERIAL=ON", - "-DENABLE_GENF90=ON", - f"-DCMAKE_PROGRAM_PATH={genf90_dir}" - ] - - if netcdf_c_path is not None: - cmake_command.append(f"-DNETCDF_C_PATH={netcdf_c_path}") - - if netcdf_f_path is not None: - cmake_command.append(f"-DNETCDF_F_PATH={netcdf_f_path}") - - cmake_command.extend(cmake_args.split(" ")) - - print("Running cmake for all tests.") - - run_cmd_no_fail(" ".join(cmake_command), combine_output=True) - -def run_make(make_j:int, clean:bool=False, verbose:bool=False): - """Run make in current working directory - - Args: - make_j (int): number of processes to use for make - clean (bool, optional): whether or not to clean Defaults to False. - verbose (bool, optional): verbose error logging for make Defaults to False. - """ - if clean: - run_cmd_no_fail("make clean") - - make_command = ["make", "-j", str(make_j)] - - if verbose: - make_command.append("VERBOSE=1") - - print("Running make for all tests.") - - run_cmd_no_fail(" ".join(make_command), combine_output=True) - -def build_exists(build_dir:str, test_dir:str, test_exe:str=None) -> bool: - """Checks to see if the build directory and associated executables exist. - - Args: - build_dir (str): build directory - test_dir (str): test directory - test_exe (str): test executable - Returns: - bool: whether or not build directory and associated executables exist - """ - - build_path = os.path.abspath(build_dir) - if not os.path.isdir(build_path): - return False - - if not os.path.isdir(os.path.join(build_path, test_dir)): - return False - - if test_exe is not None: - if not os.path.isfile(os.path.join(build_path, test_dir, test_exe)): - return False - - return True diff --git a/testing/cime_setup.md b/testing/cime_setup.md deleted file mode 100644 index 71e6e20703..0000000000 --- a/testing/cime_setup.md +++ /dev/null @@ -1,119 +0,0 @@ -# Instructions for setting up CIME on your personal computer - -## Mac and Linux Users - -### Downloads and Installs - -1. *For Mac Users Only*: Install Apple Developer Tools, if you haven't already -2. Install homebrew ([link here](https://brew.sh/)) -3. Download ESMF ([link here](https://earthsystemmodeling.org/static/releases.html)) - -### PFunit - -Download and install pfunit using the instructions on their ([GitHub page](https://github.com/Goddard-Fortran-Ecosystem/pFUnit)) - -#### Homebrew Installs - -```bash -brew install subversion git bash-completion - -brew install kdiff3 - -brew install gcc - -brew install netcdf - -brew install nco - -brew install ncview - -brew install mpich - -brew install cmake - -brew install markdown - -brew install sloccount - -brew install pyqt --with-python3 - -brew install lapack -``` - -For compilers to find `lapack` you may need to set: - -```bash -export LDFLAGS="-L/usr/local/opt/lapack/lib" -export CPPFLAGS="-I/usr/local/opt/lapack/include" -``` - -For compilers to find `libomp` you may need to set: - -```bash -export LDFLAGS="-L/usr/local/opt/libomp/lib" -export CPPFLAGS="-I/usr/local/opt/libomp/include" -``` - -```bash -brew install highlight - -brew install git-when-merged - -brew install the_silver_searcher -``` - -*For Mac users*: `brew cask install mactex` - -### ESMF Installation - -Set an environment variable `ESMF_DIR` to where you want to install ESMF, for example: `export ESMF_DIR=/Users/afoster/esmf/esmf-8.4.0` - -Next set up some other environment variables: - -```bash -export ESMF_INSTALL_PREFIX=$ESMF_DIR/install_dir -export ESMF_COMM=mpich -export ESMF_COMPILER=gfortranclang -``` - -Inside the download, run: - -```bash -gmake -j4 lib -gmake install -``` - -### CIME Setup - -You'll need to set up a `.cime` directory with some specific files in it. I use Bill Sack's setup: - -```bash -cd -git clone https://github.com/billsacks/mac_cime_configuration.git .cime -git checkout -b "my_configuration" -``` - -You'll need to modify some of the files and folders. - -1. In the top-level `config_machines.xml`, change the `MACH` values to match your machine name (can obtain this via the terminal by typing `hostname`) -2. Rename the `green` directory to your computer's hostname. -3. In the `{hostname}/config_machines.xml`, update relevant fields like `MACH`, `DESC`, `OS`, and `SUPPORTED_BY` -4. In `{hostname}/config_machines.xml`, update the `ESMFMKFILE` based on the path to what you just made above. -5. Rename `gnu_green.cmake` to `gnu_{hostname}.cmake`. -6. Inside `gnu_{hostname}.cmake` update `NETCDF_C_PATH`, `NETCDF_FORTRAN_PATH`, `APPEND LDFLAGS` (both), and `PFUNIT_PATH` to your netcdf and pfunit paths (see below). - -#### Libraries - -The `NETCDF_C_PATH` should be the output of `nc-config --prefix`. -The `NETCDF_FORTRAN_PATH` should be the output of `nf-config --prefix` -Then update the `APPEND LDFLAGS` section as the output from: - -```bash -nc-config --libs -``` - -```bash -nf-config --flibs -``` - -Pfunit should be in a directiory called `installed` in the build directory. diff --git a/testing/functional_tests.cfg b/testing/config/functional.cfg similarity index 65% rename from testing/functional_tests.cfg rename to testing/config/functional.cfg index 3fa93b494e..a3573be6f3 100644 --- a/testing/functional_tests.cfg +++ b/testing/config/functional.cfg @@ -5,21 +5,14 @@ out_file = allometry_out.nc use_param_file = True other_args = [] -[quadratic] -test_dir = fates_math_ftest -test_exe = FATES_math_exe -out_file = quad_out.nc -use_param_file = False +[fuel] +test_dir = fates_fuel_ftest +test_exe = FATES_fuel_exe +out_file = fuel_out.nc +use_param_file = True +datm_file = BONA_datm.nc other_args = [] -#[fuel] -#test_dir = fates_fuel_ftest -#test_exe = FATES_fuel_exe -#out_file = fuel_out.nc -#use_param_file = True -#datm_file = ../testing/test_data/BONA_datm.nc -#other_args = [] - [ros] test_dir = fates_ros_ftest test_exe = FATES_ros_exe @@ -40,3 +33,4 @@ test_exe = FATES_firemort_exe out_file = fire_mortality_out.nc use_param_file = True other_args = [] + diff --git a/testing/unit_tests.cfg b/testing/config/unit.cfg similarity index 77% rename from testing/unit_tests.cfg rename to testing/config/unit.cfg index 942dff05cd..ce73c6fdbb 100644 --- a/testing/unit_tests.cfg +++ b/testing/config/unit.cfg @@ -18,3 +18,10 @@ test_dir = fates_count_cohorts_utest [fire_equations] test_dir = fates_fire_equations_utest + +[quadratic_roots] +test_dir = fates_quadratic_roots_utest + +[great_circle] +test_dir = fates_great_circle_utest + diff --git a/testing/docs/cime_setup.md b/testing/docs/cime_setup.md new file mode 100644 index 0000000000..5ab70c6805 --- /dev/null +++ b/testing/docs/cime_setup.md @@ -0,0 +1,127 @@ +# CIME and Environment Setup Guide + +This guide covers setting up the specialized Fortran environment required to build and +run FATES standalone tests on a local machine (Mac/Linux). + +## System Dependencies (Homebrew) + +FATES requires a specific stack of scientific libraries. If you are on a Mac, +use ([Homebrew](https://brew.sh/)) to install these core dependencies: + +```bash +# Core Build Tools +brew install cmake gcc mpich git subversion + +# NetCDF Stack (Critical for FATES) +brew install netcdf nco ncview + +# Scientific Libraries +brew install lapack +``` + +## Setting up pFunit + +Standalone Unit Tests require pFUnit. Important: It must be built with the same +compiler you intend to use for FATES. + +**1. Clone and Build:** + +```bash +git clone https://github.com/Goddard-Fortran-Ecosystem/pFUnit.git +cd pFUnit +mkdir build && cd build + +# Set your install prefix to a local directory +export PFUNIT_INSTALL=$HOME/software/pfunit +cmake -DSKIP_OPENMP=YES -DCMAKE_INSTALL_PREFIX=$PFUNIT_INSTALL .. +make -j 8 +make tests +make install +``` + +**2. Verify:** Ensure `$PFUNIT_INSTALL/bin/pfunit-config` exists. + +## CIME Configuration + +CIME acts as the "manager" that tells the FATES build system where your libraries are. +We use a local `.cime` directory to store these "Machine Tags." + +### Initialze the .cime directory + +If you don't have a `.cime` folder in your home directory, create one: + +```bash +cd ~ +git clone https://github.com/billsacks/mac_cime_configuration.git .cime +cd .cime +``` + +### Configure `config_machines.xml` + +Find your computer's hostname by typing `hostname -s` in your terminal. Edit +`config_machines.xml` and update the following for your machine entry: + +* ``: Set this to your hostname. +* ``: `Darwin` (for Mac) or `Linux`. +* ``: `gnu`. +* ``: The path to your `ESMF.mod` file (usually found in your ESMF installation directory). See below. + +### Create your Compiler File + +Rename the template to match your machine: + +```bash +cp cmake_macros/gnu_green.cmake cmake_macros/gnu_$(hostname -s).cmake +``` + +Edit this new `.cmake` file and update the paths to match your Homebrew/Manual installs. +You can find these paths using these commands: + +| Variable | Command to find value | +| ----------------------- | -------------------------------------------- | +| **NETCDF_C_PATH** | `nc-config --prefix` | +| **NETCDF_FORTRAN_PATH** | `nf-config --prefix` | +| **LDFLAGS** | `nc-config --libs and nf-config --flibs` | +| **PFUNIT_PATH** | The `$PFUNIT_INSTALL` path used in Section 2 | + +### Environment Variables + +To ensure the FATES building scripts can communicate with CIME, you must define your +machine name and model type in your shell profile (e.g., `~/.zshrc` or `~/.bashrc`). + +Add the following lines to the bottom of your profile: + +```bash +# Tells CIME which machine entry to use from config_machines.xml +export CIME_MACHINE='hostname' + +# Tells CIME to use the CESM build structure logic +export CIME_MODEL='cesm' +``` + +*Note: Replace 'hostname` with the hostname you defined in `config_machines.xml`.* + +After saving, remember to source your profile: `source ~/.zshrc`. + +## ESMF Build + +**1. Download:** [ESMF Releases](https://github.com/esmf-org/esmf/releases) + +**2. Environment Variables:** + +```bash +export ESMF_DIR=$(pwd) # where you will install ESMF +export ESMF_INSTALL_PREFIX=$ESMF_DIR/install_dir +export ESMF_COMM=mpich +export ESMF_COMPILER=gfortranclang # For Mac +``` + +**3. Build:** + +```bash +gmake -j4 lib +gmake install +``` + +**4. Link:** Update the `` path in your .cime/config_machines.xml to point +to `$ESMF_DIR/install_dir/lib/esmf.mk`. diff --git a/testing/testing.yml b/testing/environment.yml similarity index 100% rename from testing/testing.yml rename to testing/environment.yml diff --git a/testing/framework/__init__.py b/testing/framework/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/framework/builder.py b/testing/framework/builder.py new file mode 100644 index 0000000000..123146ab08 --- /dev/null +++ b/testing/framework/builder.py @@ -0,0 +1,254 @@ +""" +Builds/compiles any tests within the FATES repository +""" + +import os +import shutil +import logging +from pathlib import Path +from dataclasses import dataclass, field +from typing import Optional +from framework.utils.path import get_cime_module, path_to_cime + +cime_utils = get_cime_module("CIME.utils") + +# setup logging +logging.basicConfig( + level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# constants +_CIMEROOT = path_to_cime() +_MPI_LIBRARY = "mpi-serial" + + +@dataclass +class BuildConfig: + """Encapsulates all configuration needed for the build.""" + + build_dir: Path + cmake_dir: Path + make_j: int + clean: bool = False + verbose: bool = False + compiler: str = field(init=False) + machine_os: str = field(init=False) + mpilib: str = _MPI_LIBRARY + + # paths resolved during setup + pfunit_path: Optional[str] = None + netcdf_c_path: Optional[str] = None + netcdf_f_path: Optional[str] = None + cmake_args: str = "" + + def __post_init__(self): + """Ensure paths are Path objects""" + self.build_dir = Path(self.build_dir).resolve() + self.cmake_dir = Path(self.cmake_dir).resolve() + + +class TestBuilder: + """Orchestrates the build process.""" + + def __init__(self, config: BuildConfig): + self.config = config + + if self.config.verbose: + logging.getLogger().setLevel(logging.INFO) + else: + logging.getLogger().setLevel(logging.WARNING) + + def build(self): + """Main entry point for building tests.""" + + self._prep_build_dir() + + # gether environment info and cmake args + self._configure_environment() + + # execute build steps + self._run_cmake() + self._run_make() + + def _prep_build_dir(self): + """Creates directory and cleans if requested.""" + if not self.config.build_dir.exists(): + self.config.build_dir.mkdir(parents=True, exist_ok=True) + + if self.config.clean: + logger.info("Cleaning build directory: %s", self.config.build_dir) + self._clean_cmake_files() + + def _clean_cmake_files(self): + """Deletes all files related to cmake build.""" + to_remove = ["CMakeCache.txt", "Macros.cmake", "env_mach_specific.xml"] + to_remove_dirs = ["CMakeFiles"] + + for f in to_remove: + p = self.config.build_dir / f + if p.is_file(): + p.unlink() + + for d in to_remove_dirs: + p = self.config.build_dir / d + if p.is_dir(): + shutil.rmtree(p) + + for p in self.config.build_dir.glob("Depends*"): + p.unlink() + for p in self.config.build_dir.glob(".env_mach_specific*"): + p.unlink() + + def _configure_environment(self): + """Generates cmake arguments and finds library paths + NOTE: this section interacts heavily with CIME global state/objects + """ + cime_machines = get_cime_module("CIME.XML.machines") + cime_configure = get_cime_module("CIME.BuildTools.configure") + cime_env_mach = get_cime_module("CIME.XML.env_mach_specific") + + machobj = cime_machines.Machines() + self.config.compiler = machobj.get_default_compiler() + self.config.machine_os = machobj.get_value("OS") + + cime_configure.configure( + machobj, + str(self.config.build_dir), + ["CMake"], + self.config.compiler, + self.config.mpilib, + True, + "nuopc", + self.config.machine_os, + unit_testing=True, + ) + + # load environment specific to this machine + machspecific = cime_env_mach.EnvMachSpecific( + str(self.config.build_dir), unit_testing=True + ) + fake_case = cime_configure.FakeCase( + self.config.compiler, self.config.mpilib, True, "nuopc", threading=False + ) + machspecific.load_env(fake_case) + + self._generate_cmake_args(machobj) + self._find_libraries() + + def _generate_cmake_args(self, machobj): + args_list = [ + f"-DOS={self.config.machine_os}", + f"-DMACH={machobj.get_machine_name()}", + f"-DCOMPILER={self.config.compiler}", + f"-DDEBUG={'ON'}", + f"-DMPILIB={self.config.mpilib}", + f"-Dcompile_threaded={'OFF'}", + f"-DCASEROOT={self.config.build_dir}", + ] + self.config.cmake_args = " ".join(args_list) + + def _find_libraries(self): + """Locates PFUNIT and NETCDF paths.""" + self.config.pfunit_path = self._query_makefile_var("PFUNIT_PATH") + + if "NETCDF" not in os.environ: + self.config.netcdf_c_path = self._query_makefile_var("NETCDF_C_PATH") + self.config.netcdf_f_path = self._query_makefile_var("NETCDF_FORTRAN_PATH") + + def _query_makefile_var(self, var_name: str) -> Optional[str]: + """Helper to query variables from CIME makefile generation + + Args: + var_name (str): variable to query + + Returns: + Optional[str]: library path + """ + # using the CmakeTmpBuildDir context manager provided by CIME + cime_build = get_cime_module("CIME.build") + with cime_build.CmakeTmpBuildDir( + macroloc=str(self.config.build_dir) + ) as cmaketmp: + all_vars = cmaketmp.get_makefile_vars(cmake_args=self.config.cmake_args) + + # parsing logic + for line in all_vars.splitlines(): + if ":=" in line: + parts = line.split(":=") + if len(parts) == 2: + key, val = parts[0].strip(), parts[1].strip() + if key == var_name: + return val + logger.warning("Library path variable %s not found.", var_name) + return None + + def _run_cmake(self): + """Run cmake""" + cmake_module_dir = _CIMEROOT / "CIME/non_py/src/CMake" + genf90_dir = _CIMEROOT / "CIME/non_py/externals/genf90" + + cmd = [ + "cmake", + "-C", + "Macros.cmake", + str(self.config.cmake_dir), + f"-DCIMEROOT={_CIMEROOT}", + f"-DSRC_ROOT={cime_utils.get_src_root()}", + f"-DCIME_CMAKE_MODULE_DIRECTORY={cmake_module_dir}", + "-DCMAKE_BUILD_TYPE=CESM_DEBUG", + f"-DCMAKE_PREFIX_PATH={self.config.pfunit_path}", + "-DUSE_MPI_SERIAL=ON", + "-DENABLE_GENF90=ON", + f"-DCMAKE_PROGRAM_PATH={genf90_dir}", + ] + if self.config.netcdf_c_path: + cmd.append(f"-DNETCDF_C_PATH={self.config.netcdf_c_path}") + if self.config.netcdf_f_path: + cmd.append(f"-DNETCDF_F_PATH={self.config.netcdf_f_path}") + + # append extra args + cmd.extend(self.config.cmake_args.split(" ")) + + logger.info("Running cmake...") + cime_utils.run_cmd_no_fail( + " ".join(cmd), from_dir=self.config.build_dir, combine_output=True + ) + + def _run_make(self): + """Run make""" + cmd = ["make", "-j", str(self.config.make_j)] + if self.config.verbose: + cmd.append("VERBOSE=1") + + logger.info("Running make...") + cime_utils.run_cmd_no_fail( + " ".join(cmd), from_dir=self.config.build_dir, combine_output=True + ) + + +def build_tests( + build_dir: Path, + cmake_dir: Path, + make_j: int, + clean: bool = False, + verbose: bool = False, +): + """Wrapper function for building tests + + Args: + build_dir (str): build directory path + cmake_directory (str): cmake directory + make_j (int): number of processes to build with + clean (bool, optional): whether or not to clean the build. Defaults to False. + verbose (bool, optional): build with verbose make. Defaults to False. + """ + config = BuildConfig( + build_dir=build_dir, + cmake_dir=cmake_dir, + make_j=make_j, + clean=clean, + verbose=verbose, + ) + builder = TestBuilder(config) + builder.build() diff --git a/testing/framework/fates_test.py b/testing/framework/fates_test.py new file mode 100644 index 0000000000..1b6455f6db --- /dev/null +++ b/testing/framework/fates_test.py @@ -0,0 +1,50 @@ +"""Abstract class for FATES tests""" + +import logging +import subprocess +from pathlib import Path +from abc import ABC, abstractmethod +from framework.utils.path import get_cime_module + +# setup logging +logging.basicConfig( + level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +class FatesTest(ABC): + """Base class for all FATES tests""" + + def __init__(self, name: str, test_dir: str): + self.name = name + self.test_dir = Path(test_dir) + + @abstractmethod + def run(self, build_dir, run_dir, param_file): + """Each category of test will define its own requirements""" + raise NotImplementedError + + def execute_shell(self, cmd: list[str], run_dir: Path) -> str: + """Run executable or test + + Args: + run_dir (Path): path to directory to run test + + Raises: + subprocess.CalledProcessError: error from subprocess call + + Returns: + str: output from subprocess call + """ + cime_utils = get_cime_module("CIME.utils") + + logging.info("--> Running Test: %s", self.name) + + stat, out, _ = cime_utils.run_cmd( + " ".join(cmd), from_dir=str(run_dir), combine_output=True + ) + if stat: + logging.error(out) + raise subprocess.CalledProcessError(stat, cmd, out) + return out diff --git a/testing/framework/functional_test.py b/testing/framework/functional_test.py new file mode 100644 index 0000000000..153d63c51e --- /dev/null +++ b/testing/framework/functional_test.py @@ -0,0 +1,99 @@ +"""Class for FATES functional tests""" + +from abc import abstractmethod +from pathlib import Path +from framework.fates_test import FatesTest +from framework.utils.general import str_to_bool, str_to_list, copy_file + +# constants +_TEST_SUB_DIR = "testing" +_DATA_DIR = Path(__file__).resolve().parents[1] / "tests" / "data" + + +class FunctionalTest(FatesTest): + """Class for running FATES functional tests""" + + def __init__(self, name: str, config: dict): + super().__init__( + name=name, + test_dir=config["test_dir"], + ) + + self.test_exe = config["test_exe"] + self.out_file = config["out_file"] + self.use_param_file = str_to_bool(config["use_param_file"]) + self.other_args = str_to_list(config["other_args"]) + self.plot = True + + datm_val = config.get("datm_file") + self.datm_file = _DATA_DIR / datm_val if datm_val else None + + def find_build(self, build_dir: Path): + """Check to see if the required binary exists and return it + + Args: + build_dir (Path): path build directory + + Raises: + FileNotFoundError: Could not find executable + """ + exe_path = build_dir / _TEST_SUB_DIR / self.test_dir / self.test_exe + if not exe_path.exists(): + raise FileNotFoundError(f"[{self.name}] Executable not found at {exe_path}") + + return exe_path + + def run_command(self, param_file: Path | None = None) -> list[str]: + """Builds run command for executing binary + + Args: + param_file (str | None, optional): input parameter file path. Defaults to None. + + Returns: + list[str]: list of arguments + """ + + cmd = [f"./{self.test_exe}"] + + if self.use_param_file and param_file: + cmd.append(str(param_file)) + + if self.datm_file: + cmd.append(str(self.datm_file)) + + if self.other_args: + cmd.extend(self.other_args) + + return cmd + + def run( + self, + build_dir: Path, + run_dir: Path, + param_file: Path | None = None, + ) -> str: + """Runs a functional test + + Args: + build_dir (Path): path to build directory + run_dir (Path): path to run directory + param_file (Path | None, optional): path to parameter file. Defaults to None. + + Returns: + str: any output from subprocess call + """ + + # find executable + exe_path = self.find_build(build_dir) + + # copy file to run directory + copy_file(exe_path, run_dir) + + # get specific run command and execute + cmd = self.run_command(param_file) + return self.execute_shell(cmd, run_dir) + + @abstractmethod + def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): + """Every functional test must implement its own plotting logic""" + raise NotImplementedError diff --git a/testing/framework/loader.py b/testing/framework/loader.py new file mode 100644 index 0000000000..9f8ea8bd42 --- /dev/null +++ b/testing/framework/loader.py @@ -0,0 +1,83 @@ +"""Loads and discovers FATES test classes""" + +import importlib +import pkgutil +import tests.functional as functional_path +from framework.functional_test import FunctionalTest + + +def discover_tests(): + """Scans the tests/functional directory and imports everything""" + + registry = {} + # walk through all subdirectories in tests/functional + for _, module_name, _ in pkgutil.walk_packages( + functional_path.__path__, functional_path.__name__ + "." + ): + module = importlib.import_module(module_name) + + # look for any class inside the module that is a subclass of FunctionalTest + for _, obj in vars(module).items(): + if ( + isinstance(obj, type) + and issubclass(obj, FunctionalTest) + and obj is not FunctionalTest + ): + # use the 'name' attribute defined in your class to register it + registry[obj.name] = obj + + return registry + + +def get_test_instances(config_dict: dict) -> dict: + """ + Transforms the raw config dictionary into a dictionary of + live test objects. + """ + # trigger test discovery + discover_tests() + + # map subclasses + available_classes = {} + for base in [FunctionalTest]: + for cls in base.__subclasses__(): + # ensure we only pick concrete classes, not the base classes themselves + if hasattr(cls, "name"): + available_classes[cls.name] = cls + + # instatiate test classes + test_instances = {} + for name, attributes in config_dict.items(): + if name in available_classes: + # pass attributes dict to the constructor + test_instances[name] = available_classes[name](name, attributes) + else: + print( + f"WARNING: No Python class found for test '{name}'. " + f"Check that 'name = \"{name}\"' is defined in your test class." + ) + + return test_instances + +def validate_test_configs(test_instances: dict): + """Checks that all external dependencies (like DATM files) exist before running any + tests. + + Args: + test_instances (dict): dictionary of test class instances + + Raises: + FileNotFoundError: Can't find file + """ + + missing_assets = [] + for name, test in test_instances.items(): + # only check tests that actually have a driver file defined + if test.datm_file: + if not test.datm_file.exists(): + missing_assets.append(f"[{name}] Driver missing: {test.datm_file}") + + if missing_assets: + error_msg = "\n".join(missing_assets) + raise FileNotFoundError(f"Pre-run validation failed:\n{error_msg}") + \ No newline at end of file diff --git a/testing/framework/test_generator.py b/testing/framework/test_generator.py new file mode 100644 index 0000000000..418de8129d --- /dev/null +++ b/testing/framework/test_generator.py @@ -0,0 +1,409 @@ +"""Module for generating empty test classes""" + +import re +import logging +from pathlib import Path +from abc import ABC, abstractmethod +from framework.utils.test_layout_protocol import ( + TestLayoutProtocol, + FunctionalLayoutProtocol, + UnitTestLayout, + FunctionalTestLayout, +) +from framework.utils.general import snake_to_camel + +# configure logger +logger = logging.getLogger(__name__) + +# root directory of this test package +_TEST_ROOT = Path(__file__).resolve().parents[1] + + +class GenerateTestClass(ABC): + """Abstract base class for creating boilerplate for FATES tests""" + + def __init__( + self, + test_name: str, + sub_dir: str | None = None, + layout: TestLayoutProtocol | None = None, + verbose: bool = False, + ): + self.test_name = re.sub(r"_test$", "", test_name.lower()) + self.sub_dir = sub_dir + self.layout = layout or self.default_layout() + self.verbose = verbose + + # values derived from test_name + self.module_name = snake_to_camel(self.test_name) + + # set logging level + if self.verbose: + logging.getLogger().setLevel(logging.INFO) + else: + logging.getLogger().setLevel(logging.WARNING) + + # ---------------------------- + # Computed properties + # ---------------------------- + + @property + def test_dir(self) -> Path: + """Returns the test directory""" + return self.layout.test_dir(self.test_name, self.sub_dir) + + @property + def build_dir(self) -> str: + """Returns the build name""" + return self.layout.build_name(self.test_name) + + @property + def file_name(self) -> str: + """Returns the program filename""" + return self.layout.filename(self.module_name) + + @property + def config_file(self) -> Path: + """Returns a config file path name""" + return self.layout.config_file + + @property + def cmake_template(self) -> Path: + """Returns a cmake template file path""" + return self.layout.cmake_template + + @property + def program_template(self) -> Path: + """Returns a program template file path""" + return self.layout.program_template + + @property + def templates_dir(self) -> Path: + """Returns path to templates directory""" + return self.layout.templates_dir + + @property + def main_cmakelists(self) -> Path: + """Returns path to main testing CMakeLists.txt""" + return self.layout.main_cmakelists + + # ---------------------------- + # Abstract interface contract + # ---------------------------- + + @abstractmethod + def default_layout(self) -> TestLayoutProtocol: + """Returns the default layout for this test type""" + raise NotImplementedError + + @property + @abstractmethod + def section_header(self) -> str: + """String that marks where this test's add_subdirectory should go in the + testing/CMakeLists.txt""" + raise NotImplementedError + + @abstractmethod + def append_config_lines(self): + """Appends lines to the config file for the test (must be implemented by + subclasses).""" + raise NotImplementedError + + @abstractmethod + def create_cmake_file(self): + """Creates the new CMakeLists.txt file for the test (must be implemented by + subclasses).""" + raise NotImplementedError + + @abstractmethod + def create_template_program(self): + """Creates a template program file (must be implemented by subclasses).""" + raise NotImplementedError + + # -------------------------- + # Concrete helpers + # -------------------------- + + def create_test_directory(self): + """Creates a new test directory + + Raises: + FileExistsError: Directory already exists + """ + if self.test_dir.exists(): + raise FileExistsError(f"Directory '{self.test_dir}' already exists.") + self.test_dir.mkdir(parents=True, exist_ok=True) + if self.verbose: + logger.info("Created directory: %d", self.test_dir) + + def update_main_cmake(self): + """Updates the main testing/CMakeLists.txt file by adding a new test directory in + the correct section. + + Raises: + FileNotFoundError: testing/CMakeLists.txt file not found + """ + if not self.main_cmakelists.exists(): + raise FileNotFoundError(f"{self.main_cmakelists} not found.") + + lines = self.main_cmakelists.read_text(encoding="utf-8").splitlines() + + test_dir_rel = self.test_dir.relative_to(_TEST_ROOT) + new_entry = f"add_subdirectory({test_dir_rel} {self.build_dir})" + + # avoid duplicates + if any(new_entry in line for line in lines): + logger.warning("Test already exists in CMakeLists. Skipping.") + return + + updated_lines = [] + found_section = False + inserted = False + + for line in lines: + # if we hit the next section header while in our target section, + # or if we are at the very end of the file and haven't inserted yet + is_next_header = line.startswith("##") and found_section + + if is_next_header and not inserted: + # if there's a blank line before the next header, + # we want to insert BEFORE that blank line. + if len(updated_lines) > 0 and updated_lines[-1].strip() == "": + updated_lines.insert(-1, new_entry.strip()) + else: + updated_lines.append(new_entry.strip()) + inserted = True + + updated_lines.append(line) + + if line.strip() == self.section_header: + found_section = True + + # if we reached the end of the file and haven't inserted (last section) + if found_section and not inserted: + # check for trailing empty lines to keep it clean + while updated_lines and updated_lines[-1].strip() == "": + updated_lines.pop() + updated_lines.append(new_entry.strip()) + + # write modified contents back to file + self.main_cmakelists.write_text("\n".join(updated_lines) + "\n") + logger.info("Updated %s to include %s.", self.main_cmakelists, self.test_dir) + + def update_config_file(self): + """Loads config file lines""" + + if not self.config_file.exists(): + raise FileNotFoundError(f"{self.config_file} not found.") + + content = self.config_file.read_text(encoding="utf-8").rstrip() + + # get the new lines from the subclass + new_lines = self.append_config_lines() + + # add a double new line before the new block + new_block = "\n" + "".join(new_lines) + + self.config_file.write_text(content + new_block + "\n") + logger.info("Updated %s.", self.config_file) + + def load_template(self, template_path: Path) -> str: + """Load a template file from the templates directory + + Args: + template_path (str): name of template + + Raises: + FileNotFoundError: Template file not found + + Returns: + Path: template path + """ + if not template_path.exists(): + raise FileNotFoundError(f"Template '{template_path}' not found.") + return template_path.read_text(encoding="utf-8") + + def render_template(self, template_path: Path, out_path: Path, **kwargs): + """Write text to a file based on an input template and keyword arguments + + Args: + template_path (Path): path of template + out_path (Path): path to file to write + """ + template = self.load_template(template_path) + out_path.write_text(template.format(**kwargs), encoding="utf-8") + + # -------------------------- + # Template Method + # -------------------------- + + def setup_test(self): + """Template method for setting up a test""" + self.create_test_directory() + self.create_cmake_file() + self.update_main_cmake() + self.update_config_file() + self.create_template_program() + + +# --------------------------------------------------------- +# Concrete Unit Test Generator +# --------------------------------------------------------- +class GenerateUnitTest(GenerateTestClass): + """Concrete generator for unit test boilerplate""" + + layout: TestLayoutProtocol + + def default_layout(self) -> TestLayoutProtocol: + """Sets the default layout for file naming""" + return UnitTestLayout(_TEST_ROOT) + + @property + def section_header(self) -> str: + """Section header for the CMakeLists.txt file""" + return "## Unit tests" + + def create_cmake_file(self): + """Create the test's CMakeLists.txt.""" + cmake_path = self.test_dir / "CMakeLists.txt" + self.render_template( + self.cmake_template, + cmake_path, + file_name=self.file_name, + module_name=self.module_name, + ) + logger.info("Created %s.", cmake_path) + + def append_config_lines(self) -> list[str]: + """Appends this test's config section to the config file contents + + Args: + lines (list[str]): lines to append to + + Returns: + list[str]: updated lines + """ + lines = ["\n", f"[{self.test_name}]\n", f"test_dir = {self.build_dir}\n"] + return lines + + def create_template_program(self): + """Generate the .pf unit test boilerplate file.""" + pfunit_path = self.test_dir / self.file_name + self.render_template( + self.program_template, pfunit_path, module_name=self.module_name + ) + logger.info("Added template test files in %s.", self.test_dir) + + +# --------------------------------------------------------- +# Concrete Functional Test Generator +# --------------------------------------------------------- +class GenerateFunctionalTest(GenerateTestClass): + """Concrete generator for functional test boilerplate""" + + layout: FunctionalLayoutProtocol + + def default_layout(self) -> FunctionalLayoutProtocol: + return FunctionalTestLayout(_TEST_ROOT) + + @property + def section_header(self) -> str: + return "## Functional tests" + + @property + def class_template(self) -> Path: + """Returns a class template path""" + return self.layout.class_template + + @property + def executable_name(self) -> str: + """Defines the name of the executable""" + return self.layout.executable_name(self.module_name) + + def create_cmake_file(self): + """Creates the test's CMakeLists.txt file for the test""" + cmake_path = self.test_dir / "CMakeLists.txt" + self.render_template( + self.cmake_template, + cmake_path, + file_name=self.file_name, + executable_name=self.executable_name, + ) + logger.info("Created %s.", cmake_path) + + def append_config_lines(self) -> list[str]: + """Append this functional test's config section to the config file + + Args: + lines (list[str]): lines to append to + + Returns: + str: updated lines + """ + lines = [ + "\n", + f"[{self.test_name}]\n", + f"test_dir = {self.build_dir}\n", + f"test_exe = {self.executable_name}\n", + "out_file = None\n", + "use_param_file = False\n", + "other_args = []\n", + ] + return lines + + def create_template_program(self): + """Generate the functional test Fortran program and Python class. + + Raises: + FileNotFoundError: Can't find the class file + """ + + # create a fortran template file + test_path = self.test_dir / self.file_name + self.render_template( + self.program_template, test_path, module_name=self.module_name + ) + + # create the python template file + class_path = self.test_dir / f"{self.test_name}_test.py" + self.render_template( + self.class_template, class_path, module_name=self.module_name, + test_name=self.test_name + ) + logger.info("Added template test files in %s.", self.test_dir) + + # create the __init__.py + init_path = self.test_dir / "__init__.py" + init_path.touch() + logger.info("Added __init__.py file in %s.", self.test_dir) + + +# --------------------------------------------------------- +# Factory +# --------------------------------------------------------- +TEST_TYPE_REGISTRY = {"unit": GenerateUnitTest, "functional": GenerateFunctionalTest} + + +def generate_test( + test_type: str, test_name: str, sub_dir: str | None = None, verbose: bool = True +) -> GenerateTestClass: + """Generates all boilerplate associated with a new test + + Args: + test_type (str): test type ('unit' or 'functional') + test_name (str): name of test + sub_dir (str, optional): optional subdirectory to place. Defaults to None. + verbose (bool, optional): whether or not to print log messages to screen + + Raises: + ValueError: incorrect test type + + Returns: + GenerateTestClass: test generator instance + """ + cls = TEST_TYPE_REGISTRY.get(test_type) + if cls is None: + raise ValueError( + f"Invalid test_type '{test_type}'. Must be one of {list(TEST_TYPE_REGISTRY.keys())}" + ) + return cls(test_name, sub_dir, layout=None, verbose=verbose) diff --git a/testing/framework/unit_test.py b/testing/framework/unit_test.py new file mode 100644 index 0000000000..eac2b01e80 --- /dev/null +++ b/testing/framework/unit_test.py @@ -0,0 +1,30 @@ +"""Class for FATES unit tests""" + +from pathlib import Path +from framework.fates_test import FatesTest + + +class UnitTest(FatesTest): + """Class for running FATES unit tests via CTest""" + + def __init__(self, name: str, config: dict): + super().__init__(name=name, test_dir=config["test_dir"]) + # unit tests don't produce plots + self.plot = False + + def run(self, build_dir: Path, run_dir: None=None, param_file: None=None): + """ + Executes a unit test. Note: Unit tests only require the build_dir + because they run inside the build tree using ctest. + """ + # locate the test directory in the build tree + # unit tests typically run from: build/testing/ + test_path = build_dir / "testing" / self.test_dir + + if not test_path.exists(): + raise FileNotFoundError(f"Unit test path not found: {test_path}") + + # build the CTest command + # --output-on-failure ensures we see the log if it crashes + cmd = ["ctest", "--output-on-failure"] + return self.execute_shell(cmd, test_path) diff --git a/testing/framework/utils/__init__.py b/testing/framework/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/framework/utils/env_check.py b/testing/framework/utils/env_check.py new file mode 100644 index 0000000000..e98e8a3f39 --- /dev/null +++ b/testing/framework/utils/env_check.py @@ -0,0 +1,18 @@ +"""Checks python environment""" + +import sys + +# minimum python version +MIN_PYTHON = (3, 12) + +def validate(): + """Checks python version and exits with error if not satisfied + """ + if sys.version_info < MIN_PYTHON: + sys.exit( + f"[-] Failure: Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ is required.\n" + f"[-] You are currently running {sys.version}." + ) + +# run on import +validate() diff --git a/testing/utils.py b/testing/framework/utils/general.py similarity index 63% rename from testing/utils.py rename to testing/framework/utils/general.py index 4cf05559aa..3c2c405ea0 100644 --- a/testing/utils.py +++ b/testing/framework/utils/general.py @@ -3,17 +3,13 @@ """ import math -import os import re import configparser import argparse -from path_utils import add_cime_lib_to_path +from pathlib import Path +from framework.utils.path import get_cime_module -add_cime_lib_to_path() - -from CIME.utils import ( - run_cmd_no_fail, -) # pylint: disable=wrong-import-position,import-error,wrong-import-order +cime_utils = get_cime_module("CIME.utils") def round_up(num: float, decimals: int = 0) -> float: @@ -44,58 +40,41 @@ def truncate(num: float, decimals: int = 0) -> float: return int(num * multiplier) / multiplier -def create_nc_from_cdl(cdl_path: str, run_dir: str) -> str: - """Creates a netcdf file from a cdl file and return path to new file. +def create_nc_from_cdl(cdl_path: Path, out_dir: Path) -> str: + """Creates a netcdf file from a cdl file and writes to specified directory Args: - cdl_path (str): full path to desired cdl file - run_dir (str): where the file should be written to + cdl_path (Path): path to cdl file + out_dir (Path): directory to write to + + Returns: + str: output from subprocess command """ - file_basename = os.path.basename(cdl_path).split(".")[-2] + + file_basename = cdl_path.name.split(".")[-2] file_nc_name = f"{file_basename}.nc" - file_gen_command = ["ncgen -o", os.path.join(run_dir, file_nc_name), cdl_path] - out = run_cmd_no_fail(" ".join(file_gen_command), combine_output=True) + file_gen_command = ["ncgen -o", str((out_dir / file_nc_name)), str(cdl_path)] + out = cime_utils.run_cmd_no_fail(" ".join(file_gen_command), combine_output=True) print(out) return file_nc_name -def copy_file(file_path: str, directory) -> str: - """Copies a file file to a desired directory and returns path to file. +def copy_file(file_path: Path, directory: Path) -> Path: + """Copies a file file to a desired directory and returns new path to file. Args: - file_path (str): full path to file - dir (str): where the file should be copied to + file_path (Path): full path to file + dir (Path): where the file should be copied to """ - file_basename = os.path.basename(file_path) + file_basename = file_path.name - file_copy_command = ["cp", os.path.abspath(file_path), os.path.abspath(directory)] - out = run_cmd_no_fail(" ".join(file_copy_command), combine_output=True) + file_copy_command = ["cp", str(file_path.resolve()), str(directory.resolve())] + out = cime_utils.run_cmd_no_fail(" ".join(file_copy_command), combine_output=True) print(out) - return file_basename - - -def get_abspath_from_config_file(relative_path, config_file): - """ - Gets the absolute path of a file relative to the config file where it was defined. - - Args: - relative_path: The path to the target file, relative to the base file. - config_file: The path to the config file. - - Returns: - The absolute path of the target file. - """ - - # Do nothing if it's already a absolute path - if os.path.isabs(relative_path): - return relative_path - - base_dir = os.path.dirname(os.path.abspath(config_file)) - absolute_path = os.path.abspath(os.path.join(base_dir, relative_path)) - return absolute_path + return directory / file_basename def config_to_dict(config_file: str) -> dict: @@ -108,9 +87,6 @@ def config_to_dict(config_file: str) -> dict: dictionary: dictionary of config file """ - # Define list of config file options that we expect to be paths - options_that_are_paths = ["datm_file"] - config = configparser.ConfigParser() config.read(config_file) @@ -119,12 +95,6 @@ def config_to_dict(config_file: str) -> dict: dictionary[section] = {} for option in config.options(section): value = config.get(section, option) - - # If the option is one that we expect to be a path, ensure it's an absolute path. - if option in options_that_are_paths: - value = get_abspath_from_config_file(value, config_file) - - # Save value to dictionary dictionary[section][option] = value return dictionary @@ -179,6 +149,7 @@ def str_to_bool(val: str) -> bool: return False raise ValueError(f"invalid truth value {val}") + def snake_to_camel(snake_str: str) -> str: """Convert a snake_case string to CamelCase. @@ -190,6 +161,7 @@ def snake_to_camel(snake_str: str) -> str: """ return "".join(word.capitalize() for word in snake_str.split("_")) + def camel_to_snake(camel_str: str) -> str: """Convert a CamelCase string to snake_case. @@ -199,9 +171,10 @@ def camel_to_snake(camel_str: str) -> str: Returns: str: output snake_case """ - return re.sub(r'(? list: +def str_to_list(val: str) -> list[str] | None: """converts string representation of list to actual list Args: @@ -212,6 +185,6 @@ def str_to_list(val: str) -> list: """ if val in ("", "[]"): # empty list - return [] + return None res = val.strip("][").split(",") return [n.strip() for n in res] diff --git a/testing/framework/utils/path.py b/testing/framework/utils/path.py new file mode 100644 index 0000000000..3aa01ce8bc --- /dev/null +++ b/testing/framework/utils/path.py @@ -0,0 +1,89 @@ +"""Utility functions related to getting paths to various important places""" + +import sys +import importlib +from pathlib import Path + +# path to the root directory of FATES, based on the path of this file +_FATES_ROOT = Path(__file__).resolve().parents[3] + + +def add_cime_lib_to_path(): + """Adds the CIME python library to the python path, to allow importing + modules from that library + + Returns: + str: path to top-level cime directory + """ + cime_path = path_to_cime() + prepend_to_python_path(str(cime_path)) + + cime_lib_path = (cime_path / "CIME" / "Tools").resolve() + prepend_to_python_path(str(cime_lib_path)) + + # try to import CIME to test + try: + import CIME + except ImportError as e: + raise ImportError( + f"Could not find CIME at {cime_lib_path}. " + f"Ensure git_fleximod has been run. Error: {e}" + ) from e + + +def path_to_cime() -> Path: + """Returns the path to cime, if it can be found + + Raises: + RuntimeError: can't find path to cime + + Returns: + str: full path to cime + """ + cime_path = (path_to_fates_root() / "../../cime").resolve() + if cime_path.is_dir(): + return cime_path + raise RuntimeError("Cannot find cime.") + + +def path_to_fates_root() -> Path: + """Returns Returns the path to the root directory of FATES + + Returns: + str: path to the root directory of FATES + """ + return _FATES_ROOT + + +def prepend_to_python_path(path: str): + """Adds the given path to python's sys.path if not already there + Path is added near the beginning, so that it takes precedence over existing + entries in path. + + Args: + path (str): input path + """ + if not path in sys.path: + # insert at location 1 rather than 0, because 0 is special + sys.path.insert(1, path) + + +def get_cime_module(module_path: str): + """Safely retrieves a CIME module. + Usage: utils = get_cime_module('CIME.utils') + + Args: + module_path (str): module path string + + Raises: + ImportError: Failed to load CIME module + + Returns: + ModuleType: module + """ + + add_cime_lib_to_path() + try: + return importlib.import_module(module_path) + except ImportError as e: + raise ImportError(f"Failed to load {module_path} from CIME: {e}") from e diff --git a/testing/utils_plotting.py b/testing/framework/utils/plotting.py similarity index 98% rename from testing/utils_plotting.py rename to testing/framework/utils/plotting.py index df38b80617..a6903edb1c 100644 --- a/testing/utils_plotting.py +++ b/testing/framework/utils/plotting.py @@ -1,5 +1,4 @@ -"""Utility functions for plotting -""" +"""Utility functions for plotting""" import math import matplotlib.pyplot as plt diff --git a/testing/framework/utils/test_layout_protocol.py b/testing/framework/utils/test_layout_protocol.py new file mode 100644 index 0000000000..f0a9e92aa0 --- /dev/null +++ b/testing/framework/utils/test_layout_protocol.py @@ -0,0 +1,203 @@ +"""Classes to define test layout and directory policy patterns for FATES tests""" + +from pathlib import Path +from abc import ABC, abstractmethod + + +class TestLayoutProtocol(ABC): + """Abstract interface for test layout classes.""" + + def __init__(self, root: Path): + self.root = root + + @property + def templates_dir(self) -> Path: + """Defines the templates directory""" + return self.root / "templates" + + @property + def main_cmakelists(self) -> Path: + """Defines the main testing/CMakeLists.txt directory""" + return self.root / "CMakeLists.txt" + + @property + @abstractmethod + def base_dir(self) -> Path: + """Base directory for this type of test""" + raise NotImplementedError + + @property + @abstractmethod + def config_file(self) -> Path: + """Defines the config file path for a type of test""" + raise NotImplementedError + + @property + @abstractmethod + def program_template(self) -> Path: + """Defines the program file template file path""" + raise NotImplementedError + + @property + @abstractmethod + def cmake_template(self) -> Path: + """Defines the CMakeLists.txt template file path""" + raise NotImplementedError + + @abstractmethod + def test_dir(self, test_name: str, sub_dir: str | None = None) -> Path: + """Defines a test directory path""" + raise NotImplementedError + + @abstractmethod + def build_name(self, test_name: str) -> str: + """Defines a build directory name""" + raise NotImplementedError + + @abstractmethod + def filename(self, module_name: str) -> str: + """Defines a file name""" + raise NotImplementedError + + +class FunctionalLayoutProtocol(TestLayoutProtocol, ABC): + """Extra properties for functional test layouts.""" + + @property + @abstractmethod + def class_template(self) -> Path: + """Defines the class template""" + raise NotImplementedError + + @abstractmethod + def executable_name(self, module_name) -> str: + """Defines the name of the executable""" + raise NotImplementedError + + +class UnitTestLayout(TestLayoutProtocol): + """Class for defining directories and filenames for unit tests""" + + @property + def base_dir(self) -> Path: + return self.root / "tests" / "unit" + + @property + def config_file(self) -> Path: + return self.root / "config" / "unit.cfg" + + @property + def cmake_template(self) -> Path: + return self.templates_dir / "cmake_utest_template.txt" + + @property + def program_template(self) -> Path: + return self.templates_dir / "pfunit_template.txt" + + def test_dir(self, test_name: str, sub_dir: str | None = None) -> Path: + """Defines a test directory path + + Args: + test_name (str): name of test + sub_dir (str | None, optional): subdirectory name. Defaults to None. + + Returns: + Path: full path to directory + """ + name = f"{test_name}_test" + return self.base_dir / sub_dir / name if sub_dir else self.base_dir / name + + def build_name(self, test_name: str) -> str: + """Defines a build directory name + + Args: + test_name (str): name of test + + Returns: + str: build directory name + """ + return f"fates_{test_name}_utest" + + def filename(self, module_name: str) -> str: + """Defines a test file name + + Args: + module_name (str): name of module + + Returns: + str: file name + """ + return f"test_{module_name}.pf" + + +class FunctionalTestLayout(FunctionalLayoutProtocol): + """Class for defining directories and filenames for unit tests""" + + @property + def base_dir(self) -> Path: + return self.root / "tests" / "functional" + + @property + def config_file(self) -> Path: + return self.root / "config" / "functional.cfg" + + @property + def cmake_template(self) -> Path: + return self.templates_dir / "cmake_ftest_template.txt" + + @property + def program_template(self) -> Path: + return self.templates_dir / "fortran_test_template.txt" + + @property + def class_template(self) -> Path: + return self.templates_dir / "test_class_template.txt" + + def test_dir(self, test_name: str, sub_dir: str | None = None) -> Path: + """Defines a test directory path + + Args: + test_name (str): name of test + sub_dir (str | None, optional): subdirectory name. Defaults to None. + + Returns: + Path: full path to directory + """ + return ( + self.base_dir / sub_dir / test_name + if sub_dir + else self.base_dir / test_name + ) + + def build_name(self, test_name: str) -> str: + """Defines a build directory name + + Args: + test_name (str): name of test + + Returns: + str: build directory name + """ + return f"fates_{test_name}_ftest" + + def filename(self, module_name: str) -> str: + """Defines a test file name + + Args: + module_name (str): name of module + + Returns: + str: file name + """ + return f"test_{module_name}.F90" + + def executable_name(self, module_name: str) -> str: + """Defines the name of the executable + + Args: + module_name (str): name of module + + Returns: + str: file name + """ + return f"{module_name}_exe" diff --git a/testing/functional_class.py b/testing/functional_class.py deleted file mode 100644 index 6ca085ef2c..0000000000 --- a/testing/functional_class.py +++ /dev/null @@ -1,20 +0,0 @@ -from abc import ABC, abstractmethod -from utils import str_to_bool, str_to_list - -class FunctionalTest(ABC): - """Class for running FATES functional tests""" - - def __init__(self, name:str, test_dir:str, test_exe:str, out_file:str, - use_param_file:str, other_args:str): - self.name = name - self.test_dir = test_dir - self.test_exe = test_exe - self.out_file = out_file - self.use_param_file = str_to_bool(use_param_file) - self.other_args = str_to_list(other_args) - self.plot = False - - @abstractmethod - def plot_output(self, run_dir:str, save_figs:bool, plot_dir:str): - pass - \ No newline at end of file diff --git a/testing/functional_class_with_drivers.py b/testing/functional_class_with_drivers.py deleted file mode 100644 index 8a0b3ae0b3..0000000000 --- a/testing/functional_class_with_drivers.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -from functional_class import FunctionalTest - - -class FunctionalTestWithDrivers(FunctionalTest): - """Class for running FATES functional tests with driver files""" - - def __init__(self, datm_file: str, *args): - - # Check that datm exists and save its absolute path - self.datm_file = os.path.abspath(datm_file) - if not os.path.exists(self.datm_file): - raise FileNotFoundError(f"datm_file not found: '{self.datm_file}'") - - super().__init__(*args) diff --git a/testing/functional_testing/math_utils/CMakeLists.txt b/testing/functional_testing/math_utils/CMakeLists.txt deleted file mode 100644 index e23eac3dcf..0000000000 --- a/testing/functional_testing/math_utils/CMakeLists.txt +++ /dev/null @@ -1,10 +0,0 @@ -set(math_sources FatesTestMathUtils.F90) - -link_directories(${PFUNIT_TOP_DIR}/lib) - -add_executable(FATES_math_exe ${math_sources}) - -target_link_libraries(FATES_math_exe - fates - csm_share - funit) \ No newline at end of file diff --git a/testing/functional_testing/math_utils/FatesTestMathUtils.F90 b/testing/functional_testing/math_utils/FatesTestMathUtils.F90 deleted file mode 100644 index 1de134d1be..0000000000 --- a/testing/functional_testing/math_utils/FatesTestMathUtils.F90 +++ /dev/null @@ -1,139 +0,0 @@ -program FatesTestQuadSolvers - - use FatesConstantsMod, only : r8 => fates_r8 - use FatesUtilsMod, only : QuadraticRootsNSWC - use FatesUtilsMod, only : GetNeighborDistance - - implicit none - - ! CONSTANTS: - integer, parameter :: n = 4 ! number of points to test - character(len=*), parameter :: out_file = 'quad_out.nc' ! output file - - ! LOCALS: - integer :: i ! looping index - real(r8) :: a(n), b(n), c(n) ! coefficients for quadratic solvers - real(r8) :: root1(n) ! real part of first root of quadratic solver - real(r8) :: root2(n) ! real part of second root of quadratic solver - logical :: err ! error - - interface - - subroutine WriteQuadData(out_file, n, a, b, c, root1, root2) - - use FatesUnitTestIOMod, only : OpenNCFile, RegisterNCDims, CloseNCFile - use FatesUnitTestIOMod, only : WriteVar, RegisterVar - use FatesUnitTestIOMod, only : type_double, type_int - use FatesConstantsMod, only : r8 => fates_r8 - implicit none - - character(len=*), intent(in) :: out_file - integer, intent(in) :: n - real(r8), intent(in) :: a(:) - real(r8), intent(in) :: b(:) - real(r8), intent(in) :: c(:) - real(r8), intent(in) :: root1(:) - real(r8), intent(in) :: root2(:) - end subroutine WriteQuadData - - end interface - - a = (/1.0_r8, 1.0_r8, 5.0_r8, 1.5_r8/) - b = (/-2.0_r8, 7.0_r8, 10.0_r8, 3.2_r8/) - c = (/1.0_r8, 12.0_r8, 3.0_r8, 1.1_r8/) - - do i = 1, n - call QuadraticRootsNSWC(a(i), b(i), c(i), root1(i), root2(i), err) - end do - - call WriteQuadData(out_file, n, a, b, c, root1, root2) - -end program FatesTestQuadSolvers - -! ---------------------------------------------------------------------------------------- - -subroutine WriteQuadData(out_file, n, a, b, c, root1, root2) - ! - ! DESCRIPTION: - ! Writes out data from the quadratic solver test - ! - use FatesConstantsMod, only : r8 => fates_r8 - use FatesUnitTestIOMod, only : OpenNCFile, RegisterNCDims, CloseNCFile - use FatesUnitTestIOMod, only : WriteVar - use FatesUnitTestIOMod, only : RegisterVar - use FatesUnitTestIOMod, only : EndNCDef - use FatesUnitTestIOMod, only : type_double, type_int - - implicit none - - ! ARGUMENTS: - character(len=*), intent(in) :: out_file ! output file name - integer, intent(in) :: n ! number of points to write out - real(r8), intent(in) :: a(:) ! coefficient a - real(r8), intent(in) :: b(:) ! coefficient b - real(r8), intent(in) :: c(:) ! coefficient c - real(r8), intent(in) :: root1(:) ! root1 from quadratic solver - real(r8), intent(in) :: root2(:) ! root2 from quadratic solver - - ! LOCALS: - integer :: n_index(n) ! array of pft indices to write out - integer :: i ! looping index - integer :: ncid ! netcdf file id - character(len=8) :: dim_names(1) ! dimension names - integer :: dimIDs(1) ! dimension IDs - integer :: aID, bID, cID - integer :: root1ID, root2ID - - ! make index - do i = 1, n - n_index(i) = i - end do - - ! dimension names - dim_names = [character(len=12) :: 'n'] - - ! open file - call OpenNCFile(trim(out_file), ncid, 'readwrite') - - ! register dimensions - call RegisterNCDims(ncid, dim_names, (/n/), 1, dimIDs) - - ! register a - call RegisterVar(ncid, 'a', dimIDs(1:1), type_double, & - [character(len=20) :: 'units', 'long_name'], & - [character(len=150) :: '', 'coefficient a'], 2, aID) - - ! register b - call RegisterVar(ncid, 'b', dimIDs(1:1), type_double, & - [character(len=20) :: 'units', 'long_name'], & - [character(len=150) :: '', 'coefficient b'], 2, bID) - - ! register c - call RegisterVar(ncid, 'c', dimIDs(1:1), type_double, & - [character(len=20) :: 'units', 'long_name'], & - [character(len=150) :: '', 'coefficient c'], 2, cID) - - ! register root1 - call RegisterVar(ncid, 'root1', dimIDs(1:1), type_double, & - [character(len=20) :: 'units', 'long_name'], & - [character(len=150) :: '', 'root 1'], 2, root1ID) - - ! register root2 - call RegisterVar(ncid, 'root2', dimIDs(1:1), type_double, & - [character(len=20) :: 'units', 'long_name'], & - [character(len=150) :: '', 'root 2'], 2, root2ID) - - ! finish defining variables - call EndNCDef(ncid) - - ! write out data - call WriteVar(ncid, aID, a(:)) - call WriteVar(ncid, bID, b(:)) - call WriteVar(ncid, cID, c(:)) - call WriteVar(ncid, root1ID, root1(:)) - call WriteVar(ncid, root2ID, root2(:)) - - ! close the file - call CloseNCFile(ncid) - -end subroutine WriteQuadData diff --git a/testing/functional_testing/math_utils/math_utils_test.py b/testing/functional_testing/math_utils/math_utils_test.py deleted file mode 100644 index df6d7fb173..0000000000 --- a/testing/functional_testing/math_utils/math_utils_test.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Concrete class for running the quadtratic functional tests for FATES. -""" -import os -import xarray as xr -import numpy as np -import matplotlib.pyplot as plt -from utils_plotting import get_color_palette -from functional_class import FunctionalTest - - -class QuadraticTest(FunctionalTest): - """Quadratic test class - """ - - name = "quadratic" - - def __init__(self, test_dict): - super().__init__( - QuadraticTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True - - def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): - """Reads in and plots quadratic formula test output - - Args: - run_dir (str): run directory - out_file (str): output file - save_figs (bool): whether or not to save the figures - plot_dir (str): plot directory - """ - - # read in quadratic data - quadratic_dat = xr.open_dataset(os.path.join(run_dir, self.out_file)) - - # plot output - self.plot_quad_and_roots( - quadratic_dat.a.values, - quadratic_dat.b.values, - quadratic_dat.c.values, - quadratic_dat.root1.values, - quadratic_dat.root2.values, - ) - if save_figs: - fig_name = os.path.join(plot_dir, "quadratic_test.png") - plt.savefig(fig_name) - - @staticmethod - def plot_quad_and_roots(a_coeff, b_coeff, c_coeff, root1, root2): - """Plots a set of quadratic formulas (ax**2 + bx + c) and their two roots - - Args: - a_coeff (float array): set of a coefficients - b_coeff (float array): set of b coefficients - c_coeff (float array): set of b coefficients - root1 (float array): set of first real roots - root2 (float array): set of second real roots - """ - num_equations = len(a_coeff) - - plt.figure(figsize=(7, 5)) - x_vals = np.linspace(-10.0, 10.0, num=20) - - colors = get_color_palette(num_equations) - for i in range(num_equations): - y_vals = a_coeff[i] * x_vals**2 + b_coeff[i] * x_vals + c_coeff[i] - plt.plot(x_vals, y_vals, lw=2, color=colors[i]) - plt.scatter(root1[i], root2[i], color=colors[i], s=50) - plt.axhline(y=0.0, color="k", linestyle="dotted") diff --git a/testing/functional_testing/patch/patch_test.py b/testing/functional_testing/patch/patch_test.py deleted file mode 100644 index 0610fd521e..0000000000 --- a/testing/functional_testing/patch/patch_test.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Concrete class for running the allometry functional tests for FATES. -""" -import os -import xarray as xr -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -from utils import round_up -from utils_plotting import blank_plot, get_color_palette -from functional_class import FunctionalTest - - -class PatchTest(FunctionalTest): - """Patch test class - """ - - name = "patch" - - def __init__(self, test_dict): - super().__init__( - PatchTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True - - def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): - """Plots all allometry plots - - Args: - run_dir (str): run directory - out_file (str): output file name - save_figs (bool): whether or not to save the figures - plot_dir (str): plot directory to save the figures to - """ - diff --git a/testing/generate_empty_test.py b/testing/generate_empty_test.py index 5d87a01393..b2aefd9486 100755 --- a/testing/generate_empty_test.py +++ b/testing/generate_empty_test.py @@ -15,14 +15,13 @@ 3. Updates the existing CMakeLists.txt file to include the new test. 4. Appends an entry for this test to the configuration file. 5. Creates a template test file in the new directory. -6. If it's a functional test, generates a new Python TestCase file and updates - load_functional_tests.py +6. If it's a functional test, generates a new Python TestCase file """ import argparse import textwrap -from test_generator_class import generate_test +from framework.test_generator import generate_test def commandline_args(): @@ -47,6 +46,12 @@ def commandline_args(): subparser.add_argument( "--test-sub-dir", default=None, help="Optional test subdirectory path" ) + subparser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Enable verbose output", + ) # print help for both subparsers parser.epilog = textwrap.dedent( @@ -67,7 +72,9 @@ def main(): args = commandline_args() # create the test - test = generate_test(args.test_type, args.test_name, args.test_sub_dir) + test = generate_test( + args.test_type, args.test_name, args.test_sub_dir, args.verbose + ) test.setup_test() diff --git a/testing/great_circle/TestGreatCircle.F90 b/testing/great_circle/TestGreatCircle.F90 deleted file mode 100644 index 87316d84fa..0000000000 --- a/testing/great_circle/TestGreatCircle.F90 +++ /dev/null @@ -1,34 +0,0 @@ -program TestGreatCircle - - use FatesConstantsMod, only : r8 => fates_r8 - use FatesUtilsMod, only : GreatCircleDist - implicit none - - ! Variable declarations - real(r8) :: inv_lat_list(5) ! list of lat coords - real(r8) :: inv_lon_list(5) ! list of lon coords - real(r8) :: site_lat_list(5) ! list of lat coords - real(r8) :: site_lon_list(5) ! list of lon coords - real(r8) :: delta_site_list(5) - - integer :: i,s - integer :: invsite - - inv_lat_list = (/-89._r8, -20._r8, 0._r8, 60._r8, 90._r8/) - inv_lon_list = (/-170._r8, -20._r8, 120.5_r8, 210._r8, 90._r8/) - - site_lat_list = (/-19._r8, -89._r8, 1._r8, 63._r8, 88._r8/) - site_lon_list = (/-21._r8, -171._r8, 118.5_r8, 214._r8, 78._r8/) - - do s=1,5 - do i =1,5 - delta_site_list(i) = & - GreatCircleDist(site_lon_list(s),inv_lon_list(i),site_lat_list(s),inv_lat_list(i)) - end do - invsite = minloc(delta_site_list(:), dim=1) - write(*,'(A,2(F6.1),A,2(F6.1))') "closest to ", site_lat_list(s),site_lon_list(s), & - " is: ",inv_lat_list(invsite),inv_lon_list(invsite) - write(*,'(A,F6.1,A)') " with distance of:",delta_site_list(invsite)/1000._r8," km" - end do - -end program TestGreatCircle diff --git a/testing/great_circle/WrapGreatCircleMod.F90 b/testing/great_circle/WrapGreatCircleMod.F90 deleted file mode 100644 index 2728111c34..0000000000 --- a/testing/great_circle/WrapGreatCircleMod.F90 +++ /dev/null @@ -1,45 +0,0 @@ -module shr_log_mod - use iso_c_binding, only : c_char - use iso_c_binding, only : c_int - - public :: shr_log_errMsg - -contains - function shr_log_errMsg(source, line) result(ans) - character(kind=c_char,len=*), intent(in) :: source - integer(c_int), intent(in) :: line - character(kind=c_char,len=4) :: cline ! character version of int - character(kind=c_char,len=128) :: ans - - write(cline,'(I4)') line - ans = "source: " // trim(source) // " line: "// trim(cline) - - end function shr_log_errMsg - -end module shr_log_mod - -module FatesGlobals - - use iso_c_binding, only : c_char - use iso_c_binding, only : c_int - use FatesConstantsMod, only : r8 => fates_r8 - - integer :: stdo_unit = 6 - -contains - - integer function fates_log() - fates_log = 6 - end function fates_log - - subroutine fates_endrun(msg) - - implicit none - character(len=*), intent(in) :: msg ! string to be printed - - write(stdo_unit,*) msg - - stop - - end subroutine fates_endrun -end module FatesGlobals diff --git a/testing/great_circle/bld/README b/testing/great_circle/bld/README deleted file mode 100644 index 5954904f79..0000000000 --- a/testing/great_circle/bld/README +++ /dev/null @@ -1 +0,0 @@ -folder holder diff --git a/testing/great_circle/build_gc.sh b/testing/great_circle/build_gc.sh deleted file mode 100755 index c3c065c7b5..0000000000 --- a/testing/great_circle/build_gc.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash - -# Path to FATES src - -FC='gfortran' - -#F_OPTS="-fPIC -O3 -llapack" -F_OPTS="-g -fPIC" -F_OBJ_OPTS="-shared" - -FATES_PATH='../../' - -#F_OPTS="-fPIC -O0 -g -ffpe-trap=zero,overflow,underflow -fbacktrace -fbounds-check -Wall" - -MOD_FLAG="-J" - -rm -f bld/*.o -rm -f bld/*.so -rm -f bld/*.mod -rm -f bld/*.a - -# Build dgesv from lapack -${FC} ${F_OPTS} -c -I bld/ -J./bld/ -o bld/libFatesConstantsMod.so ${FATES_PATH}/main/FatesConstantsMod.F90 -${FC} ${F_OPTS} -c -I bld/ -J./bld/ -o bld/libWrapGreatCircleMod.so WrapGreatCircleMod.F90 -${FC} ${F_OPTS} -c -I bld/ -J./bld/ -o bld/libFatesUtilsMod.so ${FATES_PATH}/main/FatesUtilsMod.F90 -${FC} ${F_OPTS} -I bld/ -J./bld/ -L./bld/ -lFatesConstantsMod -lFatesUtilsMod -lWrapGreatCircleMod -o test_gc TestGreatCircle.F90 - diff --git a/testing/load_functional_tests.py b/testing/load_functional_tests.py deleted file mode 100644 index 3b72dcb2b7..0000000000 --- a/testing/load_functional_tests.py +++ /dev/null @@ -1,10 +0,0 @@ -# add testing subclasses here - -from functional_class import FunctionalTest -from functional_class_with_drivers import FunctionalTestWithDrivers -from functional_testing.allometry.allometry_test import AllometryTest -from functional_testing.math_utils.math_utils_test import QuadraticTest -from functional_testing.fire.fuel.fuel_test import FuelTest -from functional_testing.fire.ros.ros_test import ROSTest -from functional_testing.patch.patch_test import PatchTest -from functional_testing.fire.mortality.fire_mortality_test import FireMortTest diff --git a/testing/path_utils.py b/testing/path_utils.py deleted file mode 100644 index 0343a69f1b..0000000000 --- a/testing/path_utils.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Utility functions related to getting paths to various important places -""" - -import os -import sys - -# path to the root directory of FATES, based on the path of this file -# it's important that this NOT end with a trailing slash -_FATES_ROOT = os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) -) - - -def add_cime_lib_to_path() -> str: - """Adds the CIME python library to the python path, to allow importing - modules from that library - - Returns: - str: path to top-level cime directory - """ - cime_path = path_to_cime() - prepend_to_python_path(cime_path) - - cime_lib_path = os.path.join(cime_path, "CIME", "Tools") - prepend_to_python_path(cime_lib_path) - - return cime_path - - -def path_to_cime() -> str: - """Returns the path to cime, if it can be found - - Raises: - RuntimeError: can't find path to cime - - Returns: - str: full path to cime - """ - cime_path = os.path.join(path_to_fates_root(), "../../cime") - if os.path.isdir(cime_path): - return cime_path - raise RuntimeError("Cannot find cime.") - - -def path_to_fates_root(): - """Returns Returns the path to the root directory of FATES - - Returns: - str: path to the root directory of FATES - """ - return _FATES_ROOT - - -def prepend_to_python_path(path: str): - """Adds the given path to python's sys.path if not already there - Path is added near the beginning, so that it takes precedence over existing - entries in path. - - Args: - path (str): input path - """ - if not path in sys.path: - # insert at location 1 rather than 0, because 0 is special - sys.path.insert(1, path) diff --git a/testing/run_functional_tests.py b/testing/run_functional_tests.py index 61f540a3d0..17b2b6e4f0 100755 --- a/testing/run_functional_tests.py +++ b/testing/run_functional_tests.py @@ -1,14 +1,13 @@ #!/usr/bin/env python - """ |------------------------------------------------------------------| |--------------------- Instructions -----------------------------| |------------------------------------------------------------------| To run this script the following python packages are required: - - numpy - - xarray - - matplotlib - - pandas + - numpy + - xarray + - matplotlib + - pandas Though this script does not require any host land model code, it does require some CIME and shr code, so you should still get these repositories as you normally would @@ -26,36 +25,29 @@ specify anything, the script will use the default FATES parameter json file. """ -import os import argparse -import subprocess +import logging +from pathlib import Path import matplotlib.pyplot as plt +import framework.utils.env_check +from framework.loader import get_test_instances, validate_test_configs +from framework.utils.general import config_to_dict, parse_test_list, copy_file +from framework.builder import build_tests + +# constants +_DEFAULT_CONFIG_FILE = Path(__file__).resolve().parents[0] / "config" / "functional.cfg" +_DEFAULT_PARAM_FILE = ( + Path(__file__).resolve().parents[1] + / "parameter_files" + / "fates_params_default.json" +) +_CMAKE_BASE_DIR = Path(__file__).resolve().parents[1] -from build_fortran_tests import build_tests, build_exists -from functional_class_with_drivers import FunctionalTestWithDrivers -from path_utils import add_cime_lib_to_path -from utils import copy_file, create_nc_from_cdl, config_to_dict, parse_test_list - -# load the functional test classes -from load_functional_tests import * - -add_cime_lib_to_path() - -from CIME.utils import run_cmd - -# constants for this script -_FILE_DIR = os.path.dirname(__file__) -_DEFAULT_CONFIG_FILE = os.path.join(_FILE_DIR, "functional_tests.cfg") -_DEFAULT_CDL_PATH = os.path.abspath( - os.path.join( - _FILE_DIR, - os.pardir, - "parameter_files", - "fates_params_default.json", - ) +# setup logging +logging.basicConfig( + level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s" ) -_CMAKE_BASE_DIR = os.path.join(_FILE_DIR, os.pardir) -_TEST_SUB_DIR = "testing" +logger = logging.getLogger(__name__) def commandline_args(): @@ -77,7 +69,7 @@ def commandline_args(): "-f", "--parameter-file", type=str, - default=_DEFAULT_CDL_PATH, + default=_DEFAULT_PARAM_FILE, help="Parameter file to run the FATES tests with.\n" "This should be JSON formatted.\n" "If no file is specified the script will use the default .json file in the\n" @@ -95,7 +87,7 @@ def commandline_args(): "-b", "--build-dir", type=str, - default=os.path.join(_CMAKE_BASE_DIR, "_build"), + default=_CMAKE_BASE_DIR / "_build", help="Directory where tests are built.\n" "Will be created if it does not exist.\n", ) @@ -104,7 +96,7 @@ def commandline_args(): "-r", "--run-dir", type=str, - default=os.path.join(_CMAKE_BASE_DIR, "_run"), + default=_CMAKE_BASE_DIR / "_run", help="Directory where tests are run.\n" "Will be created if it does not exist.\n", ) @@ -125,15 +117,7 @@ def commandline_args(): ) parser.add_argument( - "--skip-build", - action="store_true", - help="Skip building and compiling the test code.\n" - "Only do this if you already have run build.\n" - "Script will check to make sure executables are present.\n", - ) - - parser.add_argument( - "--skip-run-executables", + "--skip-run", action="store_true", help="Skip running test code executables.\n" "Only do this if you already have run the code previously.\n" @@ -149,7 +133,7 @@ def commandline_args(): ) parser.add_argument( - "--verbose-make", action="store_true", help="Run make with verbose output." + "--verbose", action="store_true", help="Run with verbose output." ) parser.add_argument( @@ -163,308 +147,121 @@ def commandline_args(): "for all tests. If not supplied, will run all tests.", ) - args = parser.parse_args() - - check_arg_validity(args) - - return args - - -def check_arg_validity(args): - """Checks validity of input script arguments - - Args: - args (parse_args): input arguments - """ - # check to make sure parameter file exists and is one of the correct forms - check_param_file(args.parameter_file) - - # make sure relevant output files exist: - if args.skip_run_executables: - # if you skip the run we assume you want to skip the build - print("--skip-run specified, assuming --skip-build") - args.skip_build = True - check_out_files(args.run_dir, args.test_dict) - - # make sure build directory exists - if args.skip_build: - if args.verbose_make: - raise argparse.ArgumentError( - None, - "Can't run verbose make and skip build.\n" - "Re-run script without --skip-build", - ) - check_build_dir(args.build_dir, args.test_dict) - - # Check that config file exists and is a file - if not os.path.exists(args.config_file): - raise FileNotFoundError(args.config_file) - if not os.path.isfile(args.config_file): - raise RuntimeError(f"config 'file' is a directory: '{args.config_file}'") - - -def check_param_file(param_file): - """Checks to see if param_file exists and is of the correct form (.json) - - Args: - param_file (str): path to parameter file + return parser.parse_args() - Raises: - argparse.ArgumentError: Parameter file is not of the correct form (.json) - argparse.ArgumentError: Can't find parameter file - """ - file_suffix = os.path.basename(param_file).split(".")[-1] - if not file_suffix in ["json"]: - raise argparse.ArgumentError( - None, "Must supply parameter file with .json. Ending." - ) - if not os.path.isfile(param_file): - raise FileNotFoundError(param_file) - -def check_build_dir(build_dir, test_dict): - """Checks to see if all required build directories and executables are present +def make_plotdirs(run_dir: Path, test_instances: dict): + """Create plotting directories Args: - build_dir (str): build directory - test_list (list, str): list of test names - - Raises: - argparse.ArgumentError: Can't find a required build directory or executable + run_dir (Path): path to run directory + test_instances (dict): dictionary of test instances """ - for attributes in test_dict.values(): - if not build_exists(build_dir, attributes["test_dir"], attributes["test_exe"]): - raise argparse.ArgumentError( - None, - "Build directory or executable does not exist.\n" - "Re-run script without --skip-build.", - ) - - -def check_out_files(run_dir, test_dict): - """Checks to see that required output files are present in the run directory - - Args: - run_dir (str): run directory - test_dict (dict): dictionary of tests to run - Raises: - argparse.ArgumentError: Can't find a required output file - """ - for test, attributes in dict( - filter(lambda pair: pair[1]["out_file"] is not None, test_dict.items()) - ).items(): - if not os.path.isfile( - os.path.join(os.path.abspath(run_dir), attributes["out_file"]) - ): - raise argparse.ArgumentError( - None, - f"Required file for {test} test does not exist.\n" - "Re-run script without --skip-run.", - ) - - -def run_functional_tests( - clean, - verbose_make, - build, - run_executables, - build_dir, - run_dir, - make_j, - param_file, - save_figs, - test_dict, -): - """Builds and runs the fates functional tests - - Args: - clean (bool): whether or not to clean the build directory - verbose_make (bool): whether or not to run make with verbose output - build (bool): whether or not to build the exectuables - run_executables (bool): whether or not to run the executables - build_dir (str): build directory - run_dir (str): run directory - make_j (int): number of processors for the build - param_file (str): input FATES parameter file - save_figs (bool): whether or not to write figures to file - test_dict (dict): dictionary of test classes to run - """ - - # absolute path to desired build directory - build_dir_path = os.path.abspath(build_dir) - - # absolute path to desired run directory - run_dir_path = os.path.abspath(run_dir) - - # make run directory if it doesn't already exist - if not os.path.isdir(run_dir_path): - os.mkdir(run_dir_path) - - # create plot directories if we need to - if save_figs: - make_plotdirs(os.path.abspath(run_dir), test_dict) - - # move parameter file to correct location - param_file = create_param_file(param_file, run_dir) - - # compile code - if build: - build_tests( - build_dir, _CMAKE_BASE_DIR, make_j, clean=clean, verbose=verbose_make - ) - - # run executables for each test in test list - if run_executables: - print("Running executables") - for _, test in test_dict.items(): - args = test.other_args - # prepend datm file (if required) to argument list - if isinstance(test, FunctionalTestWithDrivers) and test.datm_file: - args.insert(0, test.datm_file) - # prepend parameter file (if required) to argument list - if test.use_param_file: - args.insert(0, param_file) - # run - run_fortran_exectuables( - build_dir_path, test.test_dir, test.test_exe, run_dir_path, args - ) - - # plot output for relevant tests - for name, test in dict( - filter(lambda pair: pair[1].plot, test_dict.items()) - ).items(): - test.plot_output( - run_dir_path, save_figs, os.path.join(run_dir_path, "plots", name) - ) - # show plots - plt.show() - - -def make_plotdirs(run_dir, test_dict): - """Create plotting directories if they don't already exist - - Args: - run_dir (str): full path to run directory - test_dict (dict): dictionary of test to run - """ - # make main plot directory - plot_dir = os.path.join(run_dir, "plots") - if not os.path.isdir(plot_dir): - os.mkdir(plot_dir) + # create top-level plot directory + plot_dir = run_dir / "plots" + if not plot_dir.exists(): + plot_dir.mkdir(parents=True, exist_ok=True) # make sub-plot directories - for test in dict(filter(lambda pair: pair[1].plot, test_dict.items())): - sub_dir = os.path.join(plot_dir, test) - if not os.path.isdir(sub_dir): - os.mkdir(sub_dir) + for test_name, test in test_instances.items(): + if test.plot: + sub_dir = plot_dir / test_name + if not sub_dir.exists(): + sub_dir.mkdir(parents=True, exist_ok=True) -def create_param_file(param_file, run_dir): +def copy_param_file(param_file: Path, run_dir: Path) -> Path: """Moves the input parameter file to the run directory Args: - param_file (str): path to parmaeter file - run_dir (str): full path to run directory + param_file (Path): path to parameter file + run_dir (Path): path to run directory Raises: - RuntimeError: Supplied parameter file is not JSON + ValueError: parameter file not .json file Returns: - str: full path to new parameter file name/location + Path: path to new parameter file in run directory """ - if param_file is None: - print("Using default parameter file.") - param_file = _DEFAULT_CDL_PATH - param_file_update = create_nc_from_cdl(param_file, run_dir) - else: - print(f"Using parameter file {param_file}.") - file_suffix = os.path.basename(param_file).split(".")[-1] - if file_suffix == "json": - param_file_update = copy_file(param_file, run_dir) - else: - raise RuntimeError("Must supply parameter file with .json. Ending.") - return param_file_update + logging.info("Using parameter file, %s", param_file) + file_suffix = param_file.name.split(".")[-1] + if file_suffix == "json": + return copy_file(param_file, run_dir) + + raise ValueError("Must supply parameter file with .json ending.") -def run_fortran_exectuables(build_dir, test_dir, test_exe, run_dir, args): - """Run the generated Fortran executables +def prep_directories(run_dir: Path, test_instances: dict, save_figs: bool): + """Creates directories (run_dir and plotting directories) for the tests Args: - build_dir (str): full path to build directory - run_dir (str): full path to run directory - test_dir (str): test directory within the run directory - test_exe (str): test executable to run - args ([str]): arguments for executable + run_dir (Path): path to run directory + test_instances (dict): dictionary of test class instances + save_figs (bool): whether or not to save figures """ - # move executable to run directory - exe_path = os.path.join(build_dir, _TEST_SUB_DIR, test_dir, test_exe) - copy_file(exe_path, run_dir) + # create run directory + if not run_dir.exists(): + run_dir.mkdir(parents=True, exist_ok=True) - # run the executable - new_exe_path = os.path.join(run_dir, test_exe) - run_command = [new_exe_path] - run_command.extend(args) - - os.chdir(run_dir) - cmd = " ".join(run_command) - stat, out, _ = run_cmd(cmd, combine_output=True) - if stat: - print(out) - raise subprocess.CalledProcessError(stat, cmd, out) - print(out) - - -def get_test_subclasses(*argv): - """ - Given a FunctionalTest* class, find all its test subclasses. Do not include child - FunctionalTest* classes. - """ - test_subclasses = [] - for ftest_class in argv: - test_subclasses += [x for x in ftest_class.__subclasses__() if hasattr(x, "name")] - return test_subclasses + # create plot directories + if save_figs: + make_plotdirs(run_dir, test_instances) def main(): """Main script - Reads in command-line arguments and then runs the tests. + Reads in command-line arguments and then runs tests """ args = commandline_args() + build_dir = Path(args.build_dir) + run_dir = Path(args.run_dir) + + if args.verbose: + logging.getLogger().setLevel(logging.INFO) + else: + logging.getLogger().setLevel(logging.WARNING) + + # get config of specific tests to run full_test_dict = config_to_dict(args.config_file) config_dict = parse_test_list(full_test_dict, args.test_list) + + # get instances of tests to run + test_instances = get_test_instances(config_dict) + validate_test_configs(test_instances) + + # build tests + build_tests( + build_dir, + _CMAKE_BASE_DIR, + args.make_j, + clean=args.clean, + verbose=args.verbose, + ) - test_dict = {} + # create run and plotting directories + prep_directories(run_dir, test_instances, args.save_figs) - # Get all the possible test subclasses. - subclasses = get_test_subclasses(FunctionalTest, FunctionalTestWithDrivers) + # move parameter file into run directory + param_file_moved = copy_param_file(Path(args.parameter_file), run_dir) - # Associate each test in the config file with the appropriate test subclass - for name in config_dict.keys(): - test_class = list(filter(lambda subclass: subclass.name == name, subclasses))[ - 0 - ](config_dict[name]) - test_dict[name] = test_class + # run tests + if not args.skip_run: + for test_name, test in test_instances.items(): + out = test.run(build_dir, run_dir, param_file_moved) + print(out) - build = not args.skip_build - run = not args.skip_run_executables + # plot output + for test_name, test in test_instances.items(): + if test.plot: + test.plot_output(run_dir, args.save_figs, run_dir / "plots" / test_name) - run_functional_tests( - args.clean, - args.verbose_make, - build, - run, - args.build_dir, - args.run_dir, - args.make_j, - args.parameter_file, - args.save_figs, - test_dict, - ) + # show plots + plt.show() if __name__ == "__main__": diff --git a/testing/run_unit_tests.py b/testing/run_unit_tests.py index a0f84532d0..2f933afab6 100755 --- a/testing/run_unit_tests.py +++ b/testing/run_unit_tests.py @@ -17,22 +17,17 @@ This script builds and runs FATES units tests. """ -import os +from pathlib import Path import argparse -from build_fortran_tests import build_tests -from path_utils import add_cime_lib_to_path -from utils import config_to_dict, parse_test_list - -add_cime_lib_to_path() - -from CIME.utils import run_cmd_no_fail # pylint: disable=wrong-import-position,import-error,wrong-import-order +import framework.utils.env_check +from framework.unit_test import UnitTest +from framework.builder import build_tests +from framework.utils.general import config_to_dict, parse_test_list # constants for this script -_FILE_DIR = os.path.dirname(os.path.abspath(__file__)) -_CMAKE_BASE_DIR = os.path.join(_FILE_DIR, os.pardir) -_DEFAULT_CONFIG_FILE = os.path.join(_FILE_DIR, "unit_tests.cfg") -_TEST_SUB_DIR = "testing" +_CMAKE_BASE_DIR = Path(__file__).resolve().parents[1] +_DEFAULT_CONFIG_FILE = Path(__file__).resolve().parents[0] / "config" / "unit.cfg" def commandline_args(): @@ -54,7 +49,7 @@ def commandline_args(): "-b", "--build-dir", type=str, - default=os.path.join(_CMAKE_BASE_DIR, "_build"), + default=_CMAKE_BASE_DIR / "_build", help="Directory where tests are built.\n" "Will be created if it does not exist.\n", ) @@ -82,7 +77,7 @@ def commandline_args(): ) parser.add_argument( - "--verbose-make", action="store_true", help="Run make with verbose output." + "--verbose", action="store_true", help="Run make with verbose output." ) parser.add_argument( @@ -101,50 +96,29 @@ def commandline_args(): return args -def run_unit_tests(clean, verbose_make, build_dir, make_j, test_dict): - """Builds and runs the fates unit tests - - Args: - clean (bool): whether or not to clean the build directory - verbose_make (bool): whether or not to run make with verbose output - build_dir (str): build directory - make_j (int): number of processors for the build - test_dict (dict): dictionary of test classes to run - """ - - # absolute path to desired build directory - build_dir_path = os.path.abspath(build_dir) - - # compile code - build_tests( - build_dir_path, _CMAKE_BASE_DIR, make_j, clean=clean, verbose=verbose_make - ) - - # run unit tests - print("Running unit tests...") - for _, attributes in test_dict.items(): - - test_dir = os.path.join(build_dir_path, _TEST_SUB_DIR, attributes["test_dir"]) - ctest_command = ["ctest", "--output-on-failure"] - output = run_cmd_no_fail( - " ".join(ctest_command), from_dir=test_dir, combine_output=True - ) - print(output) - - def main(): """Main script Reads in command-line arguments and then runs the tests. """ args = commandline_args() + build_dir = Path(args.build_dir) + full_test_dict = config_to_dict(args.config_file) test_dict = parse_test_list(full_test_dict, args.test_list) - run_unit_tests( - args.clean, args.verbose_make, args.build_dir, args.make_j, test_dict + # build tests + build_tests( + build_dir, _CMAKE_BASE_DIR, args.make_j, clean=args.clean, verbose=args.verbose ) + # run unit tests + for name, attributes in test_dict.items(): + test = UnitTest(name, attributes) + out = test.run(build_dir) + + print(out) + if __name__ == "__main__": main() diff --git a/testing/templates/test_class_template.txt b/testing/templates/test_class_template.txt index 5e12fed4bc..bf02e532a6 100644 --- a/testing/templates/test_class_template.txt +++ b/testing/templates/test_class_template.txt @@ -5,25 +5,13 @@ import os import xarray as xr import numpy as np import matplotlib.pyplot as plt -from functional_class import FunctionalTest +from framework.functional_test import FunctionalTest class {module_name}(FunctionalTest): - """Quadratic test class + """{module_name} test class """ - - name = "allometry" - - def __init__(self, test_dict): - super().__init__( - {module_name}.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True + name = "{test_name}" def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): """Plots - update this to plot your output @@ -34,6 +22,5 @@ class {module_name}(FunctionalTest): save_figs (bool): whether or not to save the figures plot_dir (str): plot directory to save the figures to """ - pass diff --git a/testing/test_data/BONA_datm.nc b/testing/test_data/BONA_datm.nc deleted file mode 100644 index 5781ff1e8b..0000000000 --- a/testing/test_data/BONA_datm.nc +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4d0a72950d8289b555c57e00cefe32ad0cc80ff8b0739e755e40956d86f93922 -size 22212 diff --git a/testing/test_generator_class.py b/testing/test_generator_class.py deleted file mode 100644 index 6c7ab77415..0000000000 --- a/testing/test_generator_class.py +++ /dev/null @@ -1,434 +0,0 @@ -"""Module for generating empty test classes""" - -import os -import re -from abc import ABC, abstractmethod -from utils import snake_to_camel - -_TEST_SUB_DIR = os.path.dirname(os.path.abspath(__file__)) -_UNIT_TESTING_DIR = "unit_testing" -_FUNCTIONAL_TESTING_DIR = "functional_testing" -_TEMPLATE_DIR = os.path.join(_TEST_SUB_DIR, "templates") -_UNIT_CMAKE_TEMPLATE = "cmake_utest_template.txt" -_FUNCTIONAL_CMAKE_TEMPLATE = "cmake_ftest_template.txt" -_PFUNIT_TEMPLATE = "pfunit_template.txt" -_FUNCTIONAL_TEST_TEMPLATE = "fortran_test_template.txt" -_CLASS_TEMPLATE = "test_class_template.txt" -_TEST_CMAKELISTS = os.path.join(_TEST_SUB_DIR, "CMakeLists.txt") -_UNIT_TESTS_CONFIG = os.path.join(_TEST_SUB_DIR, "unit_tests.cfg") -_FUNCTIONAL_TESTS_CONFIG = os.path.join(_TEST_SUB_DIR, "functional_tests.cfg") -_LOAD_CLASS_FILE = os.path.join(_TEST_SUB_DIR, "load_functional_tests.py") - - -class GenerateTestClass(ABC): - """Abstract base class for creating empty FATES tests""" - - def __init__(self, test_name: str, sub_dir: str | None = None): - # normalize test name - self.test_name = re.sub(r"_test$", "", test_name.lower()) - self.sub_dir = sub_dir - - # values derived from test_name - self.test_dir = self.generate_test_dir() - self.build_dir = self.generate_test_build_dir() - self.module_name = snake_to_camel(self.test_name) - self.file_name = self.generate_file_name() - - # ---------------------------- - # Abstract interface contract - # ---------------------------- - - @property - @abstractmethod - def section_header(self) -> str: - """String that marks where this test's add_subdirectory should go in the - testing/CMakeLists.txt""" - raise NotImplementedError - - @property - @abstractmethod - def config_file(self) -> str: - """Path to the config file that should be modified during test creation.""" - raise NotImplementedError - - @abstractmethod - def generate_test_dir(self) -> str: - """Generates a test directory path (must be implemented by subclasses).""" - raise NotImplementedError - - @abstractmethod - def generate_test_build_dir(self) -> str: - """Generates a test build directory path (must be implemented by subclasses).""" - raise NotImplementedError - - @abstractmethod - def generate_file_name(self) -> str: - """ "Generates a file name for the test program (must be implemented by - subclases).""" - raise NotImplementedError - - @abstractmethod - def append_config_lines(self, lines): - """Appends lines to the config file for the test (must be implemented by - subclasses).""" - raise NotImplementedError - - @abstractmethod - def create_cmake_file(self): - """Creates the new CMakeLists.txt file for the test (must be implemented by - subclasses).""" - raise NotImplementedError - - @abstractmethod - def create_template_program(self): - """Creates a template program file (must be implemented by subclasses).""" - raise NotImplementedError - - # -------------------------- - # Concrete helpers - # -------------------------- - - def create_test_directory(self): - """Creates a new test directory - - Raises: - FileExistsError: Directory already exists - """ - if os.path.exists(self.test_dir): - raise FileExistsError(f"Error: Directory '{self.test_dir}' already exists.") - os.makedirs(self.test_dir) - print(f"Created directory: {self.test_dir}") - - def update_main_cmake(self): - """Updates the main testing/CMakeLists.txt file by adding a new test directory in - the correct section. - - Raises: - FileNotFoundError: testing/CMakeLists.txt file not found - """ - if not os.path.exists(_TEST_CMAKELISTS): - raise FileNotFoundError(f"ERROR: {_TEST_CMAKELISTS} not found.") - - with open(_TEST_CMAKELISTS, "r", encoding="utf-8") as f: - lines = f.readlines() - - new_entry = f"add_subdirectory({self.test_dir} {self.build_dir})\n" - updated_lines = [] - inside_block = False - - for line in lines: - updated_lines.append(line) - - # insert new entry in the correct section - if line.strip() == self.section_header: - inside_block = True - continue - - # if we're in the correct section and hit another section header, insert it - if inside_block and line.startswith("## "): - updated_lines.insert(-1, new_entry) # insert before new section header - inside_block = False # stop inserting - - # if we never found a new section header, append at end - if inside_block: - updated_lines.append(new_entry) - - # write modified contents back to file - with open(_TEST_CMAKELISTS, "w", encoding="utf-8") as f: - f.writelines(updated_lines) - - print(f"Updated {_TEST_CMAKELISTS} to include {self.test_dir}.") - - def update_config_file(self): - """Loads config file lines""" - - if not os.path.exists(self.config_file): - raise FileNotFoundError(f"ERROR: {self.config_file} not found.") - - with open(self.config_file, "r", encoding="utf-8") as f: - lines = f.readlines() - - # strip trailing newlines and whitespace-only lines - while lines and lines[-1].strip() == "": - lines.pop() - - # add one blank line before new section - lines.append("\n") - - # new config block - lines = self.append_config_lines(lines) - - with open(self.config_file, "w", encoding="utf-8") as f: - f.writelines(lines) - - print(f"Updated {self.config_file}.") - - def setup_test(self): - """Runs all setup steps: creates directory, updates CMake, and config.""" - - self.create_test_directory() - self.create_cmake_file() - self.update_main_cmake() - self.update_config_file() - self.create_template_program() - - @staticmethod - def load_template(template_name: str) -> str: - """Load a template file from the templates directory - - Args: - template_name (str): name of template - - Raises: - FileNotFoundError: Template file not found - - Returns: - str: template string - """ - - template_path = os.path.join(_TEMPLATE_DIR, template_name) - if not os.path.exists(template_path): - raise FileNotFoundError(f"ERROR: Template '{template_path}' not found.") - - with open(template_path, "r", encoding="utf-8") as f: - return f.read() - - -class GenerateUnitTest(GenerateTestClass): - """Concrete generator for unit test boilerplate""" - - @property - def section_header(self) -> str: - return "## Unit tests" - - @property - def config_file(self) -> str: - return _UNIT_TESTS_CONFIG - - # --------------------------------------------------------- - # Directory + filename generation - # --------------------------------------------------------- - - def generate_test_dir(self) -> str: - """Returns the directyr path where this unit test will be created - - Returns: - str: test directory path - """ - test_name = f"{self.test_name}_test" # append "_test" - if self.sub_dir: - return os.path.join(_UNIT_TESTING_DIR, self.sub_dir, test_name) - return os.path.join(_UNIT_TESTING_DIR, test_name) - - def generate_test_build_dir(self) -> str: - """Return the build-directory name used in add_subdirectory(). - - Returns: - str: test build directory path - """ - return f"fates_{self.test_name}_utest" - - def generate_file_name(self) -> str: - """Returns the filename for the test program. - - Returns: - str: file name - Raises: - RuntimeError: self.module name not set yet - """ - if not self.module_name: - raise RuntimeError( - "self.module_name must be set before file name generated!" - ) - return f"test_{self.module_name}.pf" - - # --------------------------------------------------------- - # Files that get written - # --------------------------------------------------------- - - def create_cmake_file(self): - """Create the test's CMakeLists.txt.""" - cmake_template = self.load_template(_UNIT_CMAKE_TEMPLATE) - cmake_path = os.path.join(self.test_dir, "CMakeLists.txt") - - rendered = cmake_template.format( - file_name=self.file_name, - module_name=self.module_name, - ) - - with open(cmake_path, "w", encoding="utf-8") as f: - f.write(rendered) - - print(f"Created {cmake_path}.") - - def append_config_lines(self, lines: list[str]) -> list[str]: - """Appends this test's config section to the config file contents - - Args: - lines (list[str]): lines to append to - - Returns: - list[str]: updated lines - """ - - lines.append(f"[{self.test_name}]\n") - lines.append(f"test_dir = {self.build_dir}\n") - return lines - - def create_template_program(self): - """Generate the .pf unit test boilerplate file.""" - pfunit_template = self.load_template(_PFUNIT_TEMPLATE) - pfunit_path = os.path.join(self.test_dir, self.file_name) - - rendered = pfunit_template.format(module_name=self.module_name) - - with open(pfunit_path, "w", encoding="utf-8") as f: - f.write(rendered) - print(f"Added template test files in {self.test_dir}.") - - -class GenerateFunctionalTest(GenerateTestClass): - """Concrete generator for functional test boilerplate""" - - @property - def section_header(self) -> str: - return "## Functional tests" - - @property - def config_file(self) -> str: - return _FUNCTIONAL_TESTS_CONFIG - - def __init__(self, test_name, sub_dir=None): - super().__init__(test_name, sub_dir) - self.executable_name = f"{self.module_name}_exe" - - # --------------------------------------------------------- - # Directory + filename generation - # --------------------------------------------------------- - - def generate_test_dir(self) -> str: - """Return the functional test directory path. - - Returns: - str: test directory path - """ - if self.sub_dir: - return os.path.join(_FUNCTIONAL_TESTING_DIR, self.sub_dir, self.test_name) - return os.path.join(_FUNCTIONAL_TESTING_DIR, self.test_name) - - def generate_file_name(self) -> str: - """Return the filename for the functional test's Fortran program. - - Returns: - str: file name - Raises: - RuntimeError: self.module name not set yet - """ - if not self.module_name: - raise RuntimeError( - "self.module_name must be set before file name generated!" - ) - return f"Test{self.module_name}.F90" - - def generate_test_build_dir(self) -> str: - """Return the build-directory name used in add_subdirectory(). - - Returns: - str: test build directory path - """ - return f"fates_{self.test_name}_ftest" - - # --------------------------------------------------------- - # Files that get written - # --------------------------------------------------------- - - def create_cmake_file(self): - """Creates the test's CMakeLists.txt file for the test""" - cmake_template = self.load_template(_FUNCTIONAL_CMAKE_TEMPLATE) - cmake_path = os.path.join(self.test_dir, "CMakeLists.txt") - - rendered = cmake_template.format( - file_name=self.file_name, - executable_name=self.executable_name, - ) - - with open(cmake_path, "w", encoding="utf-8") as f: - f.write(rendered) - print(f"Created {cmake_path}.") - - def append_config_lines(self, lines: list[str]) -> list[str]: - """Append this functional test's config section to the config file - - Args: - lines (list[str]): lines to append to - - Returns: - str: updated lines - """ - lines.append(f"[{self.test_name}]\n") - lines.append(f"test_dir = {self.build_dir}\n") - lines.append(f"test_exe = {self.executable_name}\n") - lines.append("out_file = None\n") - lines.append("use_param_file = False\n") - lines.append("other_args = []\n") - return lines - - def create_template_program(self): - """Generate the functional test Fortran program and Python class. - - Raises: - FileNotFoundError: Can't find the class file - """ - - # create a fortran template file - functional_test_template = self.load_template(_FUNCTIONAL_TEST_TEMPLATE) - test_path = os.path.join(self.test_dir, self.file_name) - - with open(test_path, "w", encoding="utf-8") as f: - f.write(functional_test_template.format(module_name=self.module_name)) - - # create the python template file - class_template = self.load_template(_CLASS_TEMPLATE) - class_path = os.path.join(self.test_dir, f"{self.test_name}.py") - - with open(class_path, "w", encoding="utf-8") as f: - f.write(class_template.format(module_name=self.module_name)) - - print(f"Added template test files in {self.test_dir}.") - - # add import to class loader file - if not os.path.exists(_LOAD_CLASS_FILE): - raise FileNotFoundError(f"ERROR: {_LOAD_CLASS_FILE} not found.") - - if self.sub_dir: - name = f"{self.sub_dir}.{self.test_name}" - else: - name = self.test_name - - with open(_LOAD_CLASS_FILE, "a", encoding="utf-8") as f: - f.write(f"from functional_testing.{name} import {self.module_name}\n") - - print(f"Updated {_LOAD_CLASS_FILE}.") - - -def generate_test( - test_type: str, test_name: str, sub_dir: str = None -) -> GenerateTestClass: - """Generates all boilerplate associated with a new test - - Args: - test_type (str): test type ('unit' or 'functional') - test_name (str): name of test - sub_dir (str, optional): optional subdirectory to place. Defaults to None. - - Raises: - RuntimeError: _description_ - - Returns: - GenerateTestClass: _description_ - """ - if test_type == "unit": - return GenerateUnitTest(test_name, sub_dir) - if test_type == "functional": - return GenerateFunctionalTest(test_name, sub_dir) - raise RuntimeError("test_type must be one of ['unit', 'functional']") diff --git a/testing/tests/__init__.py b/testing/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/tests/data/BONA_datm.cdl b/testing/tests/data/BONA_datm.cdl new file mode 100644 index 0000000000..7521b17c41 --- /dev/null +++ b/testing/tests/data/BONA_datm.cdl @@ -0,0 +1,412 @@ +netcdf BONA_datm { +dimensions: + time = 365 ; +variables: + double time(time) ; + time:units = "days since 2018-01-01 00:00:00.0 -0:00" ; + time:long_name = "time" ; + time:axis = "T" ; + double temp_degC(time) ; + temp_degC:units = "deg_C" ; + temp_degC:_FillValue = 1.e+32 ; + temp_degC:long_name = "mean air temperature" ; + double wind(time) ; + wind:units = "m/s" ; + wind:_FillValue = 1.e+32 ; + wind:long_name = "mean wind speed" ; + double RH(time) ; + RH:units = "%" ; + RH:_FillValue = 1.e+32 ; + RH:long_name = "mean relative humidity" ; + double precip(time) ; + precip:units = "mm" ; + precip:_FillValue = 1.e+32 ; + precip:long_name = "mean precipitation" ; +data: + + time = 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, + 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, + 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, + 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, + 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, + 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, + 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, + 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, + 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, + 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, + 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, + 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, + 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, + 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, + 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, + 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, + 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, + 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, + 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, + 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, + 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, + 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, + 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, + 360, 361, 362, 363, 364, 365 ; + + temp_degC = -15.8853625, -8.00424791666667, -6.54171875, -13.1669997301918, + -14.5702412917059, -10.66796875, -16.56529375, -18.2111708333333, + -16.0845520833333, -23.4475895833333, -21.16805, -23.7617229166667, + -20.3642958333333, -5.87803958333333, -4.14696458333334, + -5.79359583333333, 1.17892083333333, -4.71088440547582, + -8.48830995010491, -13.908372994484, -24.3034690172092, + -25.7048541666667, -22.67045, -31.0578480681818, -30.66270625, + -31.385625, -22.3359958333333, -25.8265729166667, -30.3238770833333, + -26.8090916666667, -18.7416833333333, -23.8240520833333, + -26.5117291666667, -20.0009333333333, -22.28421875, -24.187825, + -11.599925, -19.8320791666667, -24.7780625, -20.5686125, + -19.5733583333333, -17.8421375, -8.13892291666667, -4.1654125, + -5.15173541666666, -4.48501458333334, -7.7082875, -12.2540875, + -8.87018541666667, -6.12251458333333, -3.46273125, -5.55024375, + -4.4850125, -5.8432875, -16.8338, -15.0979458333333, -14.7962291666667, + -12.7161291666667, -17.8616145833333, -24.1731125, -17.8029125, + -11.985293586612, -10.0347645833333, -13.5615395833333, -7.40269375, + -7.81850833333333, -11.2112270833333, -4.07534166666667, -7.7585875, + -8.50188333333333, -5.6252375, -5.12515416666667, -0.843052083333331, + -4.27875833333333, -8.28398333333334, -4.562825, -3.15870416666666, + -2.15227083333334, -5.11760416666667, -8.29275624999999, -13.94569375, + -8.1788875, -6.38291875, -6.91672083333333, -7.36427916666667, + -8.74304791666667, -5.37349791666667, -2.89690833333334, + -4.17843958333333, -8.75698958333334, -3.09820208333333, -8.6424125, + -10.9660770833333, -9.6572625, -4.78563125, -2.70029166666666, + -3.55501041666667, -1.20481666666666, -1.42611458333333, + 1.53700416666667, 4.39426666666667, 2.86008125, 2.65774070518457, + -3.32191666666667, -5.26679375, -1.7135375, -0.720177083333328, + 1.21635625, -1.05647291666667, -4.47515208333333, -6.18699583333333, + -2.59409583333333, 2.78564375, 5.90520625, 5.67934375, 3.5104375, + 2.97360161741553, 2.05670833333334, 3.84908333333333, 2.63283958333333, + 1.64656875, -0.0187854166666668, 0.817104166666669, 3.84595625000001, + 6.55111041666666, 3.90850208333333, 3.93313541666667, 6.46284166666667, + 9.41569791666666, 12.8263020833333, 11.087, 5.49982291666667, + 6.77506041666667, 9.67847708333333, 6.9785125, 5.50751041666667, + 5.69528125, 9.68068333333333, 11.4832354166667, 11.7759270833333, + 11.6714145833333, 11.8845604166667, 11.3163666666667, 8.56831041666667, + 8.00595208333333, 9.33647916666666, 10.7766375, 9.56567708333333, + 11.30574375, 11.4224125, 10.4076104166667, 9.45007083333333, 10.80498125, + 13.6866145833333, 14.5199083333333, 14.7238979166667, 13.6323979166667, + 14.0971895833333, 12.8116625, 12.8749416666667, 10.4681020833333, 5.9185, + 6.40264375, 7.14656666666667, 12.22834375, 8.26439791666667, 10.404975, + 13.1956041666667, 13.7435625, 16.0895083333333, 14.7395645833333, + 19.9323916666667, 16.56388125, 13.757675, 13.83385, 15.0415833333333, + 16.9027916666667, 13.8760875, 12.8418729166667, 13.4274145833333, + 14.1001604166667, 14.7184104166667, 14.0823458333333, 16.37233125, + 17.0173645833333, 16.5245145833333, 15.9664979166667, 15.5273916666667, + 18.160525, 14.00126875, 15.6023599431818, 12.7728416666667, + 10.4698685217101, 10.4559949758477, 12.4248732781991, 15.0491578541393, + 14.4028174696015, 14.4530131435655, 14.2150691447583, 16.7386256890025, + 16.7239699482945, 17.01393125, 17.2503030113636, 17.2971063888889, + 17.228312906586, 17.7055580208333, 17.695549375, 17.3326229166667, + 14.2576854166667, 15.8327729166667, 18.1488729166667, 19.6728, + 17.4931145833333, 14.2042395833333, 14.38230625, 14.7370375, + 13.6791541666667, 12.89563125, 11.5731020833333, 9.95230833333333, + 8.97619375, 10.1454791666667, 9.5207875, 13.6811666666667, + 13.6827895833333, 11.6422354166667, 10.2341916666667, 9.61988668499711, + 10.0983791666667, 10.18543125, 10.2075145833333, 14.3359979166667, + 14.500875, 10.79938125, 8.82695625, 10.76464375, 10.0734625, + 7.47197708333334, 11.0637708333333, 9.96030416666666, 9.29078125, + 6.66794166666666, 5.20359166666667, 3.8382875, 9.17527916666667, + 9.44221041666667, 8.55998541666667, 9.0582375, 8.8325625, 7.72569375, + 6.8042125, 3.80459791666667, 4.21940833333333, 7.47067291666666, + 9.44323680555556, 11.4285434944356, 6.04151750848501, 9.2017, 8.65715, + 8.48204791666667, 8.68761041666667, 8.96666666666667, 6.887775, 7.503075, + 6.61481458333334, 6.00895208333333, 6.29355833333333, 7.16717291666666, + 5.685375, 3.9766, 2.98968333333333, 5.69374791666667, 4.45229583333333, + 9.8773375, 6.02090416666667, 3.22587291666667, 6.13019583333333, + 3.65342708333333, 3.60305416666666, 0.695983333333333, -6.90489166666666, + -5.66236458333333, 1.44356666666667, 2.06094375, 0.472895833333331, + 3.17698541666667, 3.16847708333334, 0.0680645833333339, 6.2587125, + 1.93069791666667, 1.74074472588541, 0.886237499999998, 1.44490625, + -0.152743749999999, -0.648883333333329, 3.18370208333333, + 5.15241666666666, 0.132816666666667, 3.31381352623535, 0.260862347003314, + -1.82671458333333, -7.86547291666667, -14.3570875, -17.9095520833333, + -12.1169083333333, -12.8819041666667, -13.9185458333333, -17.2963875, + -16.9147229166667, -16.9385770833333, -14.6810104166667, -11.59009375, + -13.7702134635051, -7.81819375, -7.00809166666666, -3.9286875, + -8.33709791666667, -9.7756875, -15.01895, -18.034275, -14.5888708333333, + -11.9094020833333, -6.24861458333333, -7.31516458333333, + -9.05141401473542, -12.2443035958836, -10.6629854166667, + -12.7676291666667, -13.8588458333333, -10.377575, -6.63719791666667, + -11.24510625, -10.6075484659408, -11.0599607272988, -14.5099782808519, + -6.15478125, -7.04216875, -7.17923541666666, -8.87692708333333, + -7.41575416666667, -5.54239637516659, -5.070225, -9.82926458333333, + -17.4377208333333, -16.1333541666667, -15.4386375, -19.1745395833333, + -22.51086875, -24.0732916666667, -24.4737916666667, -23.8599791666667, + -19.2512014785882, -15.9184657592801, -21.6648351113431, + -28.0273020833333, -20.6911020833333, -23.1696770833333, -19.50170625, + -20.80709375, -25.6126791666667, -20.96830625, -20.2791979166667, + -20.2325270833333, -18.01815, -5.371525 ; + + wind = 1.35104498704351, 2.08949516902496, 1.62617868411728, + 1.16804301843004, 1.2127458440856, 2.46711609113151, 1.91280873172156, + 2.13626437791593, 4.30180884890822, 1.83456246407361, 0.724973003105274, + 1.04372658987038, 1.04616640681873, 2.7683820258648, 1.65560189648208, + 1.5888305222356, 1.5496090387044, 1.02454354893772, 0.753944342170138, + 0.86979969117542, 1.06667276675782, 0.689670279085623, 0.7015528789836, + 1.07351848750609, 0.939077198318063, 1.01026196987924, 1.37058665909938, + 1.22578465643676, 1.02747575696571, 1.25830149805772, 2.92267984610739, + 2.12497471735855, 1.10348042652042, 2.09000949323901, 1.08579756032526, + 0.919104938072392, 2.11825824737449, 1.73820410992777, 1.41088343143339, + 1.27530399307781, 1.45107060332125, 1.02347268579637, 0.897756572396289, + 2.33661423001597, 1.5020063348565, 1.36220498679989, 1.4493539397013, + 0.992660541763114, 1.11809182494485, 0.947937513253106, 1.91121110092576, + 1.65910477834014, 0.847061351531248, 4.72181657964166, 1.87604047700597, + 3.26751115139342, 1.2414452518488, 3.03095898721745, 1.57638819232172, + 1.56574374812233, 1.21989561361954, 0.80018646656083, 0.851196505932279, + 1.32790453188043, 1.01831447717511, 1.66987034581667, 1.82910087128428, + 2.58474279899724, 1.4435489097042, 1.80591311219037, 1.73167227906065, + 2.00651245238741, 1.73262623478503, 1.69748938033609, 1.55608821503264, + 0.693639943069844, 1.20557793340422, 2.03585908104218, 2.3325535140728, + 1.76916584069615, 1.76017225420306, 2.86887712496725, 2.92038001228639, + 3.31633998586928, 3.57609842886948, 2.77785016858992, 1.38444767376755, + 1.35968768638328, 1.57540265968347, 1.97682966694803, 1.72049460280601, + 2.13702105298261, 3.69196934290738, 5.59635682114149, 4.44596513893986, + 3.99780375293149, 1.98341805307411, 3.24012237658596, 2.94110458420808, + 3.72437721424, 2.49984109647243, 1.73288979034058, 2.48880095129735, + 2.63342061116561, 3.34448970303495, 3.21988403837379, 3.29387271795257, + 3.42317791743869, 4.33101962788799, 4.18986070582149, 3.10394570181152, + 2.27128136284336, 2.43267654065479, 2.83578098781156, 2.54500238224905, + 2.45168643560425, 1.98798715964644, 1.80697634481247, 1.52539497885874, + 1.81329532298571, 1.74455841653835, 2.76624195406167, 2.0116048106718, + 2.90532972546352, 2.5505764744865, 2.09875071608883, 2.25924462667128, + 2.42467970562167, 3.60106880430249, 3.6127469849426, 3.26842752683565, + 2.27713495912224, 1.8916283479851, 2.60313801413039, 1.83353373592423, + 2.20002830538862, 2.12175902855918, 2.95191773132701, 3.26005362967998, + 3.11822666856092, 2.53989711828801, 2.27377305760654, 1.61736259654549, + 1.77510629265785, 1.71535446639568, 2.30691451763929, 3.7179201931684, + 2.74045189613215, 2.13864577975239, 2.22500520441643, 1.87598770530102, + 1.58600994736732, 1.98988307048643, 2.04257016985857, 2.1478664946626, + 2.95652483294815, 2.72322256528867, 2.36266976447533, 1.92199688016794, + 2.22622928672235, 2.0553500358375, 2.14872073505129, 1.61421509762019, + 1.37813667625117, 2.80373904668155, 2.60581264266545, 1.08689087601803, + 1.52581630497534, 1.89985337181252, 2.15113247538138, 1.49931585109399, + 1.72067308685215, 1.90659865459096, 2.65887653813281, 2.15729972939868, + 1.85769271711276, 2.34811394552521, 1.87886118906296, 1.5922486590805, + 1.54401827607087, 1.95868195974977, 1.48147252629583, 2.27243880361733, + 3.22106055943792, 3.07812667506569, 2.27851504055495, 2.15224841968031, + 1.97545753562323, 2.46831682455074, 1.64416021309526, 1.67467397146474, + 1.81982493248812, 1.97125645521715, 1.98796864958172, 1.93570094901821, + 1.98597361993952, 1.87051331309195, 1.93518483829357, 2.12865859141426, + 2.36098988774533, 2.37576657844245, 2.25534350347082, 2.09048739074949, + 2.09481102005675, 2.11971647925077, 2.03325445402714, 2.04074365774322, + 1.78165448606055, 1.73892945462948, 1.68271213450255, 1.87851481336595, + 1.64719736650922, 2.15857993037233, 1.33655331792662, 1.38785098116001, + 1.33219517090681, 1.83700852787976, 1.58753228890419, 1.6156040401072, + 1.5393988577831, 2.25063634521709, 1.78678539915238, 1.65472840709951, + 1.57393529536852, 1.54630766732464, 1.01320466863119, 1.64506966429602, + 2.29675465266114, 2.09327447974972, 1.69369897489124, 1.73693368323878, + 1.13534618050603, 0.940469335160978, 2.22859138852265, 1.04102543423641, + 1.08423100474904, 1.95510120057377, 1.25865540044275, 1.78385678397405, + 2.51202567442054, 1.95938395930954, 1.86669250450768, 2.09450446388996, + 1.86562223977151, 3.05509806543078, 2.19616734794738, 2.28455867124734, + 1.76715218664216, 1.26227462407645, 2.47539214462938, 2.63512378763309, + 1.55951746511678, 1.47140736474889, 1.52043606249522, 1.80699474828031, + 1.74649362904518, 2.59205475827751, 1.44586602294259, 1.51362050188372, + 1.11411603805553, 1.21143009242423, 1.1185160826794, 0.880894330993611, + 1.01947646945244, 1.18147595443397, 0.916401659715889, 1.86443346769077, + 1.70314362203642, 1.33597572312888, 2.05040330246927, 3.07765765660415, + 2.61183917767856, 2.22384362288424, 2.98885643247321, 2.38251103343681, + 1.74450611968903, 2.87314942792755, 2.08773109609107, 1.15860206425236, + 2.557547669307, 1.74011894661137, 2.00503978516226, 2.5419954585817, + 1.26730572256631, 1.25139318335366, 1.87267207420954, 1.06018863206294, + 2.3059484132581, 2.11576003810471, 1.11408087902195, 1.44060484600806, + 3.03497174213073, 1.48557091058754, 1.7369594246392, 1.62840563428649, + 4.43388957077842, 2.31149512115411, 1.77063419980347, 1.95403695842576, + 0.987482007027187, 1.48129178472961, 1.43760509488812, 1.37730236310328, + 1.56174668973575, 1.57021582160659, 1.93080005576915, 1.79312010596229, + 1.07838509885058, 1.36265053335107, 1.44613650263056, 1.19611617115427, + 0.942185818384539, 1.51246932044015, 0.697346325747528, 1.93008792557045, + 1.67759670069453, 3.36526983442148, 2.2867640322133, 1.9854045317936, + 1.18579100072544, 1.11802419288228, 1.00585709687195, 1.15904589665577, + 1.00960081266109, 1.10370217640821, 0.618418086643147, 0.740663847300886, + 1.18138316974673, 1.11663927683065, 1.25171692977564, 1.3628126823256, + 1.1765693768274, 0.830028638892579, 0.930643618506227, 1.45090876368238, + 1.99374513973832, 1.09687936099784, 1.37254919664542, 1.36926762555664, + 1.38533816100251, 1.42426876606307, 1.25876888476348, 0.924877687280245, + 1.63190643917753, 1.25382371515097, 0.942715645094827, 0.828210591532498, + 1.42234058643138, 0.867099100289516, 0.942715645094828, + 0.846034491379465, 0.729909083285592, 0.952437772284083, + 0.994566990104184, 1.12878635713361, 0.87006975026401, 0.891944536439833, + 2.38213058728166, 1.98352337252223, 1.23221898806374, 0.911658849906932, + 1.17199581130808, 1.26273566507445, 1.15768267961279, 1.95301377922511 ; + + RH = 83.9229166666667, 90.088125, 90.5679166666667, 86.9397916666667, + 85.396875, 86.8727083333333, 82.0077083333333, 79.2354166666667, + 66.940625, 76.1652083333333, 79.1864583333333, 77.363125, + 79.3447916666667, 83.8402083333333, 93.361875, 92.0877083333333, + 99.0113882751379, 94.86625, 91.6922916666667, 86.8070833333333, + 78.6633333333333, 78.0410416666667, 80.5066666666667, 74.5050492424242, + 74.440625, 73.799375, 80.4227083333333, 76.7477083333333, + 73.7495833333333, 76.4229166666667, 79.4972916666667, 76.8770833333333, + 74.9975, 78.9422916666667, 77.6160416666667, 76.5402083333333, + 82.7191666666667, 78.936875, 75.965, 78.3897916666667, 79.1691666666667, + 82.6264583333333, 91.2333333333333, 92.3858333333333, 92.9079166666667, + 92.8270833333333, 89.23125, 87.490625, 89.451875, 91.896875, + 92.4622916666667, 88.39125, 93.7720833333333, 86.0513182997257, + 79.453125, 78.5639583333333, 82.6483333333333, 84.23625, + 83.4622916666667, 72.4345833333333, 77.4495833333333, 85.1766666666667, + 85.91, 79.8116179930599, 87.7097916666667, 83.19625, 75.54875, + 71.1466666666667, 82.5625, 79.77125, 77.4275, 78.59, 85.4091666666667, + 86.92625, 85.84, 89.2589583333333, 88.2597916666667, 93.10125, + 89.3095833333333, 87.9252083333333, 82.9835416666667, 73.2227083333333, + 62.3435416666667, 51.6664583333333, 49.6464583333333, 55.0397916666667, + 61.7845833333333, 74.7516666666667, 75.09875, 72.995, 82.215, 61.741875, + 57.6272916666667, 51.8535416666667, 48.1447916666667, 52.911875, + 62.3077083333333, 58.5279166666667, 61.6560416666667, 51.3227083333333, + 59.7704166666667, 69.1883333333333, 70.9302083333333, 65.3875, + 56.9216666666667, 45.6522916666667, 53.7454166666667, 48.533125, + 42.624375, 51.8145833333333, 67.9233333333333, 63.424375, + 54.8447916666667, 63.4452083333333, 67.2647916666667, 61.6541666666667, + 67.5870833333333, 63.39375, 67.6133333333333, 91.3295833333333, + 88.8695833333333, 77.361875, 60.3375, 59.7175, 56.4133333333333, + 61.681875, 63.0491666666667, 51.358125, 51.0095833333333, 49.278125, + 59.0779166666667, 71.6535416666667, 55.793125, 48.7020833333333, + 67.4097916666667, 75.7347916666667, 62.540625, 54.9729166666667, + 67.34125, 58.1302083333333, 71.7027083333333, 63.0045833333333, + 74.6208333333333, 89.8775363938355, 85.4758333333333, 68.8008333333333, + 55.605, 51.2116666666667, 47.2479364920843, 66.20125, 65.38125, + 85.1052083333333, 66.9708333333333, 56.9808333333333, 59.0889583333333, + 60.7295833333333, 73.529375, 54.7716666666667, 61.56125, 65.059375, + 79.8258333333333, 89.5722751206075, 74.15875, 69.041875, + 61.1754166666667, 55.735, 78.9610416666667, 73.9335416666667, + 64.4333333333333, 63.86, 62.4145833333333, 53.6475, 81.246867767761, + 72.1239583333333, 60.1445833333333, 57.5610416666667, 57.778125, + 77.986875, 82.0408333333333, 82.5760416666667, 79.2566666666667, + 88.1072916666667, 87.5270833333333, 65.510625, 56.2333333333333, + 70.0333333333333, 72.03375, 69.7429166666667, 65.7775, 76.4191666666667, + 58.6040340909091, 81.3285416666667, 97.0545347222222, 97.05034375, + 84.8332438257658, 71.966571428333, 73.2949953758565, 72.8253323662111, + 77.1662185236468, 65.43809375, 65.4177465277778, 65.3152083333333, + 65.0251183712121, 64.9350173611111, 69.2598429659498, 69.6211319444444, + 69.69375, 71.4316959451015, 86.0589583333333, 69.0595833333333, + 63.4154166666667, 61.8235416666667, 74.7772916666667, 84.4741666666667, + 84.0730113636364, 88.25, 95.018801549995, 96.5268390376113, + 89.8802083333333, 81.8089583333333, 67.4225, 74.509375, 72.9914583333333, + 75.3047916666667, 79.001875, 95.4531876140769, 96.6060442314667, + 88.1294253589018, 86.2560416666667, 86.505, 76.9085416666667, + 79.1545833333333, 89.9033333333333, 81.985625, 85.627253139478, + 92.4745906511784, 90.0966666666667, 88.196875, 95.5325, 93.1863678795947, + 87.18125, 91.4520833333333, 94.0778737322737, 89.8364583333333, + 93.1276753333575, 91.2629166666667, 87.5670833333333, 89.264375, 92.9525, + 85.308125, 74.1485416666667, 77.350625, 79.0291666666667, + 76.7135416666667, 82.2736111111111, 80.4329166666667, 68.2945833333333, + 60.2410416666667, 74.7272916666667, 85.2910416666667, 79.868125, + 91.9084073880472, 96.2539583333333, 96.3810416666667, 97.715, + 98.7077932743805, 96.3586448943785, 96.258125, 96.6611226933847, + 86.1164583333333, 79.0507920063241, 67.2091143009064, 65.8260416666667, + 57.2285416666667, 56.3670833333333, 68.0833333333333, 58.5127083333333, + 64.6245833333333, 68.6697916666667, 57.3591666666667, 69.535625, + 68.6470833333333, 59.286875, 94.9520833333333, 96.4877083333333, + 97.8737689169178, 95.0422296811714, 89.6875, 91.3411431310437, 97.1875, + 95.346056470449, 88.8879166666667, 94.8577083333333, 95.9656377076798, + 92.3810416666667, 80.9870833333333, 84.6033333333333, 91.4122916666667, + 88.6691666666667, 95.161875, 92.8735416666667, 84.931875, 83.52625, + 80.9479166666667, 85.1022916666667, 83.71125, 82.8225, 80.29, 80.500625, + 81.2191666666667, 83.6945833333333, 87.23375, 85.0194952173809, + 91.1097916666667, 89.5310416666667, 91.2560416666667, 83.8408333333333, + 80.92375, 80.2527083333333, 80.9052083333333, 83.925625, + 86.8416666666667, 91.4985416666667, 91.239375, 90.6316666666667, + 87.1660416666667, 88.3952083333333, 86.1591666666667, 84.8114583333333, + 87.8802602195128, 89.4320833333333, 84.7183333333333, 89.4635416666667, + 88.11125, 84.6225, 90.6166666666667, 91.0566666666667, 90.048125, + 88.8460416666667, 88.8916666666667, 92.22125, 92.84, 89.3458333333333, + 80.63875, 81.3070833333333, 84.8616666666667, 81.56875, 77.915625, + 77.9702083333333, 77.1258333333333, 78.3377083333333, 81.9504703952504, + 83.195, 79.0089583333333, 73.849375, 80.3914583333333, 76.475, + 78.1110416666667, 77.6539583333333, 75.8645833333333, 79.22375, + 79.291875, 79.074375, 82.3997916666667, 89.9479166666667 ; + + precip = 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 1.21286391042609, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.886760629555732, 0.886760629555732, 0.886760629555732, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.825667622993205, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.95638982935677, 0.782319326582488, 0.782319326582488, 1.10777020822857, + 0.782319326582488, 4.13199120000743, 0.782319326582488, 1.04274776361249, + 3.67480213630066, 0.869015919403921, 0.956389829356769, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 1.41290157593212, 0.744176026474195, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.842483528867979, 0.6, 0.89, 1.5, 1.13, 0.77, 0.9, 1.34, 0.64, 0.68, + 1.03, 0.86, 0.91, 1.23, 1.91, 1.04, 0.75, 0.619999999999999, 0.63, 2.04, + 1.2, 1.21, 0.800000000000001, 2.31, 1.2, 1.05, 1.24, 0.583983781370185, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 1.87814495765951, + 0.737790167821228, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.889386722093501, 1.13, + 0.89593512548074, 0.802838318706262, 0.769386722093501, 0.69, 0.4, + 0.959999999999999, 1.01, 1.23, 1.09, 0.96, 0.67, 0.57, 0.366903193225522, + 2.12, 4.11, 2.93, 1.17, 0.72, 0.83, 0.31, 0.85, 0.68, 0.7, 5.23, 2.09, + 0.13, 0.41, 0.82, 1.18, 0.52, 0.74, 0.490000000000001, 1.02, 1.38, 0.58, + 0.83, 1.93, 0.63, 0.62, 4.33, 1.38, 0.73, 2.35, 0.53, 0.28, 0.63, 1.65, + 0.67, 1.27, 1.4, 1.01, 0.9, 0.223451596612761, 0.79, 1.4, 1.07, 0.23, + 0.56, 0.81, 0.540000000000001, 1.15, 3.82, 4.46, 5.3, 1.64, 2, 0.44, + 1.57, 1.33, 1.18, 1.46, 0.959999999999999, 1.04, 0.5, 1.19, 1.08, 0.48, + 0.59, 0.87, 0.24, 0.86, 0.42, 0.47, 0.24, 0.47, 0.29, 1.27, 0.6, 0.37, + 0.81, 0.25, 0.21, 0.51, 0.34, 0.91, 0.570000000000001, 0.43, 0.46, + 0.0799999999999999, 0.18672579830638, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 0.802838318706262, + 0.802838318706262, 0.802838318706262, 1.08230613394884, 0.45, + 0.418144957659511, 0.802838318706262, 0.731773949191413, 0.89, 0.72, + 0.15, 0.04, 0.66, 0.37, 0.42, 0.11, 0.38, 0.52, 0.94, 0.77, + 0.44005462120265, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, + 0.782319326582488, 0.782319326582488, 0.782319326582488, 0.782319326582488 ; +} diff --git a/testing/tests/data/BONA_datm.nc b/testing/tests/data/BONA_datm.nc new file mode 100644 index 0000000000..db99752f8c Binary files /dev/null and b/testing/tests/data/BONA_datm.nc differ diff --git a/testing/testing_shr/CMakeLists.txt b/testing/tests/fortran_shr/CMakeLists.txt similarity index 100% rename from testing/testing_shr/CMakeLists.txt rename to testing/tests/fortran_shr/CMakeLists.txt diff --git a/testing/testing_shr/FatesArgumentUtils.F90 b/testing/tests/fortran_shr/FatesArgumentUtils.F90 similarity index 100% rename from testing/testing_shr/FatesArgumentUtils.F90 rename to testing/tests/fortran_shr/FatesArgumentUtils.F90 diff --git a/testing/testing_shr/FatesFactoryMod.F90 b/testing/tests/fortran_shr/FatesFactoryMod.F90 similarity index 100% rename from testing/testing_shr/FatesFactoryMod.F90 rename to testing/tests/fortran_shr/FatesFactoryMod.F90 diff --git a/testing/testing_shr/FatesUnitTestIOMod.F90 b/testing/tests/fortran_shr/FatesUnitTestIOMod.F90 similarity index 100% rename from testing/testing_shr/FatesUnitTestIOMod.F90 rename to testing/tests/fortran_shr/FatesUnitTestIOMod.F90 diff --git a/testing/testing_shr/FatesUnitTestParamReaderMod.F90 b/testing/tests/fortran_shr/FatesUnitTestParamReaderMod.F90 similarity index 100% rename from testing/testing_shr/FatesUnitTestParamReaderMod.F90 rename to testing/tests/fortran_shr/FatesUnitTestParamReaderMod.F90 diff --git a/testing/testing_shr/FatesUnitTestUtils.F90 b/testing/tests/fortran_shr/FatesUnitTestUtils.F90 similarity index 100% rename from testing/testing_shr/FatesUnitTestUtils.F90 rename to testing/tests/fortran_shr/FatesUnitTestUtils.F90 diff --git a/testing/testing_shr/SyntheticPatchTypes.F90 b/testing/tests/fortran_shr/SyntheticPatchTypes.F90 similarity index 100% rename from testing/testing_shr/SyntheticPatchTypes.F90 rename to testing/tests/fortran_shr/SyntheticPatchTypes.F90 diff --git a/testing/tests/functional/__init__.py b/testing/tests/functional/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/allometry/CMakeLists.txt b/testing/tests/functional/allometry/CMakeLists.txt similarity index 100% rename from testing/functional_testing/allometry/CMakeLists.txt rename to testing/tests/functional/allometry/CMakeLists.txt diff --git a/testing/functional_testing/allometry/FatesTestAllometry.F90 b/testing/tests/functional/allometry/FatesTestAllometry.F90 similarity index 100% rename from testing/functional_testing/allometry/FatesTestAllometry.F90 rename to testing/tests/functional/allometry/FatesTestAllometry.F90 diff --git a/testing/tests/functional/allometry/__init__.py b/testing/tests/functional/allometry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/allometry/allometry_test.py b/testing/tests/functional/allometry/allometry_test.py similarity index 92% rename from testing/functional_testing/allometry/allometry_test.py rename to testing/tests/functional/allometry/allometry_test.py index 4b070bc8db..9291f33ee0 100644 --- a/testing/functional_testing/allometry/allometry_test.py +++ b/testing/tests/functional/allometry/allometry_test.py @@ -1,33 +1,22 @@ """ Concrete class for running the allometry functional tests for FATES. """ + import os import xarray as xr import pandas as pd import numpy as np import matplotlib.pyplot as plt -from utils import round_up -from utils_plotting import blank_plot, get_color_palette -from functional_class import FunctionalTest +from framework.utils.general import round_up +from framework.utils.plotting import blank_plot, get_color_palette +from framework.functional_test import FunctionalTest class AllometryTest(FunctionalTest): - """Quadratic test class - """ + """Allometry test class""" name = "allometry" - def __init__(self, test_dict): - super().__init__( - AllometryTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True - def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): """Plots all allometry plots diff --git a/testing/tests/functional/fire/__init__.py b/testing/tests/functional/fire/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/fire/fuel/CMakeLists.txt b/testing/tests/functional/fire/fuel/CMakeLists.txt similarity index 100% rename from testing/functional_testing/fire/fuel/CMakeLists.txt rename to testing/tests/functional/fire/fuel/CMakeLists.txt diff --git a/testing/functional_testing/fire/fuel/FatesTestFuel.F90 b/testing/tests/functional/fire/fuel/FatesTestFuel.F90 similarity index 100% rename from testing/functional_testing/fire/fuel/FatesTestFuel.F90 rename to testing/tests/functional/fire/fuel/FatesTestFuel.F90 diff --git a/testing/tests/functional/fire/fuel/__init__.py b/testing/tests/functional/fire/fuel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/fire/fuel/fuel_test.py b/testing/tests/functional/fire/fuel/fuel_test.py similarity index 91% rename from testing/functional_testing/fire/fuel/fuel_test.py rename to testing/tests/functional/fire/fuel/fuel_test.py index 9ae6c53e39..42e4205948 100644 --- a/testing/functional_testing/fire/fuel/fuel_test.py +++ b/testing/tests/functional/fire/fuel/fuel_test.py @@ -5,26 +5,13 @@ import numpy as np import xarray as xr import matplotlib.pyplot as plt -from functional_class_with_drivers import FunctionalTestWithDrivers +from framework.functional_test import FunctionalTest -class FuelTest(FunctionalTestWithDrivers): +class FuelTest(FunctionalTest): """Fuel test class""" - name = "fuel" - - def __init__(self, test_dict): - super().__init__( - test_dict["datm_file"], - FuelTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True - + def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): """Plot output associated with fuel tests @@ -81,7 +68,7 @@ def plot_barchart( varname: str, units: str, save_figs: bool, - plot_dir: bool, + plot_dir: str, by_litter_type: bool = True, ): """Plots fuel data output as a bar chart diff --git a/testing/functional_testing/fire/mortality/CMakeLists.txt b/testing/tests/functional/fire/mortality/CMakeLists.txt similarity index 100% rename from testing/functional_testing/fire/mortality/CMakeLists.txt rename to testing/tests/functional/fire/mortality/CMakeLists.txt diff --git a/testing/functional_testing/fire/mortality/FatesTestFireMortality.F90 b/testing/tests/functional/fire/mortality/FatesTestFireMortality.F90 similarity index 100% rename from testing/functional_testing/fire/mortality/FatesTestFireMortality.F90 rename to testing/tests/functional/fire/mortality/FatesTestFireMortality.F90 diff --git a/testing/tests/functional/fire/mortality/__init__.py b/testing/tests/functional/fire/mortality/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/fire/mortality/fire_mortality_test.py b/testing/tests/functional/fire/mortality/fire_mortality_test.py similarity index 94% rename from testing/functional_testing/fire/mortality/fire_mortality_test.py rename to testing/tests/functional/fire/mortality/fire_mortality_test.py index aa9a7a833d..ce757bac56 100644 --- a/testing/functional_testing/fire/mortality/fire_mortality_test.py +++ b/testing/tests/functional/fire/mortality/fire_mortality_test.py @@ -6,24 +6,12 @@ import xarray as xr import pandas as pd import matplotlib.pyplot as plt -from functional_class import FunctionalTest -from utils_plotting import blank_plot, get_color_palette +from framework.functional_test import FunctionalTest +from framework.utils.plotting import blank_plot, get_color_palette class FireMortTest(FunctionalTest): """Fire mortality test class""" - - name = "fire_mortality" - - def __init__(self, test_dict): - super().__init__( - FireMortTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True + name = 'fire_mortality' def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): """Plot output associated with fuel tests diff --git a/testing/functional_testing/fire/ros/CMakeLists.txt b/testing/tests/functional/fire/ros/CMakeLists.txt similarity index 100% rename from testing/functional_testing/fire/ros/CMakeLists.txt rename to testing/tests/functional/fire/ros/CMakeLists.txt diff --git a/testing/functional_testing/fire/ros/FatesTestROS.F90 b/testing/tests/functional/fire/ros/FatesTestROS.F90 similarity index 100% rename from testing/functional_testing/fire/ros/FatesTestROS.F90 rename to testing/tests/functional/fire/ros/FatesTestROS.F90 diff --git a/testing/tests/functional/fire/ros/__init__.py b/testing/tests/functional/fire/ros/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/functional_testing/fire/ros/ros_test.py b/testing/tests/functional/fire/ros/ros_test.py similarity index 95% rename from testing/functional_testing/fire/ros/ros_test.py rename to testing/tests/functional/fire/ros/ros_test.py index 66074676e4..0dbc1e3dab 100644 --- a/testing/functional_testing/fire/ros/ros_test.py +++ b/testing/tests/functional/fire/ros/ros_test.py @@ -6,8 +6,8 @@ import xarray as xr import pandas as pd import matplotlib.pyplot as plt -from functional_class import FunctionalTest -from utils_plotting import blank_plot +from framework.functional_test import FunctionalTest +from framework.utils.plotting import blank_plot COLORS = ["#793922", "#6B8939", "#99291F", "#CC9728", "#2C778A"] CM_TO_FT = 30.48 @@ -16,19 +16,7 @@ class ROSTest(FunctionalTest): """ROS test class""" - - name = "ros" - - def __init__(self, test_dict): - super().__init__( - ROSTest.name, - test_dict["test_dir"], - test_dict["test_exe"], - test_dict["out_file"], - test_dict["use_param_file"], - test_dict["other_args"], - ) - self.plot = True + name = 'ros' def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): """Plot output associated with fuel tests diff --git a/testing/functional_testing/fire/shr/CMakeLists.txt b/testing/tests/functional/fire/shr/CMakeLists.txt similarity index 100% rename from testing/functional_testing/fire/shr/CMakeLists.txt rename to testing/tests/functional/fire/shr/CMakeLists.txt diff --git a/testing/functional_testing/fire/shr/FatesTestFireMod.F90 b/testing/tests/functional/fire/shr/FatesTestFireMod.F90 similarity index 100% rename from testing/functional_testing/fire/shr/FatesTestFireMod.F90 rename to testing/tests/functional/fire/shr/FatesTestFireMod.F90 diff --git a/testing/functional_testing/fire/shr/SyntheticFuelModels.F90 b/testing/tests/functional/fire/shr/SyntheticFuelModels.F90 similarity index 100% rename from testing/functional_testing/fire/shr/SyntheticFuelModels.F90 rename to testing/tests/functional/fire/shr/SyntheticFuelModels.F90 diff --git a/testing/functional_testing/patch/CMakeLists.txt b/testing/tests/functional/patch/CMakeLists.txt similarity index 100% rename from testing/functional_testing/patch/CMakeLists.txt rename to testing/tests/functional/patch/CMakeLists.txt diff --git a/testing/functional_testing/patch/FatesTestPatch.F90 b/testing/tests/functional/patch/FatesTestPatch.F90 similarity index 100% rename from testing/functional_testing/patch/FatesTestPatch.F90 rename to testing/tests/functional/patch/FatesTestPatch.F90 diff --git a/testing/tests/functional/patch/__init__.py b/testing/tests/functional/patch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/tests/functional/patch/patch_test.py b/testing/tests/functional/patch/patch_test.py new file mode 100644 index 0000000000..4eed0bb245 --- /dev/null +++ b/testing/tests/functional/patch/patch_test.py @@ -0,0 +1,21 @@ +""" +Concrete class for running the allometry functional tests for FATES. +""" +from framework.functional_test import FunctionalTest + + +class PatchTest(FunctionalTest): + """Patch test class + """ + name = 'patch' + + def plot_output(self, run_dir: str, save_figs: bool, plot_dir: str): + """Plots all allometry plots + + Args: + run_dir (str): run directory + out_file (str): output file name + save_figs (bool): whether or not to save the figures + plot_dir (str): plot directory to save the figures to + """ + pass diff --git a/testing/unit_testing/count_cohorts_test/CMakeLists.txt b/testing/tests/unit/count_cohorts_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/count_cohorts_test/CMakeLists.txt rename to testing/tests/unit/count_cohorts_test/CMakeLists.txt diff --git a/testing/unit_testing/count_cohorts_test/test_CountCohorts.pf b/testing/tests/unit/count_cohorts_test/test_CountCohorts.pf similarity index 100% rename from testing/unit_testing/count_cohorts_test/test_CountCohorts.pf rename to testing/tests/unit/count_cohorts_test/test_CountCohorts.pf diff --git a/testing/unit_testing/fire_equations_test/CMakeLists.txt b/testing/tests/unit/fire_equations_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/fire_equations_test/CMakeLists.txt rename to testing/tests/unit/fire_equations_test/CMakeLists.txt diff --git a/testing/unit_testing/fire_equations_test/test_FireEquations.pf b/testing/tests/unit/fire_equations_test/test_FireEquations.pf similarity index 100% rename from testing/unit_testing/fire_equations_test/test_FireEquations.pf rename to testing/tests/unit/fire_equations_test/test_FireEquations.pf diff --git a/testing/unit_testing/fire_fuel_test/CMakeLists.txt b/testing/tests/unit/fire_fuel_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/fire_fuel_test/CMakeLists.txt rename to testing/tests/unit/fire_fuel_test/CMakeLists.txt diff --git a/testing/unit_testing/fire_fuel_test/test_FireFuel.pf b/testing/tests/unit/fire_fuel_test/test_FireFuel.pf similarity index 100% rename from testing/unit_testing/fire_fuel_test/test_FireFuel.pf rename to testing/tests/unit/fire_fuel_test/test_FireFuel.pf diff --git a/testing/unit_testing/fire_weather_test/CMakeLists.txt b/testing/tests/unit/fire_weather_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/fire_weather_test/CMakeLists.txt rename to testing/tests/unit/fire_weather_test/CMakeLists.txt diff --git a/testing/unit_testing/fire_weather_test/test_FireWeather.pf b/testing/tests/unit/fire_weather_test/test_FireWeather.pf similarity index 100% rename from testing/unit_testing/fire_weather_test/test_FireWeather.pf rename to testing/tests/unit/fire_weather_test/test_FireWeather.pf diff --git a/testing/tests/unit/great_circle_test/CMakeLists.txt b/testing/tests/unit/great_circle_test/CMakeLists.txt new file mode 100644 index 0000000000..0f0b72e38b --- /dev/null +++ b/testing/tests/unit/great_circle_test/CMakeLists.txt @@ -0,0 +1,6 @@ +set(pfunit_sources test_GreatCircle.pf) + +add_pfunit_ctest(GreatCircle + TEST_SOURCES "${pfunit_sources}" + LINK_LIBRARIES fates csm_share) + \ No newline at end of file diff --git a/testing/tests/unit/great_circle_test/test_GreatCircle.pf b/testing/tests/unit/great_circle_test/test_GreatCircle.pf new file mode 100644 index 0000000000..8f7b3cec24 --- /dev/null +++ b/testing/tests/unit/great_circle_test/test_GreatCircle.pf @@ -0,0 +1,140 @@ +module test_GreatCircle + ! + ! DESCRIPTION: + ! Add description here + ! + use FatesConstantsMod, only : r8 => fates_r8 + use FatesUtilsMod, only : GreatCircleDist + use FatesConstantsMod, only : earth_radius_eq, rad_per_deg, pi_const + use funit + + implicit none + + @TestCase + type, extends(TestCase) :: TestGreatCircle + + end type TestGreatCircle + + real(r8), parameter :: tol = 1.e-5 + + contains + + @Test + subroutine GreatCircle_ZeroDistance(this) + ! tests that distance from a point to itself is zero + class(TestGreatCircle), intent(inout) :: this + real(r8) :: distance ! returned distance + + distance = GreatCircleDist(45.0_r8, 45.0_r8, 45.0_r8, 45.0_r8) + + @assertEqual(0.0_r8, distance, tol) + + end subroutine GreatCircle_ZeroDistance + + @Test + subroutine GreatCircle_EquatorialDistance(this) + ! moves along equator to test distance + class(TestGreatCircle), intent(inout) :: this + real(r8) :: lat1, lon1 ! point 1 + real(r8) :: lat2, lon2 ! point 2 + real(r8) :: distance ! returned distance + real(r8) :: expected ! expected distance + + ! move along equator + lat1 = 0.0_r8 + lon1 = 0.0_r8 + lat2 = 0.0_r8 + lon2 = 10.0_r8 + + expected = 10.0*rad_per_deg*earth_radius_eq + + distance = GreatCircleDist(lon1, lon2, lat1, lat2) + + @assertEqual(expected, distance, tol) + + end subroutine GreatCircle_EquatorialDistance + + @Test + subroutine GreatCircle_MeridionalDistance(this) + ! moves along a constant longitude + class(TestGreatCircle), intent(inout) :: this + real(r8) :: lat1, lon1 ! point 1 + real(r8) :: lat2, lon2 ! point 2 + real(r8) :: distance ! returned distance + real(r8) :: expected ! expected distance + + ! move along pole + lat1 = -90.0_r8 + lon1 = 0.0_r8 + lat2 = 90.0_r8 + lon2 = 0.0_r8 + + expected = pi_const*earth_radius_eq + + distance = GreatCircleDist(lon1, lon2, lat1, lat2) + + @assertEqual(expected, distance, tol) + + end subroutine GreatCircle_MeridionalDistance + + + @Test + subroutine GreatCircle_DateLineWrap(this) + ! tests that we correctly wrap around the date line + ! i.e., -179 and 179 are 2 degrees apart, not 358 + class(TestGreatCircle), intent(inout) :: this + real(r8) :: distance ! returned distance + real(r8) :: expected ! expected distance + + distance = GreatCircleDist(179.0_r8, -179.0_r8, 0.0_r8, 0.0_r8) + + expected = 2.0*rad_per_deg*earth_radius_eq + + @assertEqual(expected, distance, tol) + + end subroutine GreatCircle_DateLineWrap + + @Test + subroutine GreatCircle_LongitudeNorm(this) + ! tests that code handles inputs outside (-180 to 180) + class(TestGreatCircle), intent(inout) :: this + real(r8) :: distance ! returned distance + real(r8) :: expected ! expected distance + + distance = GreatCircleDist(350.0_r8, 10.0_r8, 0.0_r8, 0.0_r8) + + expected = 20.0*rad_per_deg*earth_radius_eq + + @assertEqual(expected, distance, tol) + + end subroutine GreatCircle_LongitudeNorm + + @Test + subroutine GreatCircle_Antipodes(this) + ! tests opposite sides of Earth + class(TestGreatCircle), intent(inout) :: this + real(r8) :: distance ! returned distance + real(r8) :: expected ! expected distance + + ! From (0,0) to (180,0) - halfway around the world + distance = GreatCircleDist(0.0_r8, 180.0_r8, 0.0_r8, 0.0_r8) + + expected = 4.0_r8*atan(1.0_r8)*earth_radius_eq + + @assertRelativelyEqual(expected, distance, 1.0e-12_r8) + + end subroutine GreatCircle_Antipodes + + @Test + subroutine GreatCircle_PoleConvergence(this) + ! tests that different longitudes at the pole returns 0.0 distance + class(TestGreatCircle), intent(inout) :: this + real(r8) :: distance ! returned distance + + ! different longitudes at the same pole should have 0 distance + distance = GreatCircleDist(0.0_r8, 180.0_r8, 90.0_r8, 90.0_r8) + @assertEqual(0.0_r8, distance, tol) + + end subroutine GreatCircle_PoleConvergence + +end module test_GreatCircle \ No newline at end of file diff --git a/testing/unit_testing/insert_cohort_test/CMakeLists.txt b/testing/tests/unit/insert_cohort_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/insert_cohort_test/CMakeLists.txt rename to testing/tests/unit/insert_cohort_test/CMakeLists.txt diff --git a/testing/unit_testing/insert_cohort_test/test_InsertCohort.pf b/testing/tests/unit/insert_cohort_test/test_InsertCohort.pf similarity index 100% rename from testing/unit_testing/insert_cohort_test/test_InsertCohort.pf rename to testing/tests/unit/insert_cohort_test/test_InsertCohort.pf diff --git a/testing/tests/unit/quadratic_roots_test/CMakeLists.txt b/testing/tests/unit/quadratic_roots_test/CMakeLists.txt new file mode 100644 index 0000000000..4c6c7232fe --- /dev/null +++ b/testing/tests/unit/quadratic_roots_test/CMakeLists.txt @@ -0,0 +1,6 @@ +set(pfunit_sources test_QuadraticRoots.pf) + +add_pfunit_ctest(QuadraticRoots + TEST_SOURCES "${pfunit_sources}" + LINK_LIBRARIES fates csm_share) + \ No newline at end of file diff --git a/testing/tests/unit/quadratic_roots_test/test_QuadraticRoots.pf b/testing/tests/unit/quadratic_roots_test/test_QuadraticRoots.pf new file mode 100644 index 0000000000..44af292a2a --- /dev/null +++ b/testing/tests/unit/quadratic_roots_test/test_QuadraticRoots.pf @@ -0,0 +1,258 @@ +module test_QuadraticRoots + ! + ! DESCRIPTION: + ! Tests the QuadraticRootsNSWC method + ! + use FatesConstantsMod, only : r8 => fates_r8 + use FatesUnitTestUtils, only : endrun_msg + use FatesUtilsMod, only : QuadraticRootsNSWC + use funit + + implicit none + + @TestCase + type, extends(TestCase) :: TestQuadraticRoots + + end type TestQuadraticRoots + + real(r8), parameter :: tol = 1.e-13_r8 + + contains + + @Test + subroutine QuadraticRootsNSWC_DistinctRealRoots(this) + ! Basic sanity check with two distinct, real roots + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! known values + a = 1.0_r8 + b = -5.0_r8 + c = 6.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! roots should be (2.0, 3.0) + + ! sum of roots = -b/a + @assertEqual(5.0_r8, root1 + root2, tol) + + ! products of root = c/a + @assertEqual(6.0_r8, root1 * root2, tol) + + end subroutine QuadraticRootsNSWC_DistinctRealRoots + + + @Test + subroutine QuadraticRootsNSWC_DoubleRoot(this) + ! tests double root (discriminant = 0) + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! known values + a = 1.0_r8 + b = -4.0_r8 + c = 4.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! roots should be (2.0, 2.0) + + @assertEqual(2.0_r8, root1, tol) + @assertEqual(2.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_DoubleRoot + + @Test + subroutine QuadraticRootsNSWC_SimpleLinear(this) + ! tests a simple linear case + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! known values + a = 1.e-35_r8 + b = 2.0_r8 + c = -4.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! root2 should get linear result (-c/b) + ! root1 is 0.0 + @assertEqual(0.0_r8, root1, tol) + @assertEqual(2.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_SimpleLinear + + @Test + subroutine QuadraticRootsNSWC_AllZeroCoeffs(this) + ! tests that the routine handles when all coefficients are zero + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: root1, root2 ! roots + logical :: err ! error + + call QuadraticRootsNSWC(0.0_r8, 0.0_r8, 0.0_r8, root1, root2, err) + + @assertFalse(err) + + ! should be zero output + @assertEqual(0.0_r8, root1, tol) + @assertEqual(0.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_AllZeroCoeffs + + @Test + subroutine QuadraticRootsNSWC_ZeroC(this) + ! tests that the routine handles the c = 0 branch + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! known values + a = 1.0_r8 + b = 2.0_r8 + c = 0.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + @assertEqual(-2.0_r8, root1, tol) + @assertEqual(0.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_ZeroC + + @Test + subroutine QuadraticRootsNSWC_PureQuadratic(this) + ! tests that the routine handles a "pure" quadratic equation + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! Equation x^2 - 4 = 0 => Roots: 2, -2 + a = 1.0_r8 + b = 0.0_r8 + c = -4.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + @assertEqual(-2.0_r8, root1, tol) + @assertEqual(2.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_PureQuadratic + + @Test + subroutine QuadraticRootsNSWC_ImaginaryRoots(this) + ! tests that the routine handles a complex conjugate case + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + character(len=:), allocatable :: expected_msg ! expected error message for failure + + expected_msg = endrun_msg("imaginary roots detected in quadratic solve") + + a = 1.0_r8 + b = 0.0_r8 + c = 1.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertExceptionRaised(expected_msg) + @assertTrue(err) + + end subroutine QuadraticRootsNSWC_ImaginaryRoots + + @Test + subroutine QuadraticRootsNSWC_CatastrophicCancellation(this) + ! tests that the routine handles a catastrophic cancellation case + ! when b >> a or c, make sure we don't lose the smaller root due + ! to subtraction of nearly equal numbers + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! equation x^2 + 10^8x + 1 = 0 + a = 1.0_r8 + b = 1.0e8_r8 + c = 1.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! r1 calculation (-b1 + d)/a: + ! b1 is 5e7. Since b1 > 0, d is made negative. + ! r1 = (-5e7 - 5e7) / 1 = -1e8 + @assertEqual(-1.0e8_r8, root1, tol) + + ! r2 calculation (c/r1)/a: + ! r2 = (1.0 / -1e8) / 1.0 = -1e-8 + @assertEqual(-1.0e-8_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_CatastrophicCancellation + + @Test + subroutine QuadraticRootsNSWC_OverflowAvoidance(this) + ! tests that the routine handles a potential overflow issue + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + ! coefficients that would overflow if squared + a = 1.0e200_r8 + b = 2.0e200_r8 + c = 1.0e200_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! expected roots for (x+1)^2 = 0 are -1, -1 + @assertEqual(-1.0_r8, root1, tol) + @assertEqual(-1.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_OverflowAvoidance + + @Test + subroutine QuadraticRootsNSWC_UnderflowAvoidance(this) + ! tests that the routine handles a potential underflow issue + class(TestQuadraticRoots), intent(inout) :: this + real(r8) :: a, b, c ! arguments + real(r8) :: root1, root2 ! roots + logical :: err ! error + + a = 1.0e-20_r8 ! tiny, but larger than zero + b = 1.0_r8 + c = 1.0_r8 + + call QuadraticRootsNSWC(a, b, c, root1, root2, err) + + @assertFalse(err) + + ! r1 = -b/a = -1.0 / 1e-20 = -1e20 + ! r2 = -c/b = -1.0 / 1.0 = -1.0 + + @assertRelativelyEqual(-1.0e20_r8, root1, tol) + @assertRelativelyEqual(-1.0_r8, root2, tol) + + end subroutine QuadraticRootsNSWC_UnderflowAvoidance + +end module test_QuadraticRoots \ No newline at end of file diff --git a/testing/unit_testing/sort_cohorts_test/CMakeLists.txt b/testing/tests/unit/sort_cohorts_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/sort_cohorts_test/CMakeLists.txt rename to testing/tests/unit/sort_cohorts_test/CMakeLists.txt diff --git a/testing/unit_testing/sort_cohorts_test/test_SortCohorts.pf b/testing/tests/unit/sort_cohorts_test/test_SortCohorts.pf similarity index 100% rename from testing/unit_testing/sort_cohorts_test/test_SortCohorts.pf rename to testing/tests/unit/sort_cohorts_test/test_SortCohorts.pf diff --git a/testing/unit_testing/validate_cohorts_test/CMakeLists.txt b/testing/tests/unit/validate_cohorts_test/CMakeLists.txt similarity index 100% rename from testing/unit_testing/validate_cohorts_test/CMakeLists.txt rename to testing/tests/unit/validate_cohorts_test/CMakeLists.txt diff --git a/testing/unit_testing/validate_cohorts_test/test_ValidateCohorts.pf b/testing/tests/unit/validate_cohorts_test/test_ValidateCohorts.pf similarity index 100% rename from testing/unit_testing/validate_cohorts_test/test_ValidateCohorts.pf rename to testing/tests/unit/validate_cohorts_test/test_ValidateCohorts.pf