Plot absorbed light through a day, then compare two canopies with different leaf area indices. This page uses the existing Beer model, so you can focus on running a simulation and analysing its results. The radiation values are illustrative, not observations from an experiment.
The tutorial installation includes DataFrames for tables and CairoMakie for plots.
Supply thirteen hourly PAR fluxes, from 06:00 to 18:00, in W m⁻² of ground. The canopy has 2 m² of leaves per m² of ground throughout this example.
using Dates, DataFrames, CairoMakie, PlantSimEngine
using PlantSimEngine.Examples
incident_par = [0.0, 50, 150, 300, 500, 650, 700, 650, 500, 300, 150, 50, 0]
weather = [(Ri_PAR_f=par, duration=Hour(1)) for par in incident_par]
model = CompositeModel(
Beer(0.6);
id=:canopy, scale=:Canopy,
status=(LAI=2.0,), environment=weather,
)
simulation = run!(model; steps=length(weather), outputs=:all)
rows = collect_outputs(simulation; sink=DataFrame)
light = rows[rows.variable .== :aPPFD, :]
first(light, 3)| Row | timestep | time | application_id | object_id | variable | value |
|---|---|---|---|---|---|---|
| Int64 | Float64 | Symbol | Symbol | Symbol | Float64 | |
| 1 | 1 | 1.0 | light_interception | canopy | aPPFD | 0.0 |
| 2 | 2 | 2.0 | light_interception | canopy | aPPFD | 159.677 |
| 3 | 3 | 3.0 | light_interception | canopy | aPPFD | 479.031 |
sink=DataFrame asks for a DataFrame; this is already the default if you leave out sink. Each row records one variable for one object at one time, and identifies which model application produced it. aPPFD is absorbed PAR in μmol m⁻² of ground s⁻¹. The time column uses simulation steps rather than clock hours, with the first record at 1. In this example each step lasts one hour and the first record is at 06:00, so 6 + (time - 1) gives the hour of day. Use your weather data's actual dates and durations when they follow a different timetable.
light.hour_of_day = 6 .+ (light.time .- 1)
figure_one = Figure(size=(740, 380), fontsize=16)
axis_one = Axis(figure_one[1, 1],
xlabel="Hour of day",
ylabel="Absorbed PAR\n(μmol m⁻² ground s⁻¹)",
xticks=6:2:18,
)
scatterlines!(axis_one, light.hour_of_day, light.value;
color=:seagreen, linewidth=2.5, markersize=7)
@assert nrow(light) == length(incident_par)
@assert first(light.value) == last(light.value) == 0.0
figure_oneWith fixed leaf area, absorption follows the supplied radiation. This curve shows a mean flux for each hourly record. To calculate the total absorbed over an interval, multiply each flux by its duration in seconds and add the amounts. See different model cadences for models that run at different intervals.
Apply the same model to two independent canopies. Only their LAI differs. An application name such as :light identifies this configured use of Beer.
two_canopies = CompositeModel(
Object(:open_canopy; scale=:Canopy, status=Status(LAI=1.0)),
Object(:dense_canopy; scale=:Canopy, status=Status(LAI=3.0));
applications=(ModelSpec(Beer(0.6); name=:light, on=Many(scale=:Canopy)),),
environment=weather,
)
comparison = run!(two_canopies; steps=length(weather), outputs=:all)
comparison_rows = collect_outputs(comparison; sink=DataFrame)
comparison_light = comparison_rows[
(comparison_rows.application_id .== :light) .& (comparison_rows.variable .== :aPPFD), :]
comparison_light.hour_of_day = 6 .+ (comparison_light.time .- 1)
figure_two = Figure(size=(740, 380), fontsize=16)
axis_two = Axis(figure_two[1, 1],
xlabel="Hour of day",
ylabel="Absorbed PAR\n(μmol m⁻² ground s⁻¹)",
xticks=6:2:18,
)
for series in groupby(comparison_light, [:application_id, :object_id])
sort!(series, :time)
scatterlines!(axis_two, series.hour_of_day, series.value;
label=string(first(series.object_id)), linewidth=2.5, markersize=6)
end
axislegend(axis_two; position=:lt, framevisible=false)
@assert nrow(comparison_light) == 2 * length(incident_par)
figure_twoThe denser canopy absorbs more PAR per unit ground area under the same incoming light. Grouping by application and object keeps the two curves separate. Filtering only by variable and drawing one line would incorrectly join different objects.
outputs=:all is convenient for these small examples. Larger simulations can save selected variables with OutputRequest. Here, the new run keeps only the light result requested from the two canopies:
selected_simulation = run!(
two_canopies;
steps=length(weather),
outputs=OutputRequest(
Many(scale=:Canopy), :aPPFD;
name=:absorbed_light, application=:light,
),
)
selected = collect_outputs(selected_simulation, :absorbed_light; sink=DataFrame)
@assert nrow(selected) == 2 * length(incident_par)
first(selected, 4)| Row | timestep | time | scale | process | application_id | variable | object_id | value |
|---|---|---|---|---|---|---|---|---|
| Int64 | Float64 | Symbol | Symbol | Symbol | Symbol | Symbol | Float64 | |
| 1 | 1 | 1.0 | Canopy | light_interception | light | aPPFD | dense_canopy | 0.0 |
| 2 | 1 | 1.0 | Canopy | light_interception | light | aPPFD | open_canopy | 0.0 |
| 3 | 2 | 2.0 | Canopy | light_interception | light | aPPFD | dense_canopy | 190.729 |
| 4 | 2 | 2.0 | Canopy | light_interception | light | aPPFD | open_canopy | 103.097 |
A new run! starts a fresh timeline. Use step! or continue! when you want to extend an existing simulation instead. Runs default to outputs=:none; final_state(simulation) remains available when you only need the latest values.
PlantSimEngine may also keep past values because another model needs them, even if you did not request those values for analysis. When memory use matters, Diagnostics.explain_output_retention(simulation) explains why each time series is being stored.
The basic result table has the columns timestep, time, application_id, object_id, variable and value. Tables produced by an OutputRequest also identify scale and process. Requests that combine values over time or sample them at new times can return missing if there is not enough saved history. Check when the model ran and when the simulation started before interpreting a missing result as a gap in the weather observations.
Saved values, including arrays, record the result at that time. Later changes do not rewrite earlier rows, and removing an organ does not delete its past results. Compatible numerical types are preserved, including values with attached physical units when your model uses them.