PlantBiophysics.jlPlantBiophysics.jl

Simulation Over Several Time Steps​#

After the one-timestep example, let's simulate a leaf over six hours. A simple light model absorbs a fixed fraction of the incoming radiation at each hour. We will run the whole weather series in one call, collect the results, and plot the leaf's response.

Prepare the input table​#

Weather is a sequence of Atmosphere values, one for each timestep. Here we create it from a small table; you can also import measured weather as shown on the Micro-climate page.

julia
using PlantBiophysics, PlantSimEngine, PlantMeteo, Dates, DataFrames

forcing = DataFrame(
    timestep=1:6,
    T=[20.0, 21.0, 23.0, 25.0, 24.0, 22.0],
    Wind=[1.0, 1.0, 1.5, 2.0, 1.5, 1.0],
    Rh=[0.65, 0.62, 0.58, 0.55, 0.58, 0.63],
    Ri_PAR_f=[100.0, 200.0, 300.0, 360.0, 200.0, 80.0],
    Ri_NIR_f=[120.0, 240.0, 360.0, 430.0, 240.0, 100.0],
    P=fill(101.3, 6),
    duration=fill(Hour(1), 6)
)

weather = Weather(forcing)
first(forcing, 3)
3×8 DataFrame
RowtimestepTWindRhRi_PAR_fRi_NIR_fPduration
Int64Float64Float64Float64Float64Float64Float64Hour
1120.01.00.65100.0120.0101.31 hour
2221.01.00.62200.0240.0101.31 hour
3323.01.50.58300.0360.0101.31 hour

Weather supplies the appropriate row automatically at each timestep. Ri_PAR_f and Ri_NIR_f are incoming photosynthetically active and near-infrared radiation arriving at the leaf, both in W m⁻² of leaf area. The light model will calculate how much of this radiation the leaf absorbs.

The other units are °C for T, m s⁻¹ for Wind, and kPa for P. Rh is a fraction, not a percentage. These values describe an illustrative six-hour weather sequence.

Assemble the leaf model​#

Add ConstantAbsorption to the same leaf model as before. Here it absorbs 80% of incoming PAR and 20% of incoming NIR. These are illustrative fractions; choose values appropriate for your leaf and light source.

julia
scene = CompositeModel(
    ConstantAbsorption(α_PAR=0.8, α_NIR=0.2),
    Monteith(),
    Fvcb(),
    Medlyn(0.03, 12.0);
    status=Status(sky_fraction=1.0, d=0.03),
    environment=weather,
)

ConstantAbsorption supplies aPPFD to photosynthesis and Ra_SW_f to energy balance automatically. The fractions stay constant, but the absorbed radiation changes with each weather row. No LAI or manual radiation update is needed. See Light interception for the equations and an example with controlled lighting.

Run and retain the results​#

Pass the number of weather records to run! to simulate all six hours. PlantSimEngine recalculates light interception and the leaf response at each step, so there is no need to update radiation values by hand.

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

outputs=:all saves the calculated values at every step; the default outputs=:none only leaves the latest values on the objects.

The latest state is always available directly on the leaf:

julia
leaf = only(model_objects(scene))
(Tₗ=leaf.status.Tₗ, A=leaf.status.A, Gₛ=leaf.status.Gₛ, λE=leaf.status.λE)
(Tₗ = 20.473086197373426, A = 18.122999226412023, Gₛ = 0.7554681951737053, λE = 166.7531263742402)

And more generally, collect_outputs returns a long-form table of all recorded variables that were requested in run!. Each row corresponds to a single variable, object, application, and timestep:

julia
sim_long_form = collect_outputs(simulation; sink=DataFrame)

first(sim_long_form, 3) # Only showing the first three rows for brevity
3×6 DataFrame
Rowtimestepdatetimeapplication_idobject_idvariablevalue
Int64DateTimeSymbolSymbolSymbolFloat64
112026-10-01T09:33:24.488energy_balancesceneA21.0687
212026-10-01T09:33:24.488energy_balancesceneCᵢ344.891
312026-10-01T09:33:24.488energy_balancesceneCₛ367.605

Match outputs to input timesteps​#

We can turn the long-form representation into a wide table with one row per timestep using unstack. Then we can join the meteorological data with the simulation results using leftjoin. This puts the inputs and results in the same table. We use timestep to match each result to its input row.

Note

Requiring all variables as outputs makes a slower simulation. If you only need a few variables, request them explicitly in run! to speed up the calculation. Else, you can filter the long-form table after the simulation to keep only the variables you need using subset.

julia
# Selecting only the variables we need:
df_long_form_few = subset(
    sim_long_form,
    :variable => ByRow(in((:Tₗ, :A, :Gₛ, :λE))),
)

df_wide = unstack(
    select(df_long_form_few, :timestep, :variable, :value),
    :timestep,
    :variable,
    :value,
)

# Joining the weather and simulation results:
df = leftjoin(forcing, df_wide; on=:timestep)
# Selecting only the variables we need:
select(df, :timestep, :T, :Ri_PAR_f, :Ri_NIR_f, :Tₗ, :A, :Gₛ, :λE)
6×8 DataFrame
RowtimestepTRi_PAR_fRi_NIR_fTₗAGₛλE
Int64Float64Float64Float64Float64?Float64?Float64?Float64?
1120.0100.0120.018.899221.06870.930119163.07
2221.0200.0240.020.570829.18111.16672230.294
3323.0300.0360.022.834.7931.0901323.47
4425.0360.0430.024.396335.60071.16842415.887
5524.0200.0240.022.637929.91471.09635294.163
6622.080.0100.020.473118.1230.755468166.753

The long-form representation also records the publishing application and object. Filter by application_id before reshaping whenever several applications may publish variables with the same name.

Plot the leaf response​#

The first panel compares leaf and air temperature. The second shows how net assimilation changes with the supplied weather and light.

julia
using CairoMakie

figure = Figure(size=(800, 330))
temperature_axis = Axis(figure[1, 1]; xlabel="Timestep (hour)", ylabel="Temperature (°C)")
lines!(temperature_axis, df.timestep, df.T; label="Air")
lines!(temperature_axis, df.timestep, Float64.(df.Tₗ); label="Leaf")
axislegend(temperature_axis; position=:lt)

assimilation_axis = Axis(figure[1, 2]; xlabel="Timestep (hour)", ylabel="A (µmol CO₂ m⁻² s⁻¹)")
scatterlines!(assimilation_axis, df.timestep, Float64.(df.A))
figure

Continue with Several objects to compare leaves receiving different amounts of light.