Let's plot absorbed light through a day, then compare two canopies with different leaf area indices. We'll use the existing Beer model, so we can focus on running a simulation and analysing its results.
The tutorial installation includes DataFrames for tables and CairoMakie for plots, go there to set it up if you need it.
We'll use 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 = [
(date=DateTime(2025, 7, 1, h), Ri_PAR_f=par, duration=Hour(1))
for (h, par) in zip(6:18, 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 | datetime | application_id | object_id | variable | value |
|---|---|---|---|---|---|---|
| Int64 | DateTime | Symbol | Symbol | Symbol | Float64 | |
| 1 | 1 | 2025-07-01T06:00:00 | light_interception | canopy | aPPFD | 0.0 |
| 2 | 2 | 2025-07-01T07:00:00 | light_interception | canopy | aPPFD | 159.677 |
| 3 | 3 | 2025-07-01T08:00:00 | 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 datetime column contains the date and time supplied in the weather record for that simulation step. Here the records run from 06:00 to 18:00 on 1 July 2025, so we can extract their hours directly for the plot. The integer timestep column identifies the global simulation step, with the first record at 1.
light.hour_of_day = hour.(light.datetime)
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 = hour.(comparison_light.datetime)
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, :timestep)
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.
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 | datetime | scale | process | application_id | variable | object_id | value |
|---|---|---|---|---|---|---|---|---|
| Int64 | DateTime | Symbol | Symbol | Symbol | Symbol | Symbol | Float64 | |
| 1 | 1 | 2025-07-01T06:00:00 | Canopy | light_interception | light | aPPFD | dense_canopy | 0.0 |
| 2 | 1 | 2025-07-01T06:00:00 | Canopy | light_interception | light | aPPFD | open_canopy | 0.0 |
| 3 | 2 | 2025-07-01T07:00:00 | Canopy | light_interception | light | aPPFD | dense_canopy | 190.729 |
| 4 | 2 | 2025-07-01T07:00:00 | 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, datetime, application_id, object_id, variable and value. Tables produced by an OutputRequest also identify scale and process.
datetime follows the global simulation step. When a model runs less often, each output row still uses the weather record for its global step. For an OutputRequest that holds, interpolates, or combines values, it identifies the requested sampling step. Contributing values may have been published at earlier steps.
Dates are saved from the environment's weather records when run! starts. A Date becomes a DateTime at midnight. Undated records and custom environment backends produce missing. A single weather record reused across steps keeps its supplied date; PlantSimEngine does not invent later dates from its duration.
Requests that combine values over time or sample them at new times can return missing values 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.