Simulation Over Several Objects

This tutorial runs the same coupled PlantBiophysics model on two leaves. The leaves share one weather sequence, while each keeps its own state and absorbed radiation.

Build a small plant

Object describes the entities in the simulation. Here a scene contains one plant with a sunlit and a shaded leaf:

using PlantBiophysics, PlantSimEngine, PlantMeteo, Dates, DataFrames

weather = Weather([
    Atmosphere(T=20.0, Wind=1.0, P=101.3, Rh=0.65, duration=Hour(1)),
    Atmosphere(T=23.0, Wind=1.5, P=101.3, Rh=0.60, duration=Hour(1)),
    Atmosphere(T=25.0, Wind=2.0, P=101.3, Rh=0.55, duration=Hour(1)),
])

applications = (
    ModelSpec(Monteith(); name=:energy_balance, on=Many(scale=:Leaf)),
    ModelSpec(Fvcb(); name=:photosynthesis, on=Many(scale=:Leaf)),
    ModelSpec(Medlyn(0.03, 12.0); name=:stomatal_conductance, on=Many(scale=:Leaf)),
)

scene = CompositeModel(
    Object(:scene; scale=:Scene, kind=:scene),
    Object(:plant; scale=:Plant, kind=:plant, parent=:scene),
    Object(
        :sun_leaf;
        scale=:Leaf,
        kind=:plant,
        parent=:plant,
        status=Status(
            Ra_SW_f=20.0,
            sky_fraction=1.0,
            aPPFD=1500.0,
            d=0.03,
        ),
    ),
    Object(
        :shade_leaf;
        scale=:Leaf,
        kind=:plant,
        parent=:plant,
        status=Status(
            Ra_SW_f=8.0,
            sky_fraction=0.5,
            aPPFD=600.0,
            d=0.02,
        ),
    );
    applications=applications,
    environment=weather,
)

[(id=object.id, aPPFD=object.status.aPPFD) for
 object in model_objects(scene; scale=:Leaf)]
2-element Vector{@NamedTuple{id::ObjectId{Symbol}, aPPFD::Float64}}:
 (id = ObjectId{Symbol}(:shade_leaf), aPPFD = 600.0)
 (id = ObjectId{Symbol}(:sun_leaf), aPPFD = 1500.0)

Many(scale=:Leaf) applies each model once to every leaf. Model parameters are shared, while the two Status objects remain independent. Radiation is held constant per leaf here; see Simulation over several time steps for externally prescribed drivers that vary over time.

For repeated plants, a CompositeModelTemplate can share the topology and default model values while ObjectInstance provides plant-local scopes. Parameters may then be overridden for one instance or one exceptional object without duplicating the unchanged template.

Run every leaf over the shared weather

Output retention is explicit. outputs=:all is convenient for a small tutorial; use OutputRequest for large plants.

simulation = run!(scene; steps=length(weather), outputs=:all)

rows = DataFrame(collect_outputs(simulation; sink=nothing))
leaf_rows = subset(
    rows,
    :application_id => ByRow(==(:energy_balance)),
    :variable => ByRow(in((:Tₗ, :A, :Gₛ, :λE))),
)

leaf_results = unstack(
    select(leaf_rows, :timestep, :object_id, :variable, :value),
    [:timestep, :object_id],
    :variable,
    :value,
)
sort!(leaf_results, [:timestep, :object_id])
6×6 DataFrame
Rowtimestepobject_idAGₛTₗλE
Int64SymbolFloat64?Float64?Float64?Float64?
11shade_leaf26.65431.3283817.6854156.394
21sun_leaf32.30711.671317.6796147.815
32shade_leaf27.44281.1766520.1947220.776
42sun_leaf34.10211.5294420.0705209.997
53shade_leaf27.79091.0598221.7784283.867
63sun_leaf35.12121.4071621.5407272.392

The object_id column associates every value with the leaf that produced it; object declaration order is not an output contract. The latest state also remains available on each object:

Dict(
    object.id => (Tₗ=object.status.Tₗ, A=object.status.A, Gₛ=object.status.Gₛ)
    for object in model_objects(scene; scale=:Leaf)
)
Dict{ObjectId{Symbol}, @NamedTuple{Tₗ::Float64, A::Float64, Gₛ::Float64}} with 2 entries:
  ObjectId{Symbol}(:sun_leaf)   => (Tₗ = 21.5407, A = 35.1212, Gₛ = 1.40716)
  ObjectId{Symbol}(:shade_leaf) => (Tₗ = 21.7784, A = 27.7909, Gₛ = 1.05982)

Select values within a plant

Selectors make cross-object coupling explicit:

  • Self() means only the object where the consuming application runs.
  • Subtree() means that object and its descendants. A plant-scale model uses Many(scale=:Leaf, within=Subtree(), var=:A) to read only its own leaves.
  • SceneScope() searches the complete scene and is appropriate for a scene-scale aggregate.

For example, an input declaration for a plant-scale summary model would be:

ModelSpec(
    PlantSummaryModel();
    inputs=(
        :leaf_assimilation => Many(
            scale=:Leaf,
            within=Subtree(),
            application=:energy_balance,
            var=:A,
            policy=HoldLast(),
        ),
    ),
)

Continue with Whole-plant simulation from an MTG to adapt an existing plant topology to the same applications.