Model implementation in 5 minutes
Introduction
PlantBiophysics.jl
was designed to make new model implementation very simple thanks to PlantSimEngine
. So let's learn about how to implement your own model with a simple example: implementing a new stomatal conductance model.
Inspiration
If you want to implement a new model, the best way to do it is to start from another implementation.
So for a photosynthesis model, I advise you to look at the implementation of the FvCB
model in this Julia file: src/photosynthesis/FvCB.jl.
For an energy balance model you can look at the implementation of the Monteith
model in src/energy/Monteith.jl, and for a stomatal conductance model in src/conductances/stomatal/medlyn.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
run!
function - some helper functions used by the package and/or the users
Let's take a simple example with a new model for the stomatal conductance: the Ball and Berry model.
Example: the Ball and Berry model
The structure
The first thing to do is to implement a structure for your model.
The purpose of the structure is two-fold:
- hold the parameter values
- dispatch to the right method when calling the process function
Let's take the stomatal conductance model from Medlyn et al. (2011) as a starting point. The structure of the model (or type) is defined as follows:
struct Medlyn{T} <: AbstractStomatal_ConductanceModel
g0::T
g1::T
gs_min::T
end
The first line defines the name of the model (Medlyn
), with the types that will be used for the parameters. Then it defines the structure as a subtype of AbstractStomatal_ConductanceModel
. This step is very important as it tells to the package what kind of process the model simulates. In this case, it is a stomatal conductance model, that's why we use AbstractStomatal_ConductanceModel
. We would use AbstractPhotosynthesisModel
instead for a photosynthesis model, AbstractEnergy_BalanceModel
for an energy balance model, and AbstractLight_InterceptionModel
for a light interception model.
These abstract structures are automatically defined when defining a process using the @process
macro from PlantSimEngine.jl
(which PlantBiophysics is built upon).
For another example, the Fvcb
model is a subtype of AbstractPhotosynthesisModel
. You can check this using:
Fvcb <: AbstractPhotosynthesisModel
true
Then comes the parameters names, and their types. The type of the parameters is always forced to be of the same type in our example. This is done using the T
notation as follows:
- we say that our structure
Medlyn
is a parameterizedstruct
by puttingT
in between brackets after the name of thestruct
- We put
::T
after our parameter names in thestruct
. This way Julia knows that all parameters must be of same 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} <: AbstractStomatal_ConductanceModel
g0::T
g1::T
gs_min::T
integer_param::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 <: AbstractStomatal_ConductanceModel
g0::Float64
g1::Float64
gs_min::Float64
integer_param::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.
So let's implement a new structure for our stomatal conductance model:
struct BandB{T} <: AbstractStomatal_ConductanceModel
g0::T
g1::T
gs_min::T
end
Well, the only thing we had to change relative to the one from Medlyn is the name, easy! This is because both models share the same parameters.
The method
The models are implemented as a new method for the run!
function. The tailing exclamation point is used in Julia to tell users the function is mutating its input, i.e. it modifies it.
Your implementation should always modify the input status and return nothing. This ensures that models compute fast.
We extend run!
because then PlantSimEngine
handles every other details, such as checking that the object is correctly initialized, building the dependency graph between models, applying the computations over objects and time-steps in parallel, etc... This is nice because as a developer you don't have to deal with those details, and you can just concentrate on your implementation.
If your model calls another one (hard-coupled), you'll have to use the internal call directly to avoid the overheads of the generic functions to avoid all checks for performance.
Here we are trying to implement a new stomatal conductance model. Well, this one is the most complicated process to implement actually, because it is computed on two steps: run!
and gs_closure
.
gs_closure
is the function that actually implements the conductance model, but only the stomatal closure part. This one does not modify its input, it computes the result and returns it. Then run!
uses this output to compute the stomatal conductance. But why not implementing just run!
? Because gs_closure
is used elsewhere, usually in the photosynthesis model, before actually computing the stomatal conductance.
In practice, the run!
implementation is rather generic and will not be modified by developers. They will rather implement their method for gs_closure
, that will be used automatically by run!
.
We need to import all the functions we need to use or extend, so Julia knows we are extending the methods from PlantSimEngine, and not defining our own functions. To do so, you can prefix the functions by the package name, or import them once e.g.:
import PlantSimEngine: inputs_, outputs_
import PlantBiohysics: run!, run!
So let's do it! Here is our own implementation of the stomatal closure for a ModelList
component models:
function PlantBiophysics.gs_closure(::BandB, models, status, meteo, constants, extra)
models.stomatal_conductance.g1 * meteo.Rh / status.Cₛ
end
The first argument (::BandB
) means this method will only execute when the function is called with a first argument that is of type BandB
. This is our way of telling Julia that this method is implementing the BandB
algorithm.
An important thing to note is that our variables are stored in different structures:
models
: the models parametersstatus
: the input and output variables of the modelsmeteo
: the micro-climatic conditionsconstants
: the constantsextras
: any other value or object, this is used for example to pass the node of a MultiScaleTreeGraph
The micro-meteorological conditions are always given for one time-step inside the models methods. The conditions with several time-steps are handled earlier by the generic functions.
OK ! So that's it ? Almost. One last thing to do is to define a method for inputs/outputs so that PlantSimEngine knows which variables are needed for our model, and which it computes. Remember that the actual model is implemented for run!
, so we have to tell PlantSimEngine which ones are needed, and what are their default value:
- Inputs:
:Rh
and:Cₛ
for our specific implementation, and:A
forrun!
- Outputs: our model does not compute any new variable, and
run!
computes, well,:Gₛ
Here is how we actually implement our methods:
import PlantSimEngine
function PlantSimEngine.inputs_(::BandB)
(Rh=-Inf,Cₛ=-Inf,A=-Inf)
end
function PlantSimEngine.outputs_(::BandB)
(Gₛ=-Inf,)
end
Note that both function 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 `inputsand
outputs_`, but stripping out the default values.
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:
function BandB(g0,g1,gs_min)
BandB(promote(g0,g1,gs_min))
end
Main.BandB
This allows your user to instantiate your model parameters using different types of inputs. For example they may use this:
BandB(0,2.0,0.001)
You don't see a problem? Well your users won't either.
Here's the problem: we use parametric types, and when we declared our structure, we said that all fields in our type will share the same type. This is the T
here:
struct BandB{T} <: AbstractStomatal_ConductanceModel
g0::T
g1::T
gs_min::T
end
And in our example above, the user provides 0
as the first argument. Well, this is an integer, not a floating point number like the two others. That's were the promotion is really helpful. It will convert all your inputs to the same type. In our example it will convert 0
to 0.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 the minimum stomatal conductance. So we can provide a default value like so:
BandB(g0,g1) = BandB(g0, g1, oftype(0.001, g0))
Main.BandB
Now the user can call BandB
with only two values, and the third one will be set to 0.001
.
Another useful thing to provide to the user is the ability to instantiate your model type with keyword values. You can do it by adding the following method:
BandB(;g0,g1) = BandB(g0,g1,oftype(g0,0.001))
Main.BandB
Did you notice the ;
before the argument? It tells Julia that we want those arguments provided as keywords, so now we can call BandB
like this:
BandB(g0 = 0.0, g1 = 2.0)
Main.BandB{Float64}(0.0, 2.0, 0.001)
This is nice, but again, completely optional.
One more thing to implement is a method for the dep
function that tells PlantSimEngine which processes (and models) are needed for your model to run (i.e. if your model is coupled to another model).
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
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.
The next optional thing to implement is a method for the eltype
function:
Base.eltype(x::BandB{T}) where {T} = T
This one helps Julia to know the type of the elements in your structure, and make it faster.
And finally the last optional thing to add is a trait that defines how the model can be run in parallel over space (i.e. over objects) or time (i.e. over time-steps).
By default all models are considered not to be executable in parallel. But if you implement a model that is parallel over objects, you can define the trait like so:
PlantSimEngine.ObjectDependencyTrait(::Type{<:BandB}) = PlantSimEngine.IsObjectIndependent()
And if it is executable in parallel over time-steps, you can define it like this:
PlantSimEngine.TimeStepDependencyTrait(::Type{<:BandB}) = PlantSimEngine.IsTimeStepIndependent()
A model that is defined executable in parallel will not necessarily will. First, the user has to pass a parallel executor
to run!
(e.g. ThreadedEx
). Second, if the model is coupled with another model that is not executable in parallel, PlantSimEngine
will run all models in sequential.
OK that's it! Now you have a full new implementation of the stomatal conductance model! I hope it was clear and that you understood everything. If you think some sections could be improved, you can make a PR on this doc, or open an issue so I can improve it.
More details on model implementations
Here is another example with a different approach in case you need it. So let's change our example from the stomatal conductance to the photosynthesis. For example Fvcb
implements the model or Farquhar et al. (1980) to simulate the photosynthesis of C3 plants.
When the user calls run!
, PlantBiophysics looks into the component models type, and the type of the model implemented for the photosynthesis, in this case, Fvcb
.
Then, it calls another, internal method of run!
that will dispatch the computation to the method that implements the model. This method looks like this:
function PlantSimEngine.run!(::Fvcb, models, status, meteo, constants=Constants(), extras=nothing)
[...]
end
Where [...]
represent the lines of code implementing the model (not shown here).
The interesting bit is in the function declaration at the top. This is where all the magic happens. The first argument let Julia know that this is the method for computing the Fvcb
model.
Now if we look again at what are the fields of a ModelList
:
fieldnames(ModelList)
(:models, :status, :vars_not_propagated)
we see that it has two fields: models
and status
. The first one is a list of models named after the process they simulate. So if we want to simulate the photosynthesis with the Fvcb
model, our ModelList
needs an instance of the Fvcb
structure for the photosynthesis
process, like so:
leaf = ModelList(Fvcb());
leaf.models.photosynthesis
Fvcb{Float64}(25.0, 200.0, 250.0, 0.6, 9999.0, 46390.0, 210.0, 29680.0, 200000.0, 631.88, 58550.0, 200000.0, 629.26, 0.425, 0.7)
The photosynthesis
field is then used as the first argument to the call to the internal method of run!
, which will call the method that implements Fvcb
, because our photosynthesis
field is of type Fvcb
.
So if we want to implement our own model for the photosynthesis, we could do:
# Make the struct to hold the parameters:
struct OurModel{T} <: AbstractPhotosynthesisModel
a::T
b::T
c::T
end
# Instantiate the struct with default values + kwargs:
function OurModel(;a = 400.0, b = 1000.0, c = 1.5)
OurModel(promote(a,b)...)
end
# Define inputs:
function PlantSimEngine.inputs_(::OurModel)
(aPPFD=-Inf, Tₗ=-Inf, Cₛ=-Inf)
end
# Define outputs:
function PlantSimEngine.outputs_(::OurModel)
(A=-Inf, Gₛ=-Inf)
end
# Tells Julia what is the type of elements:
Base.eltype(x::OurModel{T}) where {T} = T
# Tells PlantSimEngine that the model is executable in parallel over objects:
PlantSimEngine.ObjectDependencyTrait(::Type{<:OurModel}) = PlantSimEngine.IsObjectIndependent()
# and time-steps:
PlantSimEngine.TimeStepDependencyTrait(::Type{<:OurModel}) = PlantSimEngine.IsTimeStepIndependent()
# Implement the photosynthesis model:
function PlantSimEngine.run!(::OurModel, models, status, meteo, constants=Constants(), extras=nothing)
status.A =
status.Cₛ / models.photosynthesis.a +
status.aPPFD / models.photosynthesis.b +
status.Tₗ / models.photosynthesis.c
PlantSimEngine.run!(models.stomatal_conductance, models, status, meteo, constants, extras)
end
🥳 And that's it! 🥳
We have a new model for photosynthesis that is coupled with the stomatal conductance.
This is a dummy photosynthesis model. Don't use it, it is very wrong biologically speaking!
Notice that we compute the stomatal conductance directly using the internal method of run!
that takes the type of model as first argument. We do this for speed, because the generic function run!
does some checks on its inputs every time it is called, while the internal method only does the computation. We don't need the extra checks and dependency graph computations because they are already done at the first call.
Now if we want to make a simulation, we can simply do:
using PlantMeteo
meteo = Atmosphere(T = 20.0, Wind = 1.0, P = 101.3, Rh = 0.65)
leaf =
ModelList(
OurModel(1.0, 2.0, 3.0),
Medlyn(0.03, 12.0),
status = (Tₗ = 25.0, aPPFD = 1000.0, Cₛ = 400.0, Dₗ = meteo.VPD)
)
# NB: we need to initalise Tₗ, aPPFD and Cₛ
run!(leaf,meteo,Constants())
leaf[:A]
1-element Vector{Float64}:
908.3333333333334