Implementing a model
For your own simulations, you might want to move beyond simple usage at some point and implement your own models. In this page, we'll go through the required steps for writing a new model. The detailed version is tailored for people less familiar with programming.
Quick version
Declare a new process :
@process "light_interception" verbose = false
Declare your model struct, and its parameters :
struct Beer{T} <: AbstractLight_InterceptionModel
k::T
end
Declare the inputs_
and outputs_
methods for that model (note the '_', these methods are distinct from inputs
and outputs
)
function PlantSimEngine.inputs_(::Beer)
(LAI=-Inf,)
end
function PlantSimEngine.outputs_(::Beer)
(aPPFD=-Inf,)
end
Write the run!
function that operates on a single timestep :
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)
Determine if parallelization is possible, and which traits to declare :
PlantSimEngine.ObjectDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsObjectIndependent()
PlantSimEngine.TimeStepDependencyTrait(::Type{<:Beer}) = PlantSimEngine.IsTimeStepIndependent()
And that is all you need to get going, for this example with a single parameter and no interdependencies.
The @process
macro does some boilerplate work described here
Some extra utility functions can also be interesting to implement to make users' lives simpler. See the Model implementation additional notes page for details. If your custom model needs to handle more complex couplings than the simple input/output described in this example, check out the Coupling more complex models page.
Detailed version
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.
The model we'll (re)implement is available as an example model from the Examples
sub-module. You can access the script from here: examples/Beer.jl
. It is also available in the PlantBioPhysics.jl
package.
You can import the model and PlantSimEngine's other example models into your environment with using
:
# Import the example models defined in the `Examples` sub-module:
using PlantSimEngine.Examples
Other examples
PlantSimEngine
's other toy models can be found in the examples folder.
For other examples, you can look at the code in PlantBiophysics.jl
, where you will find e.g. a photosynthesis model, with the implementation of the FvCB
model in 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.
Requirements
If you have a look at example models, 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
Example: the Beer-Lambert model
The process
We start by declaring the light interception process at l.7 using @process
:
@process "light_interception" verbose = false
See Implementing 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
). It is good practice to use camel case for the name, i.e. using capital letters for the words and no separator LikeThis
.
The Beer
structure is defined as a subtype of AbstractLight_InterceptionModel
indicating what kind of process the model simulates. The AbstractLight_InterceptionModel
type is automatically created when defining the process "light_interception".
We can therefore infer from the declaration that Beer
is a model to simulate the light interception process.
Then come the parameters names, and their types.
User types and parametric types
There is a little Julia specificity here, to enable the user to pass their own types to the simulation.
Beer
is a parameterizedstruct
, indicated by the{T}
annotation- We indicate the
k
parameter is of typeT
by adding::T
after the name.
The T
is an arbitrary letter here. 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 CustomModel{T,S} <: AbstractLight_InterceptionModel
k::T
x::T
y::T
z::S
end
Parameterized types are practical because they let the user choose the type of the parameters, and potentially change them at runtime. For example a user could use the Particles
type from MonteCarloMeasurements.jl for automatic uncertainty propagation throughout the simulation. We refer you to the Parametric types subsection of the Model implementation additional notes page for more information on parametric types.
Inputs and outputs
When implementing a new model, it is necessary to declare what variables will be required, whether provided as an input to our model or computed for every timestep as an output. Input variables will either be initialized by the user in a Status
object, or provided by another model. Output variables may be global simulation outputs and/or used by other models.
In our case, the Beer
model, computing light interception, has one input variable and one output variable:
- Inputs:
:LAI
, the leaf area index (m² m⁻²) - Outputs:
:aPPFD
, the photosynthetic photon flux density (μmol m⁻² s⁻¹)
We declare these inputs/outputs 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:
function PlantSimEngine.inputs_(::Beer)
(LAI=-Inf,)
end
function PlantSimEngine.outputs_(::Beer)
(aPPFD=-Inf,)
end
These functions are internal, and end with an "_". Users instead use inputs
and outputs
to query model variables.
The run! method
When running a simulation with run!
, each model is run in turn at every timestep, following whatever order was deduced from the ModelList definition and Status. Each model also has its run!
method for that purpose that update the simulation's current state, with a slightly different signature. The function takes six arguments:
function run!(::Beer, models, status, meteo, constants, extras)
- the model's type
- 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, unit-conversion constants...) - extras: any other object you want to pass to your model, mostly for advanced usage, not detailed here
A typical run!
function can therefore make use of simulation constants, input/output variables accessible through the [Status
](@ref object, or weather data.
Here is the run!
implementation of the light interception for a ModelList
component models. Note that the input and output variable are accessed through the status
argument :
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)
Additional notes
To use this model, users will have to make sure that the variables for that model are defined in the Status
object, the meteorology, and the Constants
object.
!!! Note Status
objects contain the current state of the simulation. It is not, by default, possible to make use of earlier variable states, unless a custom model is written for that purpose.
Model parameters are available from the ModelList
that is passed via the models
argument. Index by the process name, then the parameter name. For example, the k
parameter of the Beer
model is found in models.light_interception.k
.
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_
. The troubleshooting subsection Implementing a model: forgetting to import or prefix functions showcases output errors that can occur when you forget to prefix.
Parallelization 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! We now a full new model implementation for the light interception process! Other models might be more complex in terms of what computations they do, or how they couple with other models, but the approach remains the same.
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 hard dependencies in Coupling more complex models.