PlantSimEngine.jlPlantSimEngine.jl

Collecting And Plotting Outputs​#

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.

Run and plot one canopy​#

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.

julia
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)
3×6 DataFrame
Rowtimestepdatetimeapplication_idobject_idvariablevalue
Int64DateTimeSymbolSymbolSymbolFloat64
112025-07-01T06:00:00light_interceptioncanopyaPPFD0.0
222025-07-01T07:00:00light_interceptioncanopyaPPFD159.677
332025-07-01T08:00:00light_interceptioncanopyaPPFD479.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.

julia
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_one

With 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.

Keep two canopies separate​#

Apply the same model to two independent canopies. Only their LAI differs. An application name such as :light identifies this configured use of Beer.

julia
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_two

The denser canopy absorbs more PAR per unit ground area under the same incoming light. Grouping by application and object keeps the two curves separate.

Retain only the outputs you need​#

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:

julia
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)
4×8 DataFrame
Rowtimestepdatetimescaleprocessapplication_idvariableobject_idvalue
Int64DateTimeSymbolSymbolSymbolSymbolSymbolFloat64
112025-07-01T06:00:00Canopylight_interceptionlightaPPFDdense_canopy0.0
212025-07-01T06:00:00Canopylight_interceptionlightaPPFDopen_canopy0.0
322025-07-01T07:00:00Canopylight_interceptionlightaPPFDdense_canopy190.729
422025-07-01T07:00:00Canopylight_interceptionlightaPPFDopen_canopy103.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.

Reading the result tables​#

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.