diff --git a/.circleci/config.yml b/.circleci/config.yml index 907346eb38..39f1140133 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,6 @@ commands: echo 'export HDF5_LIB=/usr/lib/x86_64-linux-gnu' >> $BASH_ENV echo 'export CHARM_ROOT=$HOME/local/charm-v$CHARM_VER' >> $BASH_ENV echo 'export Grackle_ROOT=$HOME/local' >> $BASH_ENV - echo 'export GRACKLE_INPUT_DATA_DIR=$HOME/grackle/grackle_data_files/input/' >> $BASH_ENV # tag the tip so we can go back to it git tag tip @@ -125,9 +124,24 @@ commands: # convert boolean parameter to an env var storing 0 or 1 SKIP_TEST=$(( 0 <<# parameters.skiptest >> + 1 <> )) USE_DOUBLE=$(( 0 <<# parameters.usedouble >> + 1 <> )) + USE_GRACKLE=$(( 0 <<# parameters.usegrackle >> + 1 <> )) + + # this is used for tests involving Grackle + if [[ $USE_GRACKLE == 1 ]]; then + GRACKLE_INPUT_DATA_DIR="$HOME/grackle/grackle_data_files/input/" + else + GRACKLE_INPUT_DATA_DIR="" + fi if [ ! -f << parameters.skipfile >> ]; then - cmake -DEnzo-E_CONFIG=linux_gcc -GNinja -DUSE_DOUBLE_PREC=<< parameters.usedouble >> -DUSE_GRACKLE=<< parameters.usegrackle >> -Bbuild -DPARALLEL_LAUNCHER_NPROC_ARG="++local;+p" -DPython3_FIND_VIRTUALENV=ONLY + cmake -DEnzo-E_CONFIG=linux_gcc \ + -GNinja \ + -DUSE_DOUBLE_PREC=<< parameters.usedouble >> \ + -DUSE_GRACKLE=<< parameters.usegrackle >> \ + -DGRACKLE_INPUT_DATA_DIR="$GRACKLE_INPUT_DATA_DIR" \ + -Bbuild \ + -DPARALLEL_LAUNCHER_NPROC_ARG="++local;+p" \ + -DPython3_FIND_VIRTUALENV=ONLY cmake --build build -j 4 source $HOME/venv/bin/activate if [[ $SKIP_TEST != 1 ]]; then diff --git a/doc/source/tests/grackle.rst b/doc/source/tests/grackle.rst new file mode 100644 index 0000000000..db07c661f1 --- /dev/null +++ b/doc/source/tests/grackle.rst @@ -0,0 +1,37 @@ +---------------- +Grackle Tests +---------------- + +Tests for the method that invokes Grackle. These tests set up a +cooling test without hydrodynamics to run many one-zone models in +Grackle, fully sampling the density, temperature, and metallicity +parameter space over which the chemistry and cooling/heating tables +are valid. + +method_grackle_general +====================== + +This test compares the summary statistics computed for several grackle +fields after a certain period of time to previously archived values. + +The simulation timesteps are much larger that the cooling/heating. +This makes it more likely that separate processing elements will +execute grackle routines at the same time (thus increasing the chances +of exposing hypothetical problems related to Grackle & SMP mode). + +This test is somewhat fragile given that upgrading Grackle versions could +conceivably alter the field values. In the future it would be better to +replace this with a test that: + +1. checks out and builds a previous commit of Enzo-E +2. runs the simulation and saves the exact field values after running the simulations +3. checks out and builds a newer commit of Enzo-E (while leaving the build of Grackle unchanged) +4. runs the simulation and confirms that the Grackle related field values are identical to the field values from the earlier simulation. + +method_grackle_cooling_dt +========================= + +This test runs Grackle for a fixed number of cycles, and compares the +final simulation time to a reference value. Each simulation timestep +is set fraction of the minimum magnitude of the cooling/heating +timestep. diff --git a/doc/source/tests/index.rst b/doc/source/tests/index.rst index b583de5442..ab5233951b 100644 --- a/doc/source/tests/index.rst +++ b/doc/source/tests/index.rst @@ -14,6 +14,7 @@ This testing section describes tests on enzo-e program collapse cosmology gravity + grackle heat helloworld hierarchy diff --git a/input/Cosmology/method_cosmology.incl b/input/Cosmology/method_cosmology.incl index 9f8ec12bc2..dad556d9e3 100644 --- a/input/Cosmology/method_cosmology.incl +++ b/input/Cosmology/method_cosmology.incl @@ -324,7 +324,7 @@ "is_local", "default"]; position = [ "x", "y", "z" ]; velocity = [ "vx", "vy", "vz" ]; - constants = [ "mass", "double", 2.5581226e-5 ]; + constants = [ "mass", "default", 2.5581226e-5 ]; group_list = [ "is_gravitating" ]; } } diff --git a/input/Grackle/grackle.incl b/input/Grackle/grackle.incl new file mode 100644 index 0000000000..5ddceea9c2 --- /dev/null +++ b/input/Grackle/grackle.incl @@ -0,0 +1,212 @@ +# +# Grackle Test Problem: +# +# Sets up a cooling test without hydrodynamics to run many +# one-zone models in Grackle, fully sampling the density, +# temperature, and metallicity parameter space over which +# the chemistry and cooling/heating tables are valid. +# +# When including this file to launch a simulation, be sure to: +# - overwrite the entry in Method:grackle:data_file parameter +# - specify a stopping condition +# - specify an output schedule for the "data" output fileset +# (or drop that fileset from the output list) + + Boundary { + type = "reflecting"; + } + + Domain { + lower = [ 0.0, 0.0, 0.0]; + upper = [ 1.0, 1.0, 1.0]; + } + + Units { + density = 1.6726219E-24; # m_H in grams... so n = 1.0 cm^-3 for pure hydrogen + time = 3.15576E13; # 1 Myr in seconds + length = 3.086E16; # 0.01 pc in cm - does not actually matter + } + + Field { + alignment = 8; + gamma = 1.400; + ghost_depth = 3; + list = [ "density", + "internal_energy", + "total_energy", + "velocity_x", + "velocity_y", + "velocity_z", +# purposefully commenting these out to test automatic +# creation of needed fields +# "HI_density", +# "HII_density", +# "HM_density", +# "HeI_density", +# "HeII_density", +# "HeIII_density", +# "H2I_density", +# "H2II_density", +# "DI_density", +# "DII_density", +# "HDI_density", +# "e_density", +# "metal_density", +# the following fields must be explicitly defined since +# we want to add them to the "derived" group + "cooling_time", + "temperature", + "pressure" + ]; + + padding = 0; + } + + Group { + list = ["color", "derived"]; + + # fields that should be advected by hydro MUST be defined + # as color here. + # This is handled automatically in EnzoMethodGrackle for + # the Grackle-specific fields (species). But, other + # passively advected fields must be added manually. + # + # As a test of the automatic field machinery, we comment + # out all of the grackle-specific-fields + color { + field_list = []; + #"HI_density", + #"HII_density", + #"HM_density", + #"HeI_density", + #"HeII_density", + #"HeIII_density", + #"H2I_density", + #"H2II_density", + #"DI_density", + #"DII_density", + #"HDI_density", + #"e_density", + #"metal_density"]; + } + +# derived fields grouping enables Enzo-E to ensure +# that all derived fields are updated prior to output +# In order for this to work, these MUST have their own +# compute classes. (these need to manually be added to the + derived { + field_list = ["temperature", + "pressure", + "cooling_time"]; + } + } + + Initial { + list = ["grackle_test"]; + grackle_test { + # See note in "Mesh" on running in < 3D + + minimum_H_number_density = 0.0001; + maximum_H_number_density = 1000.0; + minimum_metallicity = 1.0; + maximum_metallicity = 1.0; + minimum_temperature = 10.0; + maximum_temperature = 1.0E8; + + # Keep temperature constant with changing mu? + reset_energies = 0; + + dt = 100.0; + } + } + + Mesh { + # This test problem varies density, temperature, + # and metallicity over x, y, and z dimensions + # respectively. For smaller problems you can + # run in 1 or 2 dimensions by changing the below + # accordingly and setting the min/max values in + # initialization to be the same. Currently cannot + # pick which value goes in which dimension, + # so 2D can only be density and temperature, + # 1D can only vary density. + + #root_blocks = [ 4, 4]; + #root_rank = 2; + #root_size = [128, 128]; + + # 3D test example: + root_blocks = [ 4, 4, 4]; + root_rank = 3; + root_size = [32, 32, 32]; + + } + + Method { + list = [ "grackle", "null"]; + + grackle { + courant = 0.40; # meaningless unless use_cooling_timestep = true; + + data_file = "CloudyData_UVB=HM2012_shielded.h5"; + + with_radiative_cooling = 1; + primordial_chemistry = 3; # 1, 2, or 3 + metal_cooling = 1; # 0 or 1 (off/on) + UVbackground = 1; # on or off + self_shielding_method = 3; # 0 - 3 (0 or 3 recommended) + + HydrogenFractionByMass = 0.73; + + # set this to true to limit the maximum timestep to the product of the + # minimum cooling/heating time and courant. + use_cooling_timestep = false; # default is false + } + + # use this to limit maximum timestep if grackle::use_cooling_timestep is + # set to false and Grackle crashes due to max iteration limit - this is + # not needed generally in a real simulation as hydro timestep will + # be small (usually) + null { dt = 100.0; } + + } + + Output { + list = ["data"]; + + data { + field_list = [ "density", + "internal_energy", + "total_energy", + "velocity_x", + "velocity_y", + "velocity_z", + "HI_density", + "HII_density", + "HM_density", + "HeI_density", + "HeII_density", + "HeIII_density", + "H2I_density", + "H2II_density", + "DI_density", + "DII_density", + "HDI_density", + "e_density", + "metal_density", + "cooling_time", + "temperature", + "pressure"]; + + dir = ["GRACKLE_TEST_%03d","cycle"]; + name = [ "method_grackle-1-%03d.h5", "proc" ]; + schedule { + var = "time"; + step = 1000.0; + start = 0.0; + } + type = "data"; + } + + } + diff --git a/input/Grackle/method_grackle_cooling_dt.in b/input/Grackle/method_grackle_cooling_dt.in new file mode 100644 index 0000000000..63ac8f8263 --- /dev/null +++ b/input/Grackle/method_grackle_cooling_dt.in @@ -0,0 +1,24 @@ +# this file is used for a test where the cooling time is used to limit +# the size of the timestep +# +# The automated test will also overwrite the entry in +# Method:grackle:data_file parameter with a valid path + +include "input/Grackle/grackle.incl" + +Output { list = []; } # don't write any outputs + +Method { + list = [ "grackle" ]; # intentionally omit "null" + grackle { + courant = 0.40; + use_cooling_timestep = true; + } +} + +Testing { + time_final = [0.00201833123232718]; + time_tolerance = 1.0e-4; +} + +Stopping { cycle = 20; } \ No newline at end of file diff --git a/input/Grackle/method_grackle_general.in b/input/Grackle/method_grackle_general.in new file mode 100644 index 0000000000..677dcef8d0 --- /dev/null +++ b/input/Grackle/method_grackle_general.in @@ -0,0 +1,19 @@ +# this file is used for checking general consistency of grackle results +# (without using a timestep based on the minimum cooling time) +# +# The automated test will also overwrite the entry in +# Method:grackle:data_file parameter with a valid path + +include "input/Grackle/grackle.incl" + +Output { + data { + dir = ["GeneralGrackle-%06.2f", "time"]; + schedule { + var = "time"; + list = [500.0]; + } + } +} + +Stopping { time = 500.0; } \ No newline at end of file diff --git a/input/Grackle/ref_general_grackle-double.csv b/input/Grackle/ref_general_grackle-double.csv new file mode 100644 index 0000000000..1d5be4236e --- /dev/null +++ b/input/Grackle/ref_general_grackle-double.csv @@ -0,0 +1,6 @@ +# {"code unit definitions": {"code_length": [3.086e+16, "cm"], "code_mass": [4.9157019637146824e+25, "g"], "code_density": [1.6726219e-24, "g/cm**3"], "code_specific_energy": [956277.4378779504, "erg/g"], "code_time": [31557600000000.0, "s"], "code_magnetic": [4.483279099741026e-09, "G"], "code_temperature": [1.0, "K"], "code_pressure": [1.5994905850705492e-18, "dyn/cm**2"], "code_velocity": [977.8943899409335, "cm/s"], "code_metallicity": [1.0, "dimensionless"]}, "field units": {"pressure": "dimensionless", "temperature": "code_temperature", "cooling_time": "dimensionless"}} +# +# name,min,min_xloc,min_yloc,min_zloc,max,max_xloc,max_yloc,max_zloc,mean,standard_deviation +pressure,1.520195900675427e+02,7.812500000000000e-02,1.718750000000000e-01,1.562500000000000e-02,2.753947492701290e+06,7.812500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,6.660831661417476e+04,3.557307908963668e+05 +temperature,3.330949646514823e+00,2.343750000000000e-01,1.718750000000000e-01,1.562500000000000e-02,6.376232820586985e+06,1.562500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,2.417771759620777e+05,1.115430127734497e+06 +cooling_time,-6.450617403727239e+04,1.562500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,3.866436114544078e+03,1.093750000000000e-01,1.093750000000000e-01,1.562500000000000e-02,-1.576307136350389e+03,8.485636211531526e+03 diff --git a/input/Grackle/ref_general_grackle-single.csv b/input/Grackle/ref_general_grackle-single.csv new file mode 100644 index 0000000000..edf8f81fd5 --- /dev/null +++ b/input/Grackle/ref_general_grackle-single.csv @@ -0,0 +1,6 @@ +# {"code unit definitions": {"code_length": [3.086e+16, "cm"], "code_mass": [4.9157019637146824e+25, "g"], "code_density": [1.6726219e-24, "g/cm**3"], "code_specific_energy": [956277.4378779504, "erg/g"], "code_time": [31557600000000.0, "s"], "code_magnetic": [4.483279099741026e-09, "G"], "code_temperature": [1.0, "K"], "code_pressure": [1.5994905850705492e-18, "dyn/cm**2"], "code_velocity": [977.8943899409335, "cm/s"], "code_metallicity": [1.0, "dimensionless"]}, "field units": {"pressure": "dimensionless", "temperature": "code_temperature", "cooling_time": "dimensionless"}} +# +# name,min,min_xloc,min_yloc,min_zloc,max,max_xloc,max_yloc,max_zloc,mean,standard_deviation +pressure,1.448596228495553e+02,7.812500000000000e-02,2.031250000000000e-01,1.562500000000000e-02,2.754013931249404e+06,7.812500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,6.668474451175868e+04,3.557327613678277e+05 +temperature,3.444945335388184e+00,2.343750000000000e-01,2.031250000000000e-01,1.562500000000000e-02,6.376238000000000e+06,1.562500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,2.417772680167406e+05,1.115433169502840e+06 +cooling_time,-6.450619921875000e+04,1.562500000000000e-02,2.343750000000000e-01,1.562500000000000e-02,1.856555786132812e+03,4.687500000000000e-02,7.812500000000000e-02,1.562500000000000e-02,-1.300657236445695e+03,8.072030883837461e+03 \ No newline at end of file diff --git a/input/Grackle/run_endtime_grackle_test.py b/input/Grackle/run_endtime_grackle_test.py new file mode 100644 index 0000000000..35e08cd9df --- /dev/null +++ b/input/Grackle/run_endtime_grackle_test.py @@ -0,0 +1,144 @@ +import argparse +import os.path +import sys + +_LOCAL_DIR = os.path.dirname(os.path.realpath(__file__)) +_TOOLS_DIR = os.path.join(_LOCAL_DIR, "../../tools") +if os.path.isdir(_TOOLS_DIR): + sys.path.insert(0, _TOOLS_DIR) + from gen_grackle_testing_file import generate_grackle_input_file + from run_cpp_test import run_test_and_check_success + +else: + raise RuntimeError( + f"expected testing utilities to be defined in {_TOOLS_DIR}, but that " + "directory does not exist" + ) + +parent_parser = argparse.ArgumentParser(add_help=False) + +parent_parser.add_argument( + "--grackle-data-file", required = True, type = str, + help = ("Specifies the path to the grackle data file that is to be used in " + "the simultaion.") +) +parent_parser.add_argument( + "--generate-config-path", required = True, type = str, + help = ("Specifies the path to the configuration file that is generated by " + "this program. The generated file includes the contents of " + "--nominal-config-path and overwrites path to the grackle data " + "file based on --grackle-data-path") +) +parent_parser.add_argument( + '--launch_cmd', required = True, type = str, + help = "Specifies the commands used to launch the Enzo-E simulation" +) +parent_parser.add_argument( + "--output-dump", action = "store", default = None, + help = ("Specifies the path where a copy of the standard output stream " + "from the execution of the program should optionally be dumped " + "(the data is still written to the standard output stream). The " + "contents of this file may be used to determine the outcome of " + "the tests.") +) + +_description = '''\ +Runs a test Grackle-related test that succeeds or fails based on the completion +time of the test. The success/failure of the test is reflected by the return +code of this program (an exit code of 0 indicates the test was entirely +successful). +''' + +_epilog = '''\ +In more detail, this function expects most of the test problem's parameters to +be specified by the file at the location given by --nominal_config_path. + +The program will generate a new configuration file that uses all of the +parameters from the --nominal-config-path file but overwrites the parameter +used to specify the grackle data file with the value specified by +--grackle-data-file. The generated config file is written to the path given by +--generate-config-path. + +Finally, the program executes Enzo-E with this generated configuration +file and reports whether the tests have passed. +''' + +parser = argparse.ArgumentParser(description = _description, epilog = _epilog, + parents = [parent_parser]) +parser.add_argument( + "--nominal-config-path", required = True, type = str, + help = ("Specifies the path to the configuration file that specifies most " + "parameters for the test problem.") +) + + + +def run_grackle_test(launcher, nominal_config_path, generate_config_path, + grackle_data_file, dump_path = None): + """ + Runs an enzo-e simulation test problem involving Grackle. + + In detail, this function: + - expects most of the test problem's parameters to be specified by the + file at the location given by `nominal_config_path`. + - generates a new configuration file at the path given by + `generate_config_path`. This file includes all of the parameters from + the `nominal_config_path`, but overwrites the parameter used to specify + the grackle data file with the value specified by `grackle_data_dir`. + - the function then executes enzo-e with this generated configuration + file and reports whether all tests built into the simulation (e.g. an + expected completion time) have passed, if there are any. + + Parameters + ---------- + launcher: str + Specifies the command used to launch enzo-e. + nominal_config_path: str + Specifies the path to the config file that specifies the bulk of the + generate_config_path: str + Specifies the path where the temporary input file should be written. + grackle_data_file: str + Specifies the path to the grackle data file that is to be used by the + test problem. + dump_path: str, optional + Path to a file where the output of the simulation should be written. + If this is None (the default), the output is written to a temporary + file. + + Returns + ------- + tests_pass: bool + Specifies whether the simulation ran successfully and whether all tests + that are built into the simulation have passed (if there are any). + """ + + print("generating config file at {} that uses the grackle data file at {}"\ + .format(generate_config_path, grackle_data_file)) + generate_grackle_input_file( + include_path = nominal_config_path, + data_path = grackle_data_file, + use_abs_paths = True, + output_fname = generate_config_path + ) + + print("Executing Enzo-E") + test_passes = run_test_and_check_success( + command = launcher, args_for_command = [generate_config_path], + dump_path = "cooling-test.in" + ) + return test_passes + +if __name__ == '__main__': + args = parser.parse_args() + + test_passes = run_grackle_test( + launcher = args.launch_cmd, + nominal_config_path = args.nominal_config_path, + generate_config_path = args.generate_config_path, + grackle_data_file = args.grackle_data_file, + dump_path = args.output_dump + ) + if test_passes: + sys.exit(0) + else: + sys.exit(1) diff --git a/input/Grackle/run_general_grackle_test.py b/input/Grackle/run_general_grackle_test.py new file mode 100644 index 0000000000..cdf063bd22 --- /dev/null +++ b/input/Grackle/run_general_grackle_test.py @@ -0,0 +1,79 @@ +#!/bin/python + +# runs a generic Grackle test where we compare summary statistics about the +# fields at the final snapshot + +import argparse +import os.path +import sys + +from run_endtime_grackle_test import run_grackle_test, parent_parser + +_LOCAL_DIR = os.path.dirname(os.path.realpath(__file__)) +_TOOLS_DIR = os.path.join(_LOCAL_DIR, "../../tools") +if os.path.isdir(_TOOLS_DIR): + sys.path.insert(0, _TOOLS_DIR) + from field_summary import compare_against_reference + +else: + raise RuntimeError( + f"expected testing utilities to be defined in {_TOOLS_DIR}, but that " + "directory does not exist" + ) + + + + +parser = argparse.ArgumentParser( + description = ("Runs a general Grackle test that compares summary " + "statistics at a completion time with some reference " + "values."), + parents = [parent_parser] +) + +parser.add_argument( + "--prec", required = True, type = str, choices = ["single", "double"], + help = "Specifies the precision of Enzo-E." +) + +if __name__ == '__main__': + args = parser.parse_args() + + test_passes = run_grackle_test( + launcher = args.launch_cmd, + nominal_config_path = os.path.join(_LOCAL_DIR, + 'method_grackle_general.in'), + generate_config_path = args.generate_config_path, + grackle_data_file = args.grackle_data_file, + dump_path = args.output_dump + ) + # note that there aren't actually any built-in tests in this test problem. + # Thus, if test_passes is False, that means that Enzo-E crashed + if not test_passes: + raise RuntimeError("Enzo-E crashed") + + + # now check field values against the reference values + if args.prec == 'double': + _ref_tab = os.path.join(_LOCAL_DIR, 'ref_general_grackle-double.csv') + atol = 0 + # these should be flexible for different compiler versions + rtol = {"min" : 5e-15, "max" : 5e-6, "mean" : 5e-8, + "standard_deviation" : 5e-8} + else: + _ref_tab = os.path.join(_LOCAL_DIR, 'ref_general_grackle-single.csv') + atol = 0 + # the following may need to be relaxed for different compiler versions + rtol = dict((k, 1e-7) for k in ["min","max","mean", + "standard_deviation"]) + + test_passes = compare_against_reference( + './GeneralGrackle-500.00/GeneralGrackle-500.00.block_list', + ref_summary_file_path = _ref_tab, + atol = atol, rtol = rtol, report_path = None + ) + + if test_passes: + sys.exit(0) + else: + sys.exit(1) diff --git a/input/PPML/ppml.incl b/input/PPML/ppml.incl index 9ddd7b9584..02450fc29f 100644 --- a/input/PPML/ppml.incl +++ b/input/PPML/ppml.incl @@ -102,8 +102,8 @@ Testing { - time_final = [0.00294580065398299, - 0.00294612924335524 ]; + time_final = [0.0029466382402461, # single-precision + 0.00294612924335524 ]; # double-precision time_tolerance = 1.0e-4; } diff --git a/src/Enzo/enzo_EnzoInitialGrackleTest.cpp b/src/Enzo/enzo_EnzoInitialGrackleTest.cpp index 972b9e3106..d24187b88a 100644 --- a/src/Enzo/enzo_EnzoInitialGrackleTest.cpp +++ b/src/Enzo/enzo_EnzoInitialGrackleTest.cpp @@ -70,8 +70,6 @@ void EnzoInitialGrackleTest::enforce_block gr_float * total_energy = (gr_float *) field.values("total_energy"); - gr_float * gamma = (gr_float *) field.values("gamma"); - enzo_float * pressure = field.is_field("pressure") ? (enzo_float*) field.values("pressure") : NULL; enzo_float * temperature = field.is_field("temperature") ? @@ -193,7 +191,6 @@ void EnzoInitialGrackleTest::enforce_block log10(enzo_config->initial_grackle_test_minimum_temperature)))/ mu / enzo_units->temperature() / (enzo_config->field_gamma - 1.0); total_energy[i] = grackle_fields_.internal_energy[i]; - gamma[i] = enzo_config->field_gamma; } } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index db8a0a9e7b..90cb4751f0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -174,6 +174,46 @@ if (USE_YT_BASED_TESTS) setup_test_parallel_python(merge_sinks_stationary_parallel merge_sinks/stationary/parallel "input/merge_sinks/run_merge_sinks_test.py" "--prec=${PREC_STRING}" "--ics_type=stationary") setup_test_serial_python(merge_sinks_drift_serial merge_sinks/drift/serial "input/merge_sinks/run_merge_sinks_test.py" "--prec=${PREC_STRING}" "--ics_type=drift") setup_test_parallel_python(merge_sinks_drift_parallel merge_sinks/drift/parallel "input/merge_sinks/run_merge_sinks_test.py" "--prec=${PREC_STRING}" "--ics_type=drift") + +endif() + +# Grackle tests +# +# because Grackle is a separate library, the user needs to specify the path +# to the data directory where the grackle data files are installed. if this is +# not specified, the Grackle tests are skipped + +if(NOT (DEFINED GRACKLE_INPUT_DATA_DIR)) + set(GRACKLE_INPUT_DATA_DIR "") +endif() + +if(USE_GRACKLE AND (GRACKLE_INPUT_DATA_DIR STREQUAL "")) + message(STATUS + " IMPORTANT: No tests involving Grackle have been defined (even though Grackle is being used). To enable these tests, try `-DGRACKLE_INPUT_DATA_DIR=/path/to/grackle/data/dir`." + ) +elseif(USE_GRACKLE) + # (non-yt-based test) + setup_test_parallel_python(grackle_cooling_dt grackle + "input/Grackle/run_endtime_grackle_test.py" + "--grackle-data-file=${GRACKLE_INPUT_DATA_DIR}/CloudyData_UVB=HM2012_shielded.h5" + "--nominal-config-path=input/Grackle/method_grackle_cooling_dt.in" + # by using relative paths in the following 2 arguments, we effectively + # specify output files in the testing directory + "--generate-config-path=./temp_grackle_cooling_dt.in" + "--output-dump=./grackle_cooling_dt.log" + ) + + if (USE_YT_BASED_TESTS) + setup_test_parallel_python(grackle_general grackle + "input/Grackle/run_general_grackle_test.py" + "--prec=${PREC_STRING}" + "--grackle-data-file=${GRACKLE_INPUT_DATA_DIR}/CloudyData_UVB=HM2012_shielded.h5" + # by using relative paths in the following 2 arguments, we effectively + # specify output files in the testing directory + "--generate-config-path=./temp_grackle_general.in" + "--output-dump=./grackle_general_test.log" + ) + endif() endif() # Convert markdown file to html file for more flexible viewing diff --git a/tools/field_summary.py b/tools/field_summary.py new file mode 100644 index 0000000000..fba8cf076d --- /dev/null +++ b/tools/field_summary.py @@ -0,0 +1,454 @@ +import argparse +import io +import json + +import numpy as np +import yt + +from l1_error_norm import StoreCommaListAction +from test_report import create_test_report, create_dummy_report + +if 'enzo_p' in yt.frontends.__dict__.keys(): + _ENZOE_FLUID_TYPE = 'enzop' # for older versions of yt +else: + _ENZOE_FLUID_TYPE = 'enzoe' + +_TABLE_DTYPE_ARGS = [ + ('name', object), + ('min', 'f8'), + ('min_xloc', 'f8'), + ('min_yloc', 'f8'), + ('min_zloc', 'f8'), + ('max', 'f8'), + ('max_xloc', 'f8'), + ('max_yloc', 'f8'), + ('max_zloc', 'f8'), + ('mean', 'f8'), + ('standard_deviation', 'f8') +] + +_TABLE_COLS = frozenset(col for col, _ in _TABLE_DTYPE_ARGS) + +_FIXED_COL_UNITS = dict((col, 'code_length') for col,_ in _TABLE_DTYPE_ARGS \ + if col[-5:] in ['_xloc', '_yloc', '_zloc']) + +# sequence of tuples, mapping a yt derived_quantities to table columns. +_QUANTITY_ENTRY_SETS = [ + ('min_location', ('min', 'min_xloc', 'min_yloc', 'min_zloc'), {}), + ('max_location', ('max', 'max_xloc', 'max_yloc', 'max_zloc'), {}), + ('weighted_standard_deviation', ('standard_deviation', 'mean'), + {'weight' : ("gas", "cell_volume")}), +] + +_FMT_DICT = {object : 's', 'f8' : '.15e'} +_TABLE_FMT = ','.join('%'+_FMT_DICT[dtype] for _, dtype in _TABLE_DTYPE_ARGS) +_TABLE_COLUMNS = [col_name for col_name,_ in _TABLE_DTYPE_ARGS] +_TABLE_DTYPE = np.dtype(_TABLE_DTYPE_ARGS) + +def construct_new_field_table(field_names): + array = np.zeros((len(field_names),), dtype = _TABLE_DTYPE) + for i,name in enumerate(field_names): + array[i]['name'] = name + return array + +def _get_sim_props(ds, field_names): + # determine the code unit definitions + code_unit_defs = {} + for unit in ds.unit_registry.keys(): + if 'code_' != unit[:5]: + continue + converted = ds.quan(1.0,unit).in_base('cgs') + code_unit_defs[unit] = [float(converted.v), str(converted.units)] + + # determine the default units of each field + + ds.field_list # this is necessary for initializing ds.field_info + field_units = {} + for field in field_names: + dflt_units = ds.field_info[_ENZOE_FLUID_TYPE,field].units + # it may not be necessary to complete the following line + converted = str(ds.quan(1.0, dflt_units).in_base('code').units) + field_units[field] = converted + return {'code unit definitions' : code_unit_defs, + 'field units' : field_units} + +def measure_field_summary(target_path, field_names): + target_ds = yt.load(target_path) + sim_props = _get_sim_props(target_ds, field_names) + + computed_cols = set() # initialized for a sanity check! + + # initialize columns + field_table = construct_new_field_table(field_names) + computed_cols.add('name') # for sanity check + + # now, let's compute the field properties + ad = target_ds.all_data() + for derived_quantity, colnames, kwargs in _QUANTITY_ENTRY_SETS: + for colname in colnames: + assert colname not in computed_cols # sanity check + computed_cols.add(colname) + + if ((derived_quantity == 'weighted_standard_deviation') and + (not hasattr(ad.quantities, derived_quantity))): + # in older versions of yt, this quantity was mis-named (it still + # computes that weighted standard deviation) + derived_quantity = 'weighted_variance' + + func = getattr(ad.quantities,derived_quantity) + for row_ind, field_name in enumerate(field_names): + full_field_name = (_ENZOE_FLUID_TYPE, field_name) + rslt = func(full_field_name, **kwargs) + for i,val in enumerate(rslt): + colname = colnames[i] + if colnames in _FIXED_COL_UNITS: + normalized = val.to(_FIXED_COL_UNITS[colnames]) + else: + normalized = val.in_base('code').v + field_table[row_ind][colname] = normalized + + expected_cols = field_table.dtype.fields.keys() + assert len(computed_cols.symmetric_difference(expected_cols)) == 0 + + return field_table,sim_props + +def write_field_summary(fname, field_table, sim_props = None): + if hasattr(fname, 'write'): + f = fname + close_file = False + else: + f = open(fname, 'w') + close_file = True + + if sim_props is not None: + f.write('# ') + f.write(json.dumps(sim_props)) + f.write('\n#\n') + + np.savetxt(f, field_table, fmt = _TABLE_FMT, + header = ','.join(_TABLE_COLUMNS)) + + if close_file: + f.close() + +def read_field_summary(fname): + str_converter = lambda arg : arg.decode("utf-8") + return np.genfromtxt(fname, dtype = _TABLE_DTYPE, + converters = {0: str_converter}, delimiter = ',') + +def _check_consistent_cols_and_names(cur_field_table, ref_field_table, tr): + # check for consistent columns + cur_cols = cur_field_table.dtype.fields.keys() + ref_cols = ref_field_table.dtype.fields.keys() + consistent_cols = len(set(cur_cols).symmetric_difference(ref_cols)) == 0 + if not consistent_cols: + tr.fail( + ('Column Mismatch. Current table has the columns {!r} and the ' + 'reference table has {!r}').format(list(cur_cols), list(ref_cols)) + ) + + + # check for consistent field names (for now, report different field + # orderings as a problem) + cur_names = cur_field_table['name'] + ref_names = ref_field_table['name'] + consistent_fields = ((len(cur_field_table) == len(ref_field_table)) and + (cur_names == ref_names).all()) + if not consistent_fields: + tr.fail( + ('Field Name Mismatch; current table stores data for {!r} while ' + 'the reference stores data for {!r}').format(cur_names.tolist(), + ref_names.tolist()) + ) + return consistent_cols and consistent_fields + +def test_equivalent_field_tables(cur_field_table, ref_field_table, + atol = 1e-15, rtol = 1e-14, + test_report = None): + if test_report is None: + tr = DummyReport() + else: + tr = test_report + + tr.write('Comparing Field Tables\n') + + # first check that table properties are consistent + consistent_table_prop = _check_consistent_cols_and_names(cur_field_table, + ref_field_table, + tr) + if not consistent_table_prop: # exit early when inconsistent + tr.incomplete('Aborting further comparisons') + return False + + tr.write( + 'General table properties are consistent. Proceeding with comparison\n' + ) + + colnames = tuple(ref_field_table.dtype.fields.keys()) + + filtered_colnames = list(filter(lambda col: col != 'name', colnames)) + for tol, tolname in [(atol, 'atol'), (rtol, 'rtol')]: + tr.write(f'{tolname} = {tol.represent_as_string(filtered_colnames)}\n') + + # Now actually verify equality of properties + comparison_arr = np.empty((len(cur_field_table), len(colnames)), + dtype = np.bool_) + for j,col in enumerate(colnames): + if col == "name": + comparison_arr[:,j] = True + else: + cur_vals = cur_field_table[col] + ref_vals = ref_field_table[col] + comparison_arr[:,j] = np.isclose(cur_vals, ref_vals, + rtol = rtol[col], atol = atol[col], + equal_nan = False) + + all_consistent_vals = True + # Finally check comparison results on a row-by-row basis + for i, name in enumerate(cur_field_table['name']): + comparison_passes = comparison_arr[i,:].all() + if comparison_passes: + tr.passing( + 'All Summary properties for "{}" are consistent'.format(name) + ) + else: + num_consistent_props = 0 + inconsistent_comparisons = [] + for j,col in enumerate(colnames): + if col == "name": + continue + elif comparison_arr[i,j]: + num_consistent_props += 1 + continue + inconsistent_comparisons.append( + "'{}':\t cur = {:.15e}\tref = {:.15e}".format( + col, cur_field_table[i][col], ref_field_table[i][col] + ) + ) + num_props = num_consistent_props + len(inconsistent_comparisons) + tmp='{} of {} summary properties are inconsistent for "{}"'.format( + num_props - num_consistent_props, num_props, name + ) + tr.fail('\n '.join([tmp] + inconsistent_comparisons)) + all_consistent_vals = False + return all_consistent_vals + +def _main_measure(args): + # Program to use by the measure subcommand. Just construct and report the + # field summary table + print("Measuring the Field Summary Properties") + field_table, sim_props = measure_field_summary( + args.target_path, field_names = args.fields + ) + + if args.output == '-': + output = io.StringIO() + sim_props = None + else: + print("Writing Field Summary Table to " + args.output) + output = open(args.output, 'w') + + write_field_summary(output, field_table, sim_props = sim_props) + + if isinstance(output, io.StringIO): + print("Printing Field Summary Table:\n") + print(output.getvalue()) + output.close() + +class ToleranceConfig: + def __init__(self, fallback_tol, col_specific_vals): + self._fallback_tol = fallback_tol + self._col_specific_vals = col_specific_vals + + def __getitem__(self, key): + return self._col_specific_vals.get(key, self._fallback_tol) + + def represent_as_string(self, colnames): + if len(self._col_specific_vals) == 0: + return str(self._fallback_tol) + else: + return str(dict((col,self[col]) for col in colnames)) + + @classmethod + def factory(cls, tol_val, tol_name, parse_from_str = False): + """ + Constructs a ToleranceConfig instance based on `tol_val`. + + Parameters + ---------- + tol_val + This must be an int or float which describes the tolerance for all + columns. Alternatively this can be a dict that associates + tolerances with columns of the summary table (columns without + entries have a tolerance of 0). + tol_name: str + Specifies the type of tolerance that is being processed. This is + only used for more descriptive error messages. + parse_from_str: bool + When True, tol_val is expected to be a string that directly encodes + the int or float value or a string that encodes a JSON object that + corresponds to the dict format described above. + """ + + if parse_from_str: + try: + tmp = json.loads(tol_val) + except json.JSONDecodeError: + tmp = None + elif isinstance(tol_val, cls): + return tol_val + else: + tmp = tol_val + + if isinstance(tmp, (float, int)): + return cls(fallback_tol = tmp, col_specific_vals = {}) + + elif isinstance(tmp, dict): + for col, val in tmp.items(): # check contents of dict + if col not in _TABLE_COLS: + raise ValueError(f"{tol_name} specified for unknown table " + f"column: {col}") + elif not isinstance(val, (int,float)): + raise ValueError(f"{tol_name} for '{col}', {val}, isn't " + "an int or float") + return cls(fallback_tol = 0., col_specific_vals = tmp) + + else: + raise ValueError( + f"{tol_name} option expects an int/float or a dictionary " + "object that pairs summary table column-names with int/floats. " + f"\n Received: {tol_val!r}" + ) + +def compare_against_reference(snap_path, ref_summary_file_path, + atol, rtol, report_path = None): + """ + Computes summary statistics for fields from the snapshot at the path + specified by snap_path and compares them against reference values + + Parameters + ---------- + snap_path: str + Path to the snapshot that is being tested. + ref_summary_path: str + Path to the csv file holding the reference summary statistics. + atol, rtol + Specifies the absolute and relative tolerances, respectively, for the + comparison. These arguments are allowed to be int or floats (which + apply for all summary statistics), dicts that provide tolerances for + particular summary statistics, or instances of ToleranceConfig. + report_path: str, optional + When specified, this gives a path where an output report is written + describing the outcome of the comparison. + """ + atol = ToleranceConfig.factory(atol, 'atol', parse_from_str = False) + rtol = ToleranceConfig.factory(rtol, 'rtol', parse_from_str = False) + + print("Loading the reference table to identify the fields that are to be " + "summarized") + ref_field_table = read_field_summary(ref_summary_file_path) + + field_names = ref_field_table['name'].tolist() + print("Measuring the Field Summary Properties") + cur_field_table, sim_props = measure_field_summary( + snap_path, field_names = field_names + ) + + if report_path is None: + report_creator = create_dummy_report + else: + report_creator = create_test_report + + with report_creator(report_path, clobber = True) as tr: + print("Comparing field summary tables") + test_rslt = test_equivalent_field_tables(cur_field_table, + ref_field_table, + atol = atol, rtol = rtol, + test_report = tr) + if test_rslt: + print("Field summary tables are consistent") + else: + print("Field summary tables are inconsistent") + return test_rslt + +def _main_cmp(args): + # Program to use by the cmp subcommand. Just construct the field summary + # table and compare against a reference + + atol = ToleranceConfig.factory(args.atol, 'atol', parse_from_str = True) + rtol = ToleranceConfig.factory(args.rtol, 'rtol', parse_from_str = True) + + return compare_against_reference(snap_path = args.target_path, + ref_summary_file_path = args.ref, + atol = atol, rtol = rtol, + report_path = args.report) + + +# define command line interface! +parser = argparse.ArgumentParser( + description = ("Measures and compares field summary statistics from " + "Enzo-E results.") +) +subparsers = parser.add_subparsers(help='sub-command help', dest = 'command') + +def _add_target_path_arg(parser): + parser.add_argument( + 'target_path', action = 'store', + help = ("The path to the block_list file of the simulation from which " + "the field properties should be computed.") + ) + +measure_parser = subparsers.add_parser( + 'measure', help = 'Measures the field summary information' +) +_add_target_path_arg(measure_parser) +measure_parser.add_argument( + '-f','--fields', action = StoreCommaListAction, required = True, + help = ("Specify the list of fields to use to compute the error norm. The " + "field names should be separated by commas and have no spaces.") +) +measure_parser.add_argument( + '-o','--output', required = True, + help = ("Path to file where the table should be written. Passing a hyphen " + "indicates that it should be written to the terminal") +) +measure_parser.set_defaults(func = _main_measure) + + + +cmp_parser = subparsers.add_parser( + 'cmp', help = 'measure the field summary and compares against a reference' +) +_add_target_path_arg(cmp_parser) +cmp_parser.add_argument( + '--ref', required = True, action = 'store', + help = "Path to the file of reference field summary information" +) +cmp_parser.add_argument( + '--report', default = None, + help = ('Path to file where a properly formatted test report describing ' + 'the sucess of the comparison should be written.') +) + +_extended_explanation = ( + 'This expects a single tolerance value (an integer/floating point value) ' + 'that is used for all comparing values from any column of the ' + 'summary-table. Alternatively, this can accept a JSON object that pairs ' + 'column-names with the individual tolerance values. When a tolerance ' + 'valueIf a tolerance value isn\'t provided, it defaults to 0.' +) +cmp_parser.add_argument( + '--rtol', action = 'store', default = '0', + help = f"Relative tolerance. {_extended_explanation}" +) +cmp_parser.add_argument( + '--atol', action = 'store', default = '0', + help = f"Absolute tolerance. {_extended_explanation}" +) + +cmp_parser.set_defaults(func = _main_cmp) + +if __name__ == '__main__': + args = parser.parse_args() + main_func = args.func + main_func(args) diff --git a/tools/gen_grackle_testing_file.py b/tools/gen_grackle_testing_file.py new file mode 100644 index 0000000000..4f7d35e293 --- /dev/null +++ b/tools/gen_grackle_testing_file.py @@ -0,0 +1,70 @@ +import argparse +import os.path + +_output_template ="""\ +# Generated by gen_grackle_testing_file.py + +include "{include_path}" +Method {{ grackle {{ data_file = "{data_path}"; }} }}\ +""" +def generate_grackle_input_file(include_path, data_path, + use_abs_paths = True, + output_fname = None): + """ + Generates a parameter file for use in automated Enzo-E tests involving + Grackle. + + The resulting file simply has an include directive to include the contents + of the file located at include_path and sets the Method:grackle:data_file + parameter to data_path (overwriting any previous value). + """ + prep_path_func = lambda path: path + if use_abs_paths: + prep_path_func = os.path.abspath + + output_contents = _output_template.format( + include_path = prep_path_func(include_path), + data_path = prep_path_func(data_path) + ) + + if output_fname is not None: + with open(output_fname, 'w') as f: + f.write(output_contents) + return output_contents + + +_description = """\ +Generates a parameter file for use in automated Enzo-E tests involving Grackle. + +The resulting file simply includes the contents of INCLUDE_FILE and sets the Method:grackle:data_file to DATA_PATH (overwriting any previous value). +""" + +parser = argparse.ArgumentParser(description = _description) + +parser.add_argument( + 'INCLUDE_FILE', + help = 'Path to the input file specifying most parameters' +) +parser.add_argument( + '-d','--data-path', required = True, help = 'Path to the grackle data file' +) +parser.add_argument( + '--use-abs-paths', action = 'store_true', default = False, + help = ('When specified, relative paths are converted to absolute paths. ' + 'When omitted, any relative paths should be relative to the ' + 'directory from which the parameter file will be read') +) +parser.add_argument( + '-o', '--output', action = 'store', default = None, + help = ('Path where the output parameter file is written. ' + 'When omitted, the result is printed to STDOUT') +) + +if __name__ == '__main__': + args = parser.parse_args() + rslt = generate_grackle_input_file(args.INCLUDE_FILE, args.data_path, + use_abs_paths = args.use_abs_paths, + output_fname = args.output) + if args.output is None: + print(rslt) + diff --git a/tools/run_cpp_test.py b/tools/run_cpp_test.py index e2b895117a..8fa14e37fd 100644 --- a/tools/run_cpp_test.py +++ b/tools/run_cpp_test.py @@ -26,8 +26,8 @@ help = "the C++ binary that is to be executed" ) parser.add_argument( - "args_for_command", metavar = "ARGS", action = "store", nargs = argparse.REMAINDER, - default = [], + "args_for_command", metavar = "ARGS", action = "store", + nargs = argparse.REMAINDER, default = [], help = "the arguments to the C++ binary that are to be executed" ) @@ -95,26 +95,33 @@ def _num_occurences(pattern, skip_binary_file_search = False): else: return False -if __name__ == '__main__': - args = parser.parse_args() - - if args.output_dump is None: +def run_test_and_check_success(command, args_for_command = [], + dump_path = None): + if dump_path is None: + delete_output = True dump_path = tempfile.mktemp() # path for a temporary file else: - dump_path = args.output_dump + delete_output = False - success = execute_command(command = args.command, - args = args.args_for_command, - output_dump = dump_path) - if not success: - out = 1 - elif log_suggests_test_failure(dump_path): - out = 1 - else: - out = 0 + command_success = execute_command(command = command, + args = args_for_command, + output_dump = dump_path) + success = command_success and not log_suggests_test_failure(dump_path) # cleanup the temporary file - if args.output_dump is None: + if delete_output: os.remove(dump_path) + return success - sys.exit(out) +if __name__ == '__main__': + args = parser.parse_args() + + test_passes = run_test_and_check_success( + command = args.command, args_for_command = args.args_for_command, + dump_path = args.output_dump + ) + + if test_passes: + sys.exit(0) + else: + sys.exit(1) diff --git a/tools/test_report.py b/tools/test_report.py index 0cf861fc6c..90a62a0d04 100644 --- a/tools/test_report.py +++ b/tools/test_report.py @@ -8,6 +8,24 @@ # # The interface is loosely modelled off of python's file API. +class DummyReport: + """ + Can be used in place of TestReport to print results to terminal (without + some of the unnecessary formatting + """ + def write(self, msg, flush = False): + print(msg) + sys.stdout.flush() + + def passing(self,msg): + print('pass: {}'.format(msg)) + + def fail(self, msg): + print('FAIL: {}'.format(msg)) + + def incomplete(self, msg): + print('incomplete: {}'.format(msg)) + class TestReport: """ Respresents the report for the current test(s). @@ -82,6 +100,13 @@ def complete(self): def is_complete(self): return self._complete +@contextlib.contextmanager +def create_dummy_report(*args, **kwargs): + try: + yield DummyReport() + finally: + pass + @contextlib.contextmanager def create_test_report(test_file, clobber = True): """