Skip to content

Commit 7eb1c28

Browse files
committed
Update multirate_tutorial.md
improve the tutorial
1 parent 913f9dd commit 7eb1c28

1 file changed

Lines changed: 134 additions & 27 deletions

File tree

docs/src/multirate/multirate_tutorial.md

Lines changed: 134 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ That is the key point: without `TimeStepModel`, the model still follows the
9494
incoming meteo table. The preferred timestep can be used for validation or for
9595
explanation, but it does not silently reschedule the model.
9696

97-
### Explicit coarse `TimeStepModel` triggers integration/aggregation
97+
### Using `TimeStepModel` to manage multi-rate coupling
9898

9999
The second example shows the complementary case. Here we explicitly ask one model
100100
to run hourly, even though its source data arrives every 30 minutes. Once we do
@@ -110,6 +110,8 @@ reduction policy on the source model itself with `output_policy(...)`. Since `A`
110110
has a unique producer on the same scale, PlantSimEngine can infer the source
111111
automatically and reuse that policy.
112112

113+
Let's define a simple 30-minute source model that produces a constant value `A=1.0` every time it runs, and declare that its output should be integrated when consumed by a slower model:
114+
113115
```@example multirate_timestep_flow
114116
PlantSimEngine.@process "tutorialhalfhoursource" verbose=false
115117
struct TutorialHalfHourSourceModel <: AbstractTutorialhalfhoursourceModel
@@ -122,7 +124,13 @@ function PlantSimEngine.run!(m::TutorialHalfHourSourceModel, models, status, met
122124
status.A = 1.0 # umol m-2 s-1
123125
end
124126
PlantSimEngine.output_policy(::Type{<:TutorialHalfHourSourceModel}) = (; A=Integrate(DurationSumReducer()))
127+
```
128+
129+
Note that `output_policy(...)` says that when a slower model consumes `A`, the default is to integrate it over the coarser time window, using the duration of each source row as weights.
125130

131+
Now we define a simple hourly model that consumes `A` and also reads hourly mean temperature from the meteo:
132+
133+
```@example multirate_timestep_flow
126134
PlantSimEngine.@process "tutorialhourlyintegrator" verbose=false
127135
struct TutorialHourlyIntegratorModel <: AbstractTutorialhourlyintegratorModel end
128136
PlantSimEngine.inputs_(::TutorialHourlyIntegratorModel) = (A=-Inf,)
@@ -131,7 +139,16 @@ function PlantSimEngine.run!(::TutorialHourlyIntegratorModel, models, status, me
131139
status.A_hourly = status.A
132140
status.T_hourly = meteo.T
133141
end
142+
```
143+
144+
!!! note
145+
We make two deliberate simplifications here to keep the example compact:
146+
1. The hourly model simply copies the integrated `A` value into a new variable called `A_hourly`. This is bad design in a real model because it creates unnecessary variables and makes the data flow less transparent. In a real model, you would typically consume `A` directly and let the integrated value be called `A` as well. However, here we create a separate variable to make it obvious that the hourly model is receiving an aggregated version of the original `A`.
147+
2. We don't define an `output_policy(...)` for the hourly model, because it is not consumed by any slower model. Usually, developers are encouraged to define `output_policy(...)` for all models, but here we omit it for the hourly model to keep the example compact.
148+
149+
Now we can declare a mapping that says the hourly model runs every hour, even though its source data arrives every 30 minutes. We also declare how to reduce the meteorological inputs to match the hourly cadence:
134150

