This vignette details how to make changes and add extensions to leapfrog. There are some organisation and structural things about leapfrog which were added to enable different researchers to make extensions to the model. In a way that we can turn these on or off at compile time to run different variants of the model.
The overall aim of this is to allow researchers to write these model extensions with as little overhead as possible. If you find something annoying or difficult, let me know and we can probably try and simplify it. Or at least document it better.
To make changes to Leapfrog you will first need to
./scripts/create_test_data.R from the project root./scripts/generate
from the project rootYou should then be able to run leapfrog via R, Python, or C++. See
the README.md in the specific wrappers.
Leapfrog has a set of ModelVariants which can be run.
See leapfrog-core/model_schemas/ModelVariants.json
for details of these. Each model variant is a collection of boolean
switches or enums. These are used to turn on or off different parts of
the code when it is run, so that leapfrog can have different extensions
and we can compose these together in any way we want. These model
variants are evaluated at compile time. When you compile the code, there
will be an instance in the compiled binary for each variant your code
will call. This will cause the binary to be larger but we chose to do it
this way because we wanted the speed from the compile-time
polymorphism.
All model functions are templated on the model variant, and any
conditional behaviour based upon the model variant should be written as
an if constexpr.
Model variant functions are in the models
directory. Every model variant should be created as a struct. We can
use the struct to alias state space variables or bits of the config to
make the code more easily readable. The struct should expose at least
one public function which can then be called from the project_year
function.
To explain what is going on in the model struct we can look at the adult HIV model as an example
Config - this is used to ensure that the code is not
compiled when we’re running a variant in which it is disabled.t - the current time step of the model as an index,
e.g. if running for 1970:2030, this will start at 1 and
loop to 61. Any input data you read based on time step
index should have 1970 at the first index. Index
1 in R and 0 in C++.pars - the parameters for this model, these are
read-only valuesstate_curr - the state at the current point in time.
This is read-only.state_next - the state at the next time point. This is
what we are currently populating from the previous time step and the
parameters.intermediate - a place for storing any data used within
a single time step. Use this as to store intermediate values for use
later in your code. This is reset to all 0s at the end of every time
step.project_year
Note that after each time step the code will do the following
state_curr with state_next.state_next to all 0s.intermediate to all 0s.Leapfrog runs a top-level loop over the time step. At the end of each
time step, the state is optionally saved out and eventually returned. We
do it this way because it decouples the reporting of the model and each
time step iteration. When you run the model with run_model
you can specify which years you want to output data for. By default it
will output for all time steps, but if say you are only interested in
the last time step. You can return this by running e.g.
run_model(data, parameters, 1970:2030, 10, 2030)
This time output is managed by the internal OutputState
struct see e.g. leapfrog-core/include/leapfrog.hpp
Leapfrog uses code generation to write the code for wiring up the
input and output data. This is to reduce the number of locations you
need to make changes when you add new input data or return new data from
the model. In short it amounts to updating one of the configs at leapfrog-core/model_schemas/configs
and running scripts/generate
The config is JSON, it has the following sections:
name - The name of this model variant type, it should
be short. It is used in C++ code for the type which holds the state
space, input data, intermediate data and output data associated with
this variant.long_name - A long name for the model variant, at the
moment used on for Delphi interface (to distinguish between 2 digit
module codes used by Avenir internally)namespace - The name of an instance of the model
variant, used in C++ code.enable_if - A conditional for when the model variant
should be active. This is a compile time conditional based on model
variant booleans. When the condition is true, the input data must be
supplied and output data will be returned.state_space - An object containing named integers.
These are the dimensions for the statically allocated input,
intermediate and output data. A variant can use state space parameters
from other variants.pars - The inputs to the model used in this variant.
You can use parameters supplied by other variants. Each parameter can
define:
double.opts.proj_steps * opts.hts_per_year. If no “dims” are
set, assumed this is a scalar value.intermediate - use to define any intermediate bits of
data used during the model run. We define them here because then they
can be statically allocated instead of allocated every iteration of the
time loop. These are automatically set to 0 at the end of every time
step. Each piece of intermediate data needs a “num_type” and
“dims”.state - data output from the model. Defined as a JSON
object, these are the results filled in during a model run. Each item
needs a “num_type” and (optionally) “dims”. When a model is run the
output is a named list/dictionary with keys matching from the JSON
object and values corresponding to the “dims” with an additional time
dimension. So if the state defines p_totpop with dims
["SS::pAG", "SS::NS"] the output will have
p_totpop with 3 dimensions of lengths pAG, NS and number of
output years.After making changes to the config,
run the generate script scripts/generate.
Note that this will update several of the generated files. The generated
files should never be manually changed as the generate script will
completely rewrite it.
After regenerating the code, rebuild the project and you are ready to use the new input data in C++.
There are two types of model variants at the moment:
The required changes will be different depending on if your new variant is one of the first types, which brings additional input/output or the second type which does not bring additional data.
To add a new variant. Firstly, in the code generation:
leapfrog-core/model_schemas/ModelVariants.json.
Make sure the new flag is set to true or false in all other model
variants.configs dir
and fill in the details as requiredIn the C++ code:
Add new model code for your variant. At this point I would just add a skeleton with a print line to check your set up works, and fill in actual model code later. Model code should go here. You can use the following snippet as a template
#pragma once
#include "../options.hpp"
#include "../generated/config_mixer.hpp"
namespace leapfrog {
namespace internal {
// model_variant_flag1 & 2 need to be in pascal case, can do one of multiple model variant flags required for this model
template<typename Config>
concept {{ model_name }}Enabled = {{ model_variant_flag1 }}<Config> && {{ model_variant_flag2 }}<Config>;
template<typename Config>
struct {{ model_name }} {
{{ model_name }}(...) {};
};
template<{{ model_name }}Enabled Config>
struct {{ model_name }}<Config> {
using real_type = typename Config::real_type;
using ModelVariant = typename Config::ModelVariant;
using SS = Config::SS;
using Pars = Config::Pars;
using State = Config::State;
using Intermediate = Config::Intermediate;
using Args = Config::Args;
// function args
int t;
const Pars& pars;
const State& state_curr;
State& state_next;
Intermediate& intermediate;
const Options<real_type>& opts;
// only exposing the constructor and some methods
public:
{{ model_name }}(Args& args):
t(args.t),
pars(args.pars),
state_curr(args.state_curr),
state_next(args.state_next),
intermediate(args.intermediate),
opts(args.opts)
{};
void run() { std::cout << "Running new model\n" };
};
Call your new model variant at the appropriate point in the project_year
function
You now need to update the wrappers you want to expose the model variant to. For the R wrapper:
src/leapfrog.cpp.For the Python wrapper:
The C/Delphi interface and C++ interface have more specific usages so we probably don’t need to expose new variants via these interfaces. But if we do, speak to Rob for help with how to do this.