Detailed walkthrough of a simple simulation

This page walks you through the ins and outs of a basic simulation, mostly aimed at people who have less experience programming, to showcase the various concepts presented earlier and requirements for a simulation in context.

A working trimmed-down script can be found further down in the Example simulation, and other subsections in this page will detail setup and helper functions, and querying outputs.

If you simply wish to copy-paste examples and tinker with them, you can find a few examples on the Quick examples page.

Setting up your environment

For every script in this documentation, you will always need a working Julia environment with PlantSimengine added to it, and usually several other companion packages. Details for getting to that point are provided on the Installing and running PlantSimEngine page.

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 define a process, and then implement models for its simulation. In this example, we will make use of a process that was already defined, and for which there already is a model implementation.

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 several types of entries:

  • Parameters
  • Meteorological information
  • Variables
  • Constants
  • Extras

Parameters are constant values that are used by the model to compute its outputs, and are exclusive to that model.

Meteorological information contains 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. They can be part of multiple models, computed by one and then used as an input by another. They can also be a global simulation output, or be provided at the start of a simulation by the user.

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, or serves as a placeholder for internal data.

Users declare a set of models used for simulation, as well as the necessary parameters for each model, and whatever variables need to be initialized. This is done using a ModelList structure.

For example let's instantiate a ModelList with a single model : the Beer-Lambert model of light extinction, used to simulate the light interception process. The model is implemented with the Beer structure and only has 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 declare a ModelList with the Beer model:

m = ModelList(Beer(0.5))
PlantSimEngine.DependencyGraph{Dict{Symbol, PlantSimEngine.SoftDependencyNode}, Any}(Dict{Symbol, PlantSimEngine.SoftDependencyNode}(:light_interception => PlantSimEngine.Examples.Beer{Float64}
), Dict{Symbol, Any}())Status(LAI = -Inf, aPPFD = -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 value constant for a simulation that is internal to a model and used for its computations. For example, the Beer-Lambert model uses the extinction coefficient (k) to compute the light extinction. The Beer structure in the Beer-Lambert model implementation, only has one field: k. We can see that using fieldnames on the model structure:

fieldnames(Beer)
(:k,)

Variables (inputs, outputs)

Variables are either inputs or outputs (i.e. computed) of models. Variables and their values are stored in the ModelList structure, and are initialized automatically or manually.

For example, the Beer model needs the leaf area index (LAI, m² m⁻²) to run.

We can see which variables are passed in as inputs using inputs:

inputs(Beer(0.5))
(:LAI,)

and which are computed outputs of the model using outputs:

outputs(Beer(0.5))
(:aPPFD,)

The ModelList structure will keep track of every variable's current state when running the simulation, storing them in a field called status. We can inspect that field with the status function and see that in our example it 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 in the model's implementation code, which is usually equal to typemin(), e.g. -Inf for Float64.

Tip

Prefer using to_initialize rather than inputs to check which variables should be initialized. inputs returns every variable that is needed by the model to run, but in multi-model simulations, some of them may already be computed by other models and not require initialization. to_initialize returns only the variables that are needed by the model to run and that are not initialized in the ModelList.

We can initialize the required variables by providing their starting values to the status when declaring the ModelList:

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}())Status(LAI = 2.0, aPPFD = -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, to_initialize 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("2025-04-14T13:17:03.724"), 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)

This meteo variable will therefore provide a single weather timeframe that can be used in a simulation.

More details are available from the package documentation.

Simulation

Simulation of processes

To run a simulation, you can call the run! method on the ModelList. If some meteorological data is required for models to be simulated over several timesteps, that can be passed in as an optional argument as well.

Your call to the function would then look like this:

run!(model_list, meteo)

The first argument is the model list (see ModelList), and the second defines the micro-climatic conditions.

The ModelList should already be initialized for the given process before calling the function. Refer to the earlier subsection 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,))

outputs_example = run!(leaf, meteo)

outputs_example[: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 outputs of the very last timestep of a simulation using the status function.

The actual full output data is returned by the run! function. Data is usually stored in a TimeStepTable structure from PlantMeteo.jl, which is a fast DataFrame-like 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.

In our example, the simulation was only provided one weather timestep, so the outputs returned by run! and the ModelList's status field are identical.

Let's look at the outputs structure of our previous simulated leaf:

We can extract the value of one variable by indexing into it, e.g. for the intercepted light:

outputs_example[:aPPFD]
1-element Vector{Float64}:
 1444.3954769232544

Or similarly using the dot syntax:

outputs_example.aPPFD
1-element Vector{Float64}:
 1444.3954769232544

You can then print the outputs, convert them to another format, or visualize them, using other Julia packages. You can read more on how to do that in the Visualizing outputs and data page.

Another convenient 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
convert_outputs(outputs_example, DataFrame)
1×2 DataFrame
RowLAIaPPFD
Float64Float64
12.01444.4

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 Standard model coupling and Coupling more complex models for more details, or Handling dependencies in a multiscale context for multi-scale specific coupling considerations.