151+
```@example multirate_timestep_flow
135152
mapping_coarse = ModelMapping(
136153
:Leaf => (
137154
ModelSpec(TutorialHalfHourSourceModel(Ref(0))),
@@ -140,7 +157,21 @@ mapping_coarse = ModelMapping(
140157
MeteoBindings(; T=MeanWeighted()),
141158
),
142159
)
160+
```
161+
162+
Setting the `TimeStepModel(Hour(1))` forces the second model to run hourly. Since it consumes `A` from the first model, PlantSimEngine looks at the source model's `output_policy(...)` and sees that it should integrate `A` over the hour using the duration of each 30-minute row as weights.
163+
164+
!!! note
165+
If we had omitted `TimeStepModel(Hour(1))`, the hourly model would have simply run on each 30-minute row, and the `output_policy(...)` on the source model would not have been triggered. The hourly model would have received the original 30-minute `A` values instead of an hourly aggregate. This illustrates the key point: `TimeStepModel(...)` is what triggers the multi-rate coupling and the use of reduction policies.
166+
167+
In our example, the hourly model does not declare a `timestep_hint`, so it can run at any cadence. By declaring `TimeStepModel(Hour(1))`, we explicitly force it to run hourly, which means it will receive aggregated inputs and meteo.
168+
169+
!!! note
170+
Because our hourly model does not declare a `timestep_hint`, it is flexible and can run at any cadence. However, if we had declared a `timestep_hint` that did not include hourly as an acceptable cadence, then PlantSimEngine would have raised an error when we tried to force it to run hourly. Consequently, it is usually a good practice to declare a `timestep_hint` when writing a model, because it helps to ensure that the model is used in a way that is consistent with its design and intended use.
171+
172+
Let's now run the simulation:
143173

174+
```@example multirate_timestep_flow
144175
meteo_30min_4 = Weather([
145176
Atmosphere(date=DateTime(2025, 6, 12, 12, 0, 0), duration=Minute(30), T=20.0, Wind=1.0, Rh=0.6),
146177
Atmosphere(date=DateTime(2025, 6, 12, 12, 30, 0), duration=Minute(30), T=22.0, Wind=1.0, Rh=0.6),
@@ -155,11 +186,10 @@ out_coarse = run!(
155186
executor=SequentialEx(),
156187
tracked_outputs=Dict(:Leaf => (:A_hourly, :T_hourly)),
157188
)
158-
out_coarse_df = PlantSimEngine.convert_outputs(out_coarse, DataFrame)
159-
(out_coarse_df[:Leaf][end, :A_hourly], out_coarse_df[:Leaf][end, :T_hourly])
189+
out_coarse_df[:Leaf][end]
160190
```
161191

162-
The final tuple is `(3600.0, 23.0)`: hourly integrated assimilation
192+
The final timestep outputs are `3600.0` for `A_hourly` and `23.0` for `T_hourly`: hourly integrated assimilation
163193
(`sum(A .* duration_seconds)` over two 30-minute rows) and hourly mean temperature over the coarse window.
164194

165195
So this example already captures the core multi-rate idea: the fast model still
@@ -177,6 +207,9 @@ We also reuse package example assets instead of inventing new input files.
177207
We reuse one package example asset:
178208
- `examples/meteo_day.csv` for weather.
179209

210+
We start by importing the packages we need and by creating a very small MTG with
211+
only four nodes: a `Scene`, a `Plant`, one `Internode`, and one `Leaf`.
212+
180213
```@example multirate_tutorial
181214
using PlantSimEngine
182215
using PlantMeteo
@@ -190,9 +223,12 @@ mtg = Node(NodeMTG("/", :Scene, 1, 0))
190223
plant = Node(mtg, NodeMTG("+", :Plant, 1, 1))
191224
internode = Node(plant, NodeMTG("/", :Internode, 1, 2))
192225
Node(internode, NodeMTG("+", :Leaf, 1, 2))
226+
```
193227

194-
meteo_path = joinpath(pkgdir(PlantSimEngine), "examples", "meteo_day.csv")
228+
Next, we point to the bundled weather file and confirm that it exists:
195229

230+
```@example multirate_tutorial
231+
meteo_path = joinpath(pkgdir(PlantSimEngine), "examples", "meteo_day.csv")
196232
@assert isfile(meteo_path)
197233
```
198234

@@ -202,12 +238,16 @@ hourly weather table. The values are simply repeated within each day, which is
202238
perfectly fine here because the purpose is to illustrate scheduling and data flow
203239
rather than to create a realistic forcing dataset.
204240

205-
`meteo_day.csv` is daily. We convert one week to an hourly weather table:
241+
The first step is to read the file and keep only one week of rows:
206242

207243
```@example multirate_tutorial
208244
daily_df = CSV.read(meteo_path, DataFrame, header=18)
209245
week_df = first(daily_df, 7)
246+
```
210247

248+
We then expand each day into 24 hourly `Atmosphere` rows:
249+
250+
```@example multirate_tutorial
211251
hourly_rows = Atmosphere[]
212252
for row in eachrow(week_df)
213253
for h in 0:23
@@ -225,7 +265,11 @@ for row in eachrow(week_df)
225265
)
226266
end
227267
end
268+
```
269+
270+
Finally, we wrap those rows into a `Weather` object, which is what `run!` expects:
228271

