Canopy development might need an update once a day, while light interception needs one every hour. A model's cadence is how often it runs. This page shows how an hourly light model uses a daily LAI value, then how to calculate daily water uptake from hourly rates.
The simulation's base step is its smallest time interval. Each model's cadence must be a whole number of base steps: hourly and 90-minute models can share a 30-minute base step. PlantSimEngine rejects every=Minute(90) with an hourly base step. You can use fixed durations, including fractions of a second. Calendar months and timesteps that change during a run are not supported as model cadences.
Reuse the models that calculate thermal time, LAI, and absorbed light. The weather advances hourly. Thermal time and LAI update daily, while light is calculated every hour. HoldLast tells the light model to keep using the most recent LAI value until a new one is calculated.
using PlantSimEngine, Dates, DataFrames
using PlantSimEngine.Examples
hourly_forcing = [
(T=20.0, Ri_PAR_f=300.0, duration=Hour(1))
for _ in 1:25
]
model = CompositeModel(
Object(:canopy; scale=:Canopy, kind=:canopy, status=Status(TT_cu=600.0));
applications=(
ModelSpec(
ToyDegreeDaysCumulModel();
name=:degree_days,
on=One(scale=:Canopy),
every=Day(1),
),
ModelSpec(
ToyLAIModel();
name=:lai,
on=One(scale=:Canopy),
every=Day(1),
),
ModelSpec(
Beer(0.6);
name=:light,
on=One(scale=:Canopy),
inputs=(
:LAI => One(
within=Self(),
application=:lai,
var=:LAI,
policy=HoldLast(),
window=Day(1),
),
),
every=Hour(1),
),
),
environment=hourly_forcing,
)
simulation = run!(model; steps=25, outputs=:all)First inspect the outputs at the beginning of the run and around the daily update. LAI is in m² of leaves per m² of ground; absorbed PAR is in μmol m⁻² of ground s⁻¹. The initial 600 °C d is an illustrative development stage, chosen so the LAI changes are visible.
rows = collect_outputs(simulation; sink=DataFrame)
light_samples = rows[(rows.variable .== :aPPFD) .& in.(rows.timestep, Ref((1, 2, 24, 25))),
[:timestep, :value]]
light_samples| Row | timestep | value |
|---|---|---|
| Int64 | Float64 | |
| 1 | 1 | 706.657 |
| 2 | 2 | 706.657 |
| 3 | 24 | 706.657 |
| 4 | 25 | 743.913 |
The daily models run at steps 1 and 25. They start at the beginning of the simulation, so the first update does not wait for a whole day of weather. The hourly light model uses the first LAI value until the next daily update. If your equation needs a full preceding day, decide what it should do at the start, before that history is available.
Check how often each model runs, in seconds (dt_seconds) and in base steps (dt_steps):
select(
DataFrame(Diagnostics.explain_schedule(model)),
:application_id,
:dt_seconds,
:dt_steps,
)| Row | application_id | dt_seconds | dt_steps |
|---|---|---|---|
| Symbol | Float64 | Float64 | |
| 1 | degree_days | 86400.0 | 24.0 |
| 2 | lai | 86400.0 | 24.0 |
| 3 | light | 3600.0 | 1.0 |
You can also check which rule each input uses to read earlier values. The LAI input uses HoldLast because we chose it in inputs above. Sharing a canopy object does not choose that rule for us:
select(
DataFrame(Diagnostics.explain_bindings(model)),
:application_id,
:input,
:policy,
:window,
:carrier_kind,
)| Row | application_id | input | policy | window | carrier_kind |
|---|---|---|---|---|---|
| Symbol | Symbol | HoldLast | Union… | Symbol | |
| 1 | lai | TT_cu | HoldLast() | ref | |
| 2 | light | LAI | HoldLast() | 1 day | temporal_stream |
There are two results for each daily model, at steps 1 and 25. The hourly model has a result at every step. The nsamples column counts them:
select(
DataFrame(Diagnostics.explain_outputs(simulation)),
:application_id,
:variable,
:nsamples,
)| Row | application_id | variable | nsamples |
|---|---|---|---|
| Symbol | Symbol | Int64 | |
| 1 | degree_days | TT | 2 |
| 2 | degree_days | TT_cu | 2 |
| 3 | lai | LAI | 2 |
| 4 | light | aPPFD | 25 |
HoldLast fits this example because LAI describes the canopy at a given time. The daily development model assumes that LAI stays unchanged between updates.
To turn a water-uptake rate into an amount, multiply each rate by the time it represents, then add the amounts. Integrate(reducer) lets you supply that calculation as a function, called a reducer. Be careful: Integrate() without this function only adds the values; it does not multiply by time.
The function below uses durations in seconds. For a rate in mg per second, 24 hourly values give a daily amount in mg. The plant then adds the amounts from its two leaves. The constant rates are teaching values, not predictions of water demand.
Load the teaching models. One copies a leaf's supplied water-uptake rate; the other sums amounts from leaves. Their source is shown in Hourly, Daily, And Weekly Models.
include(joinpath(pkgdir(PlantSimEngine), "docs", "src", "guides", "time", "teaching_models.jl"))
using .TeachingTimeModelsflux_model = CompositeModel(
Object(:plant; scale=:Plant),
Object(
:leaf_1;
scale=:Leaf,
parent=:plant,
status=Status(rate_mg_s=1.0),
),
Object(
:leaf_2;
scale=:Leaf,
parent=:plant,
status=Status(rate_mg_s=2.0),
);
applications=(
ModelSpec(
HourlyWaterRate();
name=:hourly_flux,
on=Many(scale=:Leaf),
every=Hour(1),
),
ModelSpec(
SumWaterAmounts();
name=:daily_amount,
on=One(scale=:Plant),
inputs=(
:amounts_mg => Many(
scale=:Leaf,
within=Subtree(),
application=:hourly_flux,
var=:water_rate_mg_s,
policy=Integrate((values, durations_seconds) -> sum(values .* durations_seconds)),
window=Day(1),
),
),
every=Day(1),
),
),
environment=[(duration=Hour(1),) for _ in 1:25],
)
flux_simulation = run!(flux_model; steps=25, outputs=:all)
amount = final_state(flux_simulation, One(scale=:Plant)).water_amount_mg
@assert amount == 259200.0
amountThe complete-day result is (1 + 2) × 24 × 3600 = 259200 mg of water. The first execution has only one hour of available history, so it produces 10800 mg; step 25 has a complete 24-hour rolling window:
water_rows = collect_outputs(flux_simulation; sink=DataFrame)
daily_rows = water_rows[water_rows.application_id .== :daily_amount, [:timestep, :value]]
@assert daily_rows.value == [10800.0, 259200.0]
daily_rows| Row | timestep | value |
|---|---|---|
| Int64 | Float64 | |
| 1 | 1 | 10800.0 |
| 2 | 25 | 259200.0 |
Each window looks back over a fixed duration; it does not automatically start at midnight. The weekly example extends this same calculation to seven days and checks the first result separately. Use Aggregate(reducer) to calculate a mean, minimum, maximum, or another statistic from the values in a window.
When models declare units and physical meaning
A VariableContract describes a variable's units and physical meaning. The teaching models above do not declare these contracts. If your models do declare them, the two sides of a connection must match. Integrate() changes the values but does not change their declared contract, so it cannot directly connect a rate contract to an amount contract.
Instead, write a small conversion model. Its input declares the rate contract, and its output declares the amount contract expected by the next model. If the rate changes, calculate each amount when the rate updates, or use a rate correctly averaged over the interval. Multiplying one held value by a long duration is only correct when the rate stays constant over that interval. See Coupling models.
Neither example needs PreviousTimeStep: no two models wait for each other's result in the same step. Use that option only when an equation should read the preceding step. See Diagnosing Dependency Cycles for an example.