Package design
PlantSimEngine.jl
is designed to ease the process of modelling and simulation of plants, soil and atmosphere, or really any system (e.g. agroforestry system, agrivoltaics...). PlantSimEngine.jl
aims at being the backbone tool for developing Functional-Structural Plant Models (FSPM) and crop models without the hassle of performance and other computer-science considerations.
Definitions
Processes
A process in this package defines a biological or physical phenomena. Think of any process happening in a system, such as light interception, photosynthesis, water, carbon and energy fluxes, growth, yield or even electricity produced by solar panels.
A process is "declared", meaning we just define a process using @process
, and then we implement models for its simulation. Declaring a process generates some boilerplate code for its simulation:
- an abstract type for the process
- a method for the
process
function, that is used internally
For example, the light_interception
process is declared using:
@process light_interception
Which would generate a tutorial to help the user implement a model for the process.
The abstract process type is then used as a supertype of all models implementations for the process, and is named Abstract<process_name>Process
, e.g. AbstractLight_InterceptionModel
.
Models (ModelList)
A process is simulated using a particular implementation, or a model. Each model is implemented using a structure that lists the parameters of the model. For example, PlantBiophysics provides the Beer
structure for the implementation of the Beer-Lambert law of light extinction. The process of light_interception
and the Beer
model are provided as an example script in this package too at examples/Beer.jl
.
Models can use three types of entries:
- Parameters
- Meteorological information
- Variables
- Constants
- Extras
Parameters are constant values that are used by the model to compute its outputs. Meteorological information are values that are provided by the user and are used as inputs to the model. It is defined for one time-step, and PlantSimEngine.jl
takes care of applying the model to each time-steps given by the user. Variables are either used or computed by the model and can optionally be initialized before the simulation. Constants are constant values, usually common between models, e.g. the universal gas constant. And extras are just extra values that can be used by a model, it is for example used to pass the current node of the Multi-Scale Tree Graph to be able to e.g. retrieve children or ancestors values.
Users can choose which model is used to simulate a process using the ModelList
structure. ModelList
is also used to store the values of the parameters, and to initialize variables.
For example let's instantiate a ModelList
with the Beer-Lambert model of light extinction. The model is implemented with the Beer
structure and has only one parameter: the extinction coefficient (k
).
Importing the package:
using PlantSimEngine
Import the examples defined in the Examples
sub-module (light_interception
and Beer
):
using PlantSimEngine.Examples
And then making a ModelList
with the Beer
model:
ModelList(Beer(0.5))
PlantSimEngine.DependencyGraph{Dict{Symbol, PlantSimEngine.SoftDependencyNode}, Any}(Dict{Symbol, PlantSimEngine.SoftDependencyNode}(:light_interception => PlantSimEngine.Examples.Beer{Float64}
), Dict{Symbol, Any}())TimeStepTable{Status{(:LAI, :aPPFD), Tuple{...}(1 x 2):
╭─────┬─────────┬─────────╮
│ Row │ LAI │ aPPFD │
│ │ Float64 │ Float64 │
├─────┼─────────┼─────────┤
│ 1 │ -Inf │ -Inf │
╰─────┴─────────┴─────────╯
What happened here? We provided an instance of the Beer
model to a ModelList
to simulate the light interception process.
Parameters
A parameter is a constant value that is used by a model to compute its outputs. For example, the Beer-Lambert model uses the extinction coefficient (k
) to compute the light extinction. The Beer-Lambert model is implemented with the Beer
structure, which has only one field: k
. We can see that using fieldnames
on the model structure:
fieldnames(Beer)
(:k,)
Variables (inputs, outputs)
Variables are either input or outputs (i.e. computed) of models. Variables and their values are stored in the ModelList
structure, and are initialized automatically or manually.
Hence, ModelList
objects stores two fields:
fieldnames(ModelList)
(:models, :status, :vars_not_propagated)
The first field is a list of models associated to the processes they simulate. The second, :status
, is used to hold all inputs and outputs of our models, called variables. For example the Beer
model needs the leaf area index (LAI
, m² m⁻²) to run.
We can see which variables are needed as inputs using inputs
:
inputs(Beer(0.5))
(:LAI,)
and the outputs of the model using outputs
:
outputs(Beer(0.5))
(:aPPFD,)
If we instantiate a ModelList
with the Beer-Lambert model, we can see that the :status
field has two variables: LAI
and PPFD
. The first is an input, the second an output (i.e. it is computed by the model).
m = ModelList(Beer(0.5))
keys(status(m))
(:LAI, :aPPFD)
To know which variables should be initialized, we can use to_initialize
:
m = ModelList(Beer(0.5))
to_initialize(m)
(light_interception = (:LAI,),)
Their values are uninitialized though (hence the warnings):
(m[:LAI], m[:aPPFD])
([-Inf], [-Inf])
Uninitialized variables are initialized to the value given in the inputs
or outputs
methods, which is usually equal to typemin()
, e.g. -Inf
for Float64
.
Prefer using to_initialize
rather than inputs
to check which variables should be initialized. inputs
returns the variables that are needed by the model to run, but to_initialize
returns the variables that are needed by the model to run and that are not initialized in the ModelList
. Also to_initialize
is more clever when coupling models (see below).
We can initialize the variables by providing their values to the status at instantiation:
m = ModelList(Beer(0.5), status = (LAI = 2.0,))
PlantSimEngine.DependencyGraph{Dict{Symbol, PlantSimEngine.SoftDependencyNode}, Any}(Dict{Symbol, PlantSimEngine.SoftDependencyNode}(:light_interception => PlantSimEngine.Examples.Beer{Float64}
), Dict{Symbol, Any}())TimeStepTable{Status{(:LAI, :aPPFD), Tuple{...}(1 x 2):
╭─────┬─────────┬─────────╮
│ Row │ LAI │ aPPFD │
│ │ Float64 │ Float64 │
├─────┼─────────┼─────────┤
│ 1 │ 2.0 │ -Inf │
╰─────┴─────────┴─────────╯
Or after instantiation using init_status!
:
m = ModelList(Beer(0.5))
init_status!(m, LAI = 2.0)
[ Info: Some variables must be initialized before simulation: (light_interception = (:LAI,),) (see `to_initialize()`)
We can check if a component is correctly initialized using is_initialized
:
is_initialized(m)
true
Some variables are inputs of models, but outputs of other models. When we couple models, PlantSimEngine.jl
is clever and only requests the variables that are not computed by other models.
Climate forcing
To make a simulation, we usually need the climatic/meteorological conditions measured close to the object or component.
Users are strongly encouraged to use PlantMeteo.jl
, the companion package that helps manage such data, with default pre-computations and structures for efficient computations. The most basic data structure from this package is a type called Atmosphere
, which defines steady-state atmospheric conditions, i.e. the conditions are considered at equilibrium. Another structure is available to define different consecutive time-steps: TimeStepTable
.
The mandatory variables to provide for an Atmosphere
are: T
(air temperature in °C), Rh
(relative humidity, 0-1) and Wind
(the wind speed, m s⁻¹). In our example, we also need the incoming photosynthetically active radiation flux (Ri_PAR_f
, W m⁻²). We can declare such conditions like so:
using PlantMeteo
meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0)
Atmosphere(date = Dates.DateTime("2024-10-28T16:49:54.819"), duration = Dates.Second(1), T = 20.0, Wind = 1.0, P = 101.325, Rh = 0.65, Precipitations = 0.0, Cₐ = 400.0, e = 1.5255470730405223, eₛ = 2.3469954969854188, VPD = 0.8214484239448965, ρ = 1.2040822421461452, λ = 2.4537e6, γ = 0.06725339460440805, ε = 0.5848056484857892, Δ = 0.14573378083416522, clearness = Inf, Ri_SW_f = Inf, Ri_PAR_f = 500.0, Ri_NIR_f = Inf, Ri_TIR_f = Inf, Ri_custom_f = Inf)
More details are available from the package documentation.
Simulation
Simulation of processes
Making a simulation is rather simple, we simply use run!
on the ModelList
:
The call to run!
is the same whatever the models you choose for simulating the processes. This is some magic allowed by PlantSimEngine.jl
! Here is an example:
run!(model_list, meteo)
The first argument is the model list (see ModelList
), and the second defines the micro-climatic conditions.
The ModelList
should be initialized for the given process before calling the function. See Variables (inputs, outputs) for more details.
Example simulation
For example we can simulate the light_interception
of a leaf like so:
using PlantSimEngine, PlantMeteo
# Import the examples defined in the `Examples` sub-module
using PlantSimEngine.Examples
meteo = Atmosphere(T = 20.0, Wind = 1.0, Rh = 0.65, Ri_PAR_f = 500.0)
leaf = ModelList(Beer(0.5), status = (LAI = 2.0,))
run!(leaf, meteo)
leaf[:aPPFD]
1-element Vector{Float64}:
1444.3954769232544
Outputs
The status
field of a ModelList
is used to initialize the variables before simulation and then to keep track of their values during and after the simulation. We can extract the simulation outputs of a model list using the status
function.
The status is usually stored in a TimeStepTable
structure from PlantMeteo.jl
, which is a fast DataFrame-alike structure with each time step being a Status
. It can be also be any Tables.jl
structure, such as a regular DataFrame
. The weather is also usually stored in a TimeStepTable
but with each time step being an Atmosphere
.
Let's look at the status of our previous simulated leaf:
We can extract the value of one variable using the status
function, e.g. for the intercepted light:
status(leaf, :aPPFD)
1-element Vector{Float64}:
1444.3954769232544
Or similarly using the dot syntax:
leaf.status.aPPFD
1-element Vector{Float64}:
1444.3954769232544
Or much simpler (and recommended), by indexing directly into the model list:
leaf[:aPPFD]
1-element Vector{Float64}:
1444.3954769232544
Another simple way to get the results is to transform the outputs into a DataFrame
. Which is very easy because the TimeStepTable
implements the Tables.jl interface:
using DataFrames
DataFrame(leaf)
Row | LAI | aPPFD | timestep |
---|---|---|---|
Float64 | Float64 | Int64 | |
1 | 2.0 | 1444.4 | 1 |
The output from DataFrame
is adapted to the kind of simulation you did: one row per time-step, and per component models if you simulated several.
Model coupling
A model can work either independently or in conjunction with other models. For example a stomatal conductance model is often associated with a photosynthesis model, i.e. it is called from the photosynthesis model.
PlantSimEngine.jl
is designed to make model coupling painless for modelers and users. Please see Model coupling for users and Model coupling for modelers for more details.