272+
```@example multirate_tutorial
229273
meteo_hourly = Weather(hourly_rows)
230274
meteo_hourly[1:3] # show the first 3 rows of the hourly weather table
231275
```
@@ -244,6 +288,9 @@ Next we define three deliberately simple models:
244288
These models are intentionally minimal. Their role is to make the rate changes
245289
and aggregation policies obvious.
246290

291+
We begin with the hourly leaf model. It reads hourly meteorological radiation and
292+
produces an hourly assimilation value:
293+
247294
```@example multirate_tutorial
248295
PlantSimEngine.@process "tutorialleafhourly" verbose=false
249296
struct TutorialLeafHourlyModel <: AbstractTutorialleafhourlyModel end
@@ -253,7 +300,16 @@ function PlantSimEngine.run!(::TutorialLeafHourlyModel, models, status, meteo, c
253300
status.leaf_assim_h = 0.004 * meteo.Ri_PAR_f
254301
end
255302
PlantSimEngine.output_policy(::Type{<:TutorialLeafHourlyModel}) = (; leaf_assim_h=Integrate())
303+
```
304+
305+
The `output_policy(...)` declaration matters for multi-rate use: it says that
306+
when a slower model consumes `leaf_assim_h`, the natural default is to integrate
307+
it over the coarser time window.
256308

309+
Now we define the daily plant model. It receives leaf assimilation values,
310+
aggregates them over a day, and also reads daily reduced meteo variables:
311+
312+
```@example multirate_tutorial
257313
PlantSimEngine.@process "tutorialplantdaily" verbose=false
258314
struct TutorialPlantDailyModel <: AbstractTutorialplantdailyModel end
259315
PlantSimEngine.inputs_(::TutorialPlantDailyModel) = (leaf_assim_h=[0.0],)
@@ -264,7 +320,15 @@ function PlantSimEngine.run!(::TutorialPlantDailyModel, models, status, meteo, c
264320
status.T = meteo.T
265321
end
266322
PlantSimEngine.output_policy(::Type{<:TutorialPlantDailyModel}) = (; plant_assim_d=Integrate())
323+
```
324+
325+
Again, `output_policy(...)` is used so that a coarser consumer can infer the
326+
appropriate default behavior for `plant_assim_d`.
327+
328+
Finally, we define the weekly plant model. It simply sums the daily plant
329+
assimilation values over one week:
267330

