This teaching example describes daily biomass production as:
biomass increment = radiation-use efficiency × intercepted radiation
For an efficiency of 1.5 g dry matter per mol of photons and 10 mol of intercepted photons per plant, the result is 15 g dry matter per plant. These values show how to write a model; they are not a calibrated crop model.
We will give that equation a name, declare its variables and units, test it, and run it on two plants. Before adding your own model, use New process or new model? to choose its process.
A process identifies the scientific calculation. A model implements one hypothesis for it. This example declares a biomass-production process:
PlantSimEngine.@process "biomass_production" verbose=falseThe model stores one fixed parameter, rue. The {T} allows its numerical type to follow the supplied parameter.
struct RadiationUseEfficiency{T} <: AbstractBiomass_ProductionModel
rue::T
end| Value | Role | Meaning |
|---|---|---|
rue | Fixed parameter | g dry matter per mol intercepted photons |
intercepted_par | Input | Daily intercepted photons, mol per plant |
biomass_increment | Output | Daily biomass production, g dry matter per plant |
Required(Real) says the simulation must supply a real-valued input. zero(model.rue) initializes the output with the parameter's numerical type. This model reads no environmental variables directly.
PlantSimEngine.inputs_(::RadiationUseEfficiency) = (
intercepted_par=Required(Real),
)
PlantSimEngine.outputs_(model::RadiationUseEfficiency) = (
biomass_increment=zero(model.rue),
)
PlantSimEngine.environment_inputs_(::RadiationUseEfficiency) = NamedTuple()
PlantSimEngine.environment_outputs_(::RadiationUseEfficiency) = NamedTuple()A VariableContract describes a variable's units and meaning. It records, for example, whether a value is per plant or per square metre, and whether it is a rate or a daily total. This helps check that two connected models interpret a value in the same way. Here both quantities are daily totals for one plant:
const INTERCEPTED_PAR_CONTRACT = VariableContract(
unit=:mol_photon,
basis=:plant,
temporal=:day,
aggregation=:total,
extent=:extensive,
)
const BIOMASS_INCREMENT_CONTRACT = VariableContract(
unit=:g_dry_matter,
basis=:plant,
temporal=:day,
aggregation=:total,
extent=:extensive,
)Read these settings as follows: unit names the measurement unit, basis=:plant says it refers to one plant, and temporal=:day with aggregation=:total says it is a daily total. extent=:extensive means the amounts from several plants can be added to obtain their combined amount.
Attach those descriptions to the corresponding variables:
PlantSimEngine.variable_contracts_(::RadiationUseEfficiency) = (
intercepted_par=INTERCEPTED_PAR_CONTRACT,
biomass_increment=BIOMASS_INCREMENT_CONTRACT,
)These descriptions help check connections, but do not convert values. If one model supplies radiation per square metre and another needs radiation per plant, write the conversion in an adapter model.
The run! function reads the parameter from model, reads the input from status, and writes its result back to status:
function PlantSimEngine.run!(
model::RadiationUseEfficiency,
status,
environment,
constants,
context,
)
status.biomass_increment = model.rue * status.intercepted_par
return nothing
endThe other arguments provide environmental data, constants, and tools for calling other models or changing the simulated objects. This equation does not need them. Choose which plants use the model and when it runs in the simulation setup below.
These displayed definitions come from the package's executable skills/plantsimengine/assets/minimal-model.jl example. To load the complete example in your session:
using Dates, Test, PlantSimEngine
asset = joinpath(pkgdir(PlantSimEngine), "skills", "plantsimengine", "assets", "minimal-model.jl")
include(asset)
using .MinimalModelExample
model = RadiationUseEfficiency(1.5f0)
status = Status(intercepted_par=10.0f0, biomass_increment=0.0f0)
PlantSimEngine.run!(model, status, NamedTuple(), nothing, nothing)
@test status.biomass_increment == 15.0f0
@test status.biomass_increment isa Float32
status.biomass_incrementThe f0 notation chooses Float32. The tests check the equation and that the implementation preserves this numerical type. Also check the declarations:
validation = Authoring.validate_model(model; strict=true)
@test validation.valid
validation.validThat check finds missing or inconsistent model declarations. To check whether the equation describes real plants, compare its results with appropriate observations or reference results.
Each plant has its own intercepted radiation. The same model applies to both:
plants = CompositeModel(
Object(:plant_1; scale=:Plant, status=Status(intercepted_par=10.0f0)),
Object(:plant_2; scale=:Plant, status=Status(intercepted_par=6.0f0));
applications=(
ModelSpec(model; name=:biomass_production, on=Many(scale=:Plant)),
),
environment=(duration=Day(1),),
)
simulation = run!(plants)
result_1 = final_state(simulation, :plant_1).biomass_increment
result_2 = final_state(simulation, :plant_2).biomass_increment
@test (result_1, result_2) == (15.0f0, 9.0f0)
(plant_1=result_1, plant_2=result_2)PlantSimEngine calls the equation once for each selected plant. Each plant keeps its own input and result, so you do not need to write a loop over plants inside the model.
Port an existing model: separate a calculation from its original script.
Model repository layout and tests: organize a package and its checks.
Implement Cross-Object Values: read another object's result or sum several.
Model compatibility and replacement: compare another hypothesis.
Loaded model catalog: discover and inspect models already loaded.