Model implementation in 5 minutes
Introduction
PlantSimEngine.jl
was designed to make new model implementation very simple. So let's learn about how to implement your own model with a simple example: implementing a new light interception model.
Inspiration
If you want to implement a new model, the best way to do it is to start from another implementation.
For a complete example, you can look at the code in PlantBiophysics.jl
, were you will find e.g. a photosynthesis model, with the implementation of the FvCB
model in this Julia file: src/photosynthesis/FvCB.jl; an energy balance model with the implementation of the Monteith
model in src/energy/Monteith.jl; or a stomatal conductance model in src/conductances/stomatal/medlyn.jl.
PlantSimEngine
also provide toy models that can be used as a base to better understand how to implement a new model:
- The Beer model for light interception in examples/Beer.jl
- A toy LAI development in examples/ToyLAIModel.jl
Requirements
In those files, you'll see that in order to implement a new model you'll need to implement:
- a structure, used to hold the parameter values and to dispatch to the right method
- the actual model, developed as a method for the process it simulates
- some helper functions used by the package and/or the users
If you create your own process, the function will print a short tutorial on how to do all that, adapted to the process you just created (see Implement a new process).
In this page, we'll just implement a model for a process that already exists: the light interception. This process is defined in PlantBiophysics.jl
, and also made available as an example model from the Examples
sub-module. You can access the script from here: examples/Beer.jl
.
We can import the model like so:
# Import the example models defined in the `Examples` sub-module:
using PlantSimEngine.Examples
But instead of just using it, we will review the script line by line.
Example: the Beer-Lambert model
The process
We declare the light interception process at l.7 using @process
:
@process "light_interception" verbose = false
See Implement a new process for more details on how that works and how to use the process.
The structure
To implement a model, the first thing to do is to define a structure. The purpose of this structure is two-fold:
- hold the parameter values
- dispatch to the right
run!
method when calling it
The structure of the model (or type) is defined as follows:
struct Beer{T} <: AbstractLight_InterceptionModel
k::T
end
The first line defines the name of the model (Beer
), which is completely free, except it is good practice to use camel case for the name, i.e. using capital letters for the words and no separator LikeThis
.
We also can see that we define the Beer
structure as a subtype of AbstractLight_InterceptionModel
. This step is very important as it tells to the package what kind of process the model simulates. AbstractLight_InterceptionModel
is automatically created when defining the process "light_interception".
In our case, it tells us that Beer
is a model to simulate the light interception process.
Then comes the parameters names, and their types. The type of parameters is given by the user at instantiation in our example. This is done using the T
notation as follows:
- we say that our structure
Beer
is a parameterizedstruct
by puttingT
in between brackets after the name of thestruct
- We put
::T
after our parameter name in thestruct
. This way Julia knows that our parameter will be of typeT
.
The T
is completely free, you can use any other letter or word instead. If you have parameters that you know will be of different types, you can either force their type, or make them parameterizable too, using another letter, e.g.:
struct YourStruct{T,S} <: AbstractLight_InterceptionModel
k::T
x::T
y::T
z::S
end
Parameterized types are very useful because they let the user choose the type of the parameters, and potentially dispatch on them.
But why not forcing the type such as the following:
struct YourStruct <: AbstractLight_InterceptionModel
k::Float64
x::Float64
y::Float64
z::Int
end
Well, you can do that. But you'll lose a lot of the magic Julia has to offer this way.
For example a user could use the Particles
type from MonteCarloMeasurements.jl to make automatic uncertainty propagation, and this is only possible if the type is parameterizable.
The method
The models are implemented by adding a method for its type to the run!
function. The exclamation point at the end of the function name is used in Julia to tell users that the function is mutating, i.e. it modifies its input.
The function takes six arguments:
- the type of your model
- models: a
ModelList
object, which contains all the models of the simulation - status: a
Status
object, which contains the current values (i.e. state) of the variables for one time-step (e.g. the value of the plant LAI at time t) - meteo: (usually) an
Atmosphere
object, or a row of the meteorological data, which contains the current values of the meteorological variables for one time-step (e.g. the value of the PAR at time t) - constants: a
Constants
object, or aNamedTuple
, which contains the values of the constants for the simulation (e.g. the value of the Stefan-Boltzmann constant) - extras: any other object you want to pass to your model. This is for advanced users, and is not used in this example. Note that it is used to pass the
Node
when simulating a MultiScaleTreeGraph.
Your implementation can use any variables or parameters in these objects. The only thing you have to do is to make sure that the variables you use are defined in the Status
object, the meteorology, and the Constants
object.
The variables you use from the Status
must be declared as inputs of your model. And the ones you modify must be declared as outputs. We'll that below.
Models implementations are done for one time-step by design. The values of the previous time-step is always available in the status
(e.g. status.biomass
) as long as the variable is an output of your model. This is because at the end of a time-step, the Status
object is recycled for the next time-step and so the latest computed values are always available. This is why it is possible to increment a value every time-step using e.g. status.biomass += 1.0
. By design models don't have access to values prior to the one before. If you're not convinced by this approach, ask yourself how the plant knows the value of e.g. LAI from 15 days ago. It doesn't. It only knows its current state. Most of the time-sensitive variables really are just an accumulation of values until a threshold anyway. BUt if you really need to use values from the past (e.g. 15 time-steps before), you can add a variable to the Status
object that is uses like a queue (see e.g. DataStructures.jl).
PlantSimEngine
then automatically deals with every other detail, such as checking that the object is correctly initialized, applying the computations over objects and time-steps. This is nice because as a developer you don't have to deal with those details, and you can just concentrate on your model implementation.
You need to import all the functions you want to extend, so Julia knows your intention of adding a method to the function from PlantSimEngine, and not defining your own function. To do so, you have to prefix the said functions by the package name, or import them before e.g.: import PlantSimEngine: inputs_, outputs_
So let's do it! Here is our own implementation of the light interception for a ModelList
component models:
function run!(::Beer, models, status, meteo, constants, extras)
status.PPFD =
meteo.Ri_PAR_f *
exp(-models.light_interception.k * status.LAI) *
constants.J_to_umol
end
run! (generic function with 1 method)
The first argument (::Beer
) means this method will only execute when the function is called with a first argument that is of type Beer
. This is our way of telling Julia that this method implements the Beer
model for the light interception process.
An important thing to note is that the model parameters are available from the ModelList
that is passed via the models
argument. Then parameters are found in field called by the process name, and the parameter name. For example, the k
parameter of the Beer
model is found in models.light_interception.k
.
One last thing to do is to define the inputs and outputs of our model. This is done by adding a method for the inputs
and outputs
functions. These functions take the type of the model as argument, and return a NamedTuple
with the names of the variables as keys, and their default values as values.
In our case, the Beer
model has one input and one output:
- Inputs:
:LAI
, the leaf area index (m² m⁻²) - Outputs:
:aPPFD
, the photosynthetic photon flux density (μmol m⁻² s⁻¹)
Here is how we communicate that to PlantSimEngine:
function PlantSimEngine.inputs_(::Beer)
(LAI=-Inf,)
end
function PlantSimEngine.outputs_(::Beer)
(aPPFD=-Inf,)
end
Note that both functions end with an "_". This is because these functions are internal, they will not be called by the users directly. Users will use inputs
and outputs
instead, which call inputs_
and outputs_
, but stripping out the default values.
Dependencies
If your model explicitly calls another model, you need to tell PlantSimEngine about it. This is called a hard dependency, in opposition to a soft dependency, which is when your model uses a variable from another model, but does not call it explicitly.
To do so, we can add a method to the dep
function that tells PlantSimEngine which processes (and models) are needed for the model to run.
Our example model does not call another model, so we don't need to implement it. But we can look at e.g. the implementation for Fvcb
in PlantBiophysics.jl
to see how it works:
PlantSimEngine.dep(::Fvcb) = (stomatal_conductance=AbstractStomatal_ConductanceModel,)
Here we say to PlantSimEngine that the Fvcb
model needs a model of type AbstractStomatal_ConductanceModel
in the stomatal conductance process.
You can read more about dependencies in Model coupling for modelers and Model coupling for users.
The utility functions
Before running a simulation, you can do a little bit more for your implementation (optional).
First, you can add a method for type promotion. It wouldn't make any sense for our example because we have only one parameter. But we can make another example with a new model that would be called Beer2
that would take two parameters:
struct Beer2{T} <: AbstractLight_InterceptionModel
k::T
x::T
end
To add type promotion to Beer2
we would do:
function Beer2(k,x)
Beer2(promote(k,x))
end
This would allow users to instantiate the model parameters using different types of inputs. For example they may use this:
Beer2(0.6,2)
You don't see a problem? Well your users won't either. But there's one: Beer2
is a parametric type, so all fields share the same type T
. This is the T
in Beer2{T}
and then in k::T
and x::T
. And this force the user to give all parameters with the same type.
And in our example above, the user provides 0.6
for k
, which is a Float64
, and 2
for x
, which is an Int
. ANd if you don't have type promotion, Julia will return an error because both should be either Float64
or Int
. That's were the promotion comes in handy, it will convert all your inputs to a common type (when possible). In our example it will convert 2
to 2.0
.
A second thing also is to help your user with default values for some parameters (if applicable). For example a user will almost never change the value of k
. So we can provide a default value like so:
Beer() = Beer(0.6)
Main.Beer
Now the user can call Beer
with zero value, and k
will default to 0.6
.
Another useful thing is the ability to instantiate your model type with keyword arguments, i.e. naming the arguments. You can do it by adding the following method:
Beer(;k) = Beer(k)
Main.Beer
Did you notice the ;
before the argument? It tells Julia that we want those arguments provided as keywords, so now we can call Beer
like this:
Beer(k = 0.7)
Main.Beer{Float64}(0.7)
This is nice when we have a lot of parameters and some with default values, but again, this is completely optional.
The last optional thing to implement is a method for the eltype
function:
Base.eltype(x::Beer{T}) where {T} = T
This one helps Julia know the type of the elements in the structure, and make it faster.
Traits
PlantSimEngine
defines traits to get additional information about the models. At the moment, there are two traits implemented that help the package to know if a model can be run in parallel over space (i.e. objects) and/or time (i.e. time-steps).
By default, all models are assumed to be not parallelizable over objects and time-steps, because it is the safest default. If your model is parallelizable, you should add the trait to the model.
For example, if we want to add the trait for parallelization over objects to our Beer
model, we would do:
PlantSimEngine.ObjectDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsObjectIndependent()
And if we want to add the trait for parallelization over time-steps to our Beer
model, we would do:
PlantSimEngine.TimeStepDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsTimeStepIndependent()
A model is parallelizable over objects if it does not call another model directly inside its code. Similarly, a model is parallelizable over time-steps if it does not get values from other time-steps directly inside its code. In practice, most of the models are parallelizable one way or another, but it is safer to assume they are not.
OK that's it! Now we have a full new model implementation for the light interception process! I hope it was clear and you understood everything. If you think some sections could be improved, you can make a PR on this doc, or open an issue.