-
Notifications
You must be signed in to change notification settings - Fork 2
OOPS 2D Tutorial: Adding Parameters
Any scientific code needs to be able to adjust parameters without recompiling. In some codes, adding new parameters or adjusting permissible inputs is often very difficult; at the bare minimum, you'll have to add a new parameter to a data structure, possibly with access functions, and modify a parser to read the parameter and guarantee that the requested value is valid. If you're working on a parallel code, these parameters also need to be broadcast to all the other processors. It's such an onerous process that a lot of very simple codes simply relegate all their parameters to command-line arguments.
Fortunately, OOPS-2D tries to be one of the more enlightened codes that makes adding parameters easy. Thanks to the genParams.py script, the hardest task is just writing a setup file. You can even set up your build procedure to run genParams.py whenever the code is compiled.
Note: readers familiar with parameters in OOPS only need to read the sections on setting up CMake and adjusting the main function. The process is otherwise identical.
OOPS-2D stores a list of all possible parameters, their acceptable values, and their defaults in a JSON setup script. A typical file looks something like the following:
{
"Example":{
"name":"Example",
"id":3,
"members":[
{
"name":"AnInteger",
"type":"int",
"min":0,
"max":10,
"default":3
},
{
"name":"ADouble",
"type":"double",
"min":-1e6,
"max":1e6,
"default":1.0
},
{
"name":"AnEnum",
"type":"enum",
"values":[
"VALUE1",
"VALUE2",
"VALUE3"
],
"default":"VALUE1"
},
{
"name":"AString",
"type":"string",
"default":"This is a string"
}
]
}
}The first layer creates a new object, "Example". This represents a single set of parameters (yes, you can have multiple sets in a single file). Every parameter object must define three variables: "name", which is a string that would qualify as a valid identifier in C++ (i.e., only letters, numbers, and underscores); "id", which is an integer that helps match a generated Parameters object with an appropriate ParamParser object; and "members", which is an array of objects, each defining a new parameter. Each parameter inside "members" must also define three variables: "name", corresponding to the name which will be used to access the parameter in OOPS, "type", corresponding to the parameter's C++ type (currently supporting int, enum, double, and string), and "default", which is the default value for the parameter if no other value is specified. The numeric types, double and int, must also define the "min" and "max" variables to indicate the acceptable ranges for the parameter. The enum type has another variable, "values", which is an array of JSON strings defining the acceptable inputs in C++. Therefore, "AnEnum" results in a C++ enumerator AnEnum which accepts VALUE1, VALUE2, and VALUE3 as inputs.
The genParams.py Python script inside OOPS-2D/scripts accepts a list of JSON setup files as arguments, parses each one, and generates four files per parameter set in the JSON files: <name>parameters.h, <name>parser.h, <name>parameters.cpp, and <name>parser.cpp. Currently, the script is hard-coded to look for include and src directories to place these files in, so your setup script should always be run in your project's root directory or its scripts directory. For example, if your project is called Example, you would run python3 genParams.py <OOPS-2D directory>/Example/scripts/example.json or python3 genParams.py <OOPS-2D directory>/Example/example.json.
While you could just run genParams.py by hand every single time you change your JSON setup file, this quickly becomes tedious and error-prone. It's a lot more convenient to set up CMakeLists.txt to automatically run the script for you at compile time. Inside CMakeLists.txt, we could add the following code after the project declaration for our example.json:
# Generate the Parameters and ParamParser files.
set(PARAM_SRC
${CMAKE_CURRENT_SOURCE_DIR}/src/exampleparser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/exampleparameters.cpp
)
set(PARAM_INC
${CMAKE_CURRENT_SOURCE_DIR}/include/exampleparameters.h
${CMAKE_CURRENT_SOURCE_DIR}/include/exampleparser.h
)
set(SETUP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/scripts/example.json)
add_custom_command(
OUTPUT ${PARAM_INC}
${PARAM_SRC}
DEPENDS ${SETUP_SRC}
COMMAND ${PYTHON_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/genParams.py ${SETUP_SRC}
COMMENT "Generating custom Parameters files"
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
VERBATIM USES_TERMINAL
)If your CMakeLists.txt file is set up like the Scalar Wave Tutorial, the only other modification would be appending the SOURCE_FILES definition to read
set(SOURCE_FILES ${EXAMPLE_INCLUDE_FILES} ${EXAMPLE_SOURCE_FILES} ${PARAM_INC} ${PARAM_SRC})At this point, we have custom parameters, but there's no way to use them. To do that, you could add something like the following to your main function after the MPI initialization (making sure to add exampleparameters.h and exampleparser.h to your includes):
int rank = comm->getRank(); // Assuming your MPICommunicator is called "comm"
int root = comm->getRootRank();
// Load the parameter file.
ExampleParameters params;
if(rank == root){
if(argc < 2){
std::cout << "Usage: ./Example <parameter file>\n";
return 0;
}
ExampleParser parser;
parser.updateParameters(argv[1], ¶ms);
}
// Broadcast all the parameters.
params.broadcastParameters();Every JSON setup file generates two new classes: <Name>Parameters and <Name>Parser. The first is a derived class from Parameters and is basically a giant set of getters and setters for all the different parameters, plus a function to broadcast them over MPI. For example, we could access AnInteger or AnEnum with the functions params.getAnInteger(), params.getAnEnum(), params.setAnInteger(int value), and params.setAnEnum(ExampleParameters::AnEnum value). The <Name>Parser class is a subclass of ParamParser, and it works with the ParamReader class included in OOPS-2D to interpret a parameter file and update an appropriate Parameters class.
We can see these two classes at work in this example. We create a default ExampleParameters object, whose parameters will all be set to the defaults specified in example.json. We read in a parameter file from the command line (and quit if there isn't one), but only if we're on the root processor. We then create a ExampleParser and use it to read in the parameter file and update the ExampleParameters object. Following that, we call broadcastParameters. Since we only read in the parameters on one core (which is the safest behavior), broadcastParameters copies the stored parameter values from ExampleParameters on the root processor and sends them to all the other processors.
If your parameters are going to be used inside your ODE derived class (such as for setting initial conditions or certain numerical methods), we strongly recommend modifying your class declaration to resemble the following:
#ifndef EXAMPLE_ODE_H
#define EXAMPLE_ODE_H
// Other includes...
#include <exampleparameters.h>
class ExampleODE : public ODE{
private:
// Other private members...
ExampleParameters *params;
public:
// Other public members...
inline void setParameters(ExampleParameters *p){
params = p;
}
inline ExampleParameters* getParameters() const{
return params;
}
};
#endifFor an excellent example of how Parameters are implemented in a project, consult the WaveTest project.
If you go ahead and try to run your code at this point, you'll notice that you can't; you still need a parameter file. Fortunately, the OOPS-2D parameter file structure is very simple and quite forgiving. A file for ExampleParameters might look something like the following:
# This is a comment
[Example]
AnInt = 10
ADouble = 3.14159
AnEnum = VALUE3
AString = A different stringThe section header [] corresponds to the name of a <name>Parameters object. You can declare as many sections as you would like in a single parameter file (thus making it possible to define multiple sets of parameters with a single file), but each section should only be declared once. Each parameter must be declared after a section header and has the format parameter = value. Whitespace before or after parameter, =, and value is ignored. A parameter may be defined multiple times, with the last definition taking precedence over the others. Parameters should be a valid C++ name, but values may contain whitespace. Additionally, the # character declares the start of a line comment.
Because the ParamReader class is completely agnostic of any parameters, non-existent sections and parameters are not syntax errors. They will be loaded into the ParamReader and simply ignored by any ParamParser class accessing them.
Here are some exercises to make you more comfortable with parameters in OOPS-2D:
- Try adding a set of parameters to the Scalar Wave Tutorial. In particular, make
sigma(the width of the Gaussian),x0, andy0parameters. Also try adding parameters for the number of points of theGridand the dimensions of theDomain. What happens if the resulting spacings in the x-direction and y-direction are different? - As mentioned earlier, the JSON setup file supports multiple parameter sets in a single file. Try splitting the parameters from Exercise 1 into one set for
Waveand another for theGridandDomain. Don't forget to modifyCMakeLists.txtand your parameter file accordingly. - The
genParams.pyscript supports reading in multiple setup files at once. Try splitting your setup script from Exercise 2 into multiple scripts and modifyingCMakeLists.txtto read both. - After completing Exercise 2, try splitting your parameter files into multiple parameter files and modifying
mainto read in a separate parameter file forWaveand another forGridandDomain. - Try adding a new set of initial conditions to the
Waveprogram (such as a sine wave)
Some interesting takeaways: the parameters system in OOPS-2D is extremely flexible. In particular, the ability to read in multiple parameter files means that it is possible to separate parameters for setting up the solution grid and domain from the ODE itself, or even various parts of the ODE (such as numerical methods) from the initial conditions.
To imagine a situation where this might be useful, suppose you need to run a problem with four sets of initial conditions, three different numerical methods, and three different grid resolutions. Generating a single unique parameter file for all of these combinations results in 36 different parameter files and an enormous waste of time. Splitting up the parameters into one for ODE numerical methods, one for ODE initial conditions, and another for the solution domain means you can also split the parameters into three different files, thus only needing 10 different parameter files.