331+
```@example multirate_tutorial
268332
PlantSimEngine.@process "tutorialplantweekly" verbose=false
269333
struct TutorialPlantWeeklyModel <: AbstractTutorialplantweeklyModel end
270334
PlantSimEngine.inputs_(::TutorialPlantWeeklyModel) = (plant_assim_d=[0.0],)
@@ -296,32 +360,57 @@ For model-to-model bindings, this tutorial relies on automatic source inference
296360
plus `output_policy(...)` on the source models. That keeps the main example
297361
compact while still exercising multi-rate input aggregation.
298362

363+
We start by defining the three clocks used in the simulation:
364+
299365
```@example multirate_tutorial
300366
hourly = 1.0
301367
daily = ClockSpec(24.0, 0.0)
302368
weekly = ClockSpec(168.0, 0.0)
369+
```
370+
371+
The leaf model is straightforward: it runs hourly and is scoped to the current
372+
plant:
373+
374+
```@example multirate_tutorial
375+
leaf_spec = ModelSpec(TutorialLeafHourlyModel()) |>
376+
TimeStepModel(hourly) |>
377+
ScopeModel(:plant)
378+
```
379+
380+
The daily plant model is where multi-rate coupling becomes visible. It:
381+
382+
- receives `leaf_assim_h` from the `:Leaf` scale through `MultiScaleModel(...)`;
383+
- runs daily;
384+
- receives reduced meteorological variables through `MeteoBindings(...)`.
385+
386+
```@example multirate_tutorial
387+
plant_daily_spec = ModelSpec(TutorialPlantDailyModel()) |>
388+
ScopeModel(:plant) |>
389+
MultiScaleModel([:leaf_assim_h => :Leaf]) |>
390+
TimeStepModel(daily) |>
391+
MeteoBindings(
392+
;
393+
T=MeanWeighted(),
394+
Rh=MeanWeighted(),
395+
Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()),
396+
)
397+
```
398+
399+
The weekly plant model is simpler again: it only needs to run weekly and receive
400+
the daily plant output automatically:
401+
402+
```@example multirate_tutorial
403+
plant_weekly_spec = ModelSpec(TutorialPlantWeeklyModel()) |>
404+
ScopeModel(:plant) |>
405+
TimeStepModel(weekly)
406+
```
407+
408+
We can now assemble the full mapping:
303409

410+
```@example multirate_tutorial
304411
mapping = ModelMapping(
305-
:Leaf => (
306-
ModelSpec(TutorialLeafHourlyModel()) |>
307-
TimeStepModel(hourly) |>
308-
ScopeModel(:plant),
309-
),
310-
:Plant => (
311-
ModelSpec(TutorialPlantDailyModel()) |>
312-
ScopeModel(:plant) |>
313-
MultiScaleModel([:leaf_assim_h => :Leaf]) |>
314-
TimeStepModel(daily) |>
315-
MeteoBindings(
316-
;
317-
T=MeanWeighted(),
318-
Rh=MeanWeighted(),
319-
Ri_SW_q=(source=:Ri_SW_f, reducer=RadiationEnergy()),
320-
),
321-
ModelSpec(TutorialPlantWeeklyModel()) |>
322-
ScopeModel(:plant) |>
323-
TimeStepModel(weekly),
324-
),
412+
:Leaf => (leaf_spec,),
413+
:Plant => (plant_daily_spec, plant_weekly_spec),
325414
)
326415
```
327416

@@ -365,6 +454,10 @@ We use `OutputRequest(...)` to say which variable we want and on which clock.
365454
Here again we keep the example minimal: `process=` is omitted because each
366455
requested output has a unique canonical publisher.
367456

457+
We first declare the export requests. One request keeps the hourly leaf series,
458+
another exports the daily plant series, and the last one exports the weekly plant
459+
series:
460+
368461
```@example multirate_tutorial
369462
req_leaf_hourly = OutputRequest(:Leaf, :leaf_assim_h;
370463
name=:leaf_assim_hourly,
@@ -379,7 +472,12 @@ req_plant_weekly = OutputRequest(:Plant, :plant_assim_w;
379472
name=:plant_assim_weekly,
380473
clock=weekly,
381474
)
475+
```
382476

477+
Then we run the simulation and ask PlantSimEngine to return both the regular
478+
simulation outputs and the explicitly requested exported series:
479+
480+
```@example multirate_tutorial
383481
out_status, exported = run!(
384482
mtg,
385483
mapping,
@@ -388,7 +486,11 @@ out_status, exported = run!(
388486
tracked_outputs=[req_leaf_hourly, req_plant_daily, req_plant_weekly],
389487
return_requested_outputs=true,
390488
)
489+
```
490+
491+
Finally, we extract the exported tables we want to inspect:
391492

493+
```@example multirate_tutorial
392494
leaf_hourly_df = exported[:leaf_assim_hourly]
393495
plant_daily_df = exported[:plant_assim_daily]
394496
plant_weekly_df = exported[:plant_assim_weekly]
@@ -397,13 +499,18 @@ plant_weekly_df = exported[:plant_assim_weekly]
397499
The exported tables already have the cadence we asked for, so they are much
398500
easier to inspect than a single mixed output table.
399501

400-
Quick checks:
502+
We can start with a few basic checks on the number of rows:
401503

402504
```@example multirate_tutorial
403505
@show nrow(leaf_hourly_df) # 168 (1 leaf x 168 hours)
404506
@show nrow(plant_daily_df) # 7 (1 plant x 7 days)
405507
@show nrow(plant_weekly_df) # 1 (1 plant x 1 week)
508+
```
406509

510+
To compare the hourly and daily outputs directly, we group the hourly series by
511+
day and sum it manually:
512+
513+
```@example multirate_tutorial
407514
leaf_hourly_df.day = repeat(1:7, inner=24)
408515
leaf_hourly_sum = combine(groupby(leaf_hourly_df, :day), :value => sum => :leaf_assim_h_sum)
409516
```

0 commit comments

Comments
 (0)