Skip to content

Commit 76fb72b

Browse files
committed
Add PlantSimEngine.output_policy trait
1 parent 0e7a3ea commit 76fb72b

11 files changed

Lines changed: 263 additions & 4 deletions

File tree

docs/make.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ makedocs(;
4242
"Implementing a model : additional notes" => "./step_by_step/implement_a_model_additional.md",
4343
],
4444
"Execution" => "model_execution.md",
45+
"Model traits" => "model_traits.md",
4546
"Working with data" => [
4647
"Reducing DoF" => "./working_with_data/reducing_dof.md",
4748
"Fitting" => "./working_with_data/fitting.md",

docs/src/API/API_public.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ For mapping-level multi-rate configuration, combine:
2424
- `MeteoWindow(...)`
2525
- `OutputRouting(...)`
2626
- `ScopeModel(...)`
27+
- `timespec(::Type{<:AbstractModel})` (optional trait)
28+
- `output_policy(::Type{<:AbstractModel})` (optional trait)
2729
- `timestep_hint(::Type{<:AbstractModel})` (optional trait)
2830
- `meteo_hint(::Type{<:AbstractModel})` (optional trait)
2931
- `resolved_model_specs(mapping)` (utility)
@@ -47,6 +49,8 @@ Trait-based inference detail:
4749
: `required` = hard compatibility constraint, `preferred` = informational only.
4850
- If `InputBindings(...)` is omitted, same-name sources are inferred automatically from
4951
: unique producers (same scale first, then cross-scale). Ambiguous cases require explicit bindings.
52+
- For inferred bindings, policy defaults to producer `output_policy` when defined, otherwise `HoldLast()`.
53+
- Explicit `InputBindings(..., policy=...)` always overrides trait defaults.
5054
- If `MeteoBindings(...)` / `MeteoWindow(...)` are omitted, `meteo_hint(::Type{<:Model})`
5155
: may provide `(; bindings=..., window=...)`.
5256
- Explicit mapping-level configuration always overrides hints.

docs/src/model_execution.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ For multiscale simulations, model usage is configured in the mapping through `Mo
2020
- `OutputRouting(...)`: sets whether an output is canonical (`:canonical`) or stream-only (`:stream_only`).
2121
- `ScopeModel(...)`: partitions producer streams by scope (`:global`, `:plant`, `:scene`, `:self`) for multi-entity simulations.
2222

23+
For a compact overview of all model traits and precedence rules, see [Model traits](model_traits.md).
24+
2325
If users do not provide `MeteoBindings(...)` or `MeteoWindow(...)`,
2426
the runtime can infer defaults from model traits:
27+
- `timespec(::Type{<:MyModel})`
28+
- `output_policy(::Type{<:MyModel})`
2529
- `timestep_hint(::Type{<:MyModel})`
2630
- `meteo_hint(::Type{<:MyModel})`
2731

@@ -34,6 +38,12 @@ If users do not provide `InputBindings(...)`, runtime infers same-name bindings:
3438
- if no producer exists, input stays unresolved (so initialization/forced values can be used);
3539
- if multiple producers are possible, runtime errors and asks for explicit `InputBindings(...)`.
3640

41+
For inferred bindings, default policy is resolved as:
42+
- producer `output_policy` for the source output when defined;
43+
- otherwise `HoldLast()`.
44+
45+
Explicit mapping policies still have priority (`InputBindings(..., policy=...)`).
46+
3747
For timestep hints:
3848
- `timestep_hint.required` is a hard compatibility constraint when runtime uses meteo-derived timestep.
3949
- `timestep_hint.preferred` is informational only (it does not set runtime timestep by itself).

docs/src/model_traits.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Model traits
2+
3+
This page centralizes the model-level traits that can be defined in `PlantSimEngine`.
4+
It complements:
5+
6+
- [Model execution](model_execution.md) for runtime behavior,
7+
- [Parallelization](step_by_step/parallelization.md) for execution over objects/time-steps.
8+
9+
## Trait inventory for models
10+
11+
### `timespec(::Type{<:MyModel})`
12+
13+
Defines the default execution clock of a model.
14+
15+
Default:
16+
17+
```julia
18+
PlantSimEngine.timespec(::Type{<:AbstractModel}) = ClockSpec(1.0, 0.0)
19+
```
20+
21+
Use it when your model has a natural native clock (for example daily by default).
22+
23+
### `output_policy(::Type{<:MyModel})`
24+
25+
Defines per-output default schedule policy for produced streams.
26+
27+
Default:
28+
29+
```julia
30+
PlantSimEngine.output_policy(::Type{<:AbstractModel}) = NamedTuple()
31+
```
32+
33+
Behavior:
34+
35+
- unspecified outputs fall back to `HoldLast()`;
36+
- used by runtime when resolving cross-clock reads;
37+
- used as default policy for inferred `InputBindings(...)` when users do not provide explicit bindings.
38+
39+
Example:
40+
41+
```julia
42+
PlantSimEngine.output_policy(::Type{<:MyModel}) = (
43+
carbon_assimilation=Integrate(),
44+
leaf_temperature=Aggregate(MeanReducer()),
45+
)
46+
```
47+
48+
### `timestep_hint(::Type{<:MyModel})`
49+
50+
Optional compatibility hint when `TimeStepModel(...)` is not provided.
51+
52+
Default:
53+
54+
```julia
55+
PlantSimEngine.timestep_hint(::Type{<:AbstractModel}) = nothing
56+
```
57+
58+
Supported forms include:
59+
60+
- fixed period: `Dates.Hour(1)`;
61+
- range: `(Dates.Minute(30), Dates.Hour(2))`;
62+
- named tuple: `(; required=..., preferred=...)`.
63+
64+
`required` is enforced when runtime uses meteo-derived timestep.
65+
`preferred` is informational only.
66+
67+
### `meteo_hint(::Type{<:MyModel})`
68+
69+
Optional inference trait for weather sampling configuration.
70+
71+
Default:
72+
73+
```julia
74+
PlantSimEngine.meteo_hint(::Type{<:AbstractModel}) = nothing
75+
```
76+
77+
Expected value:
78+
79+
```julia
80+
(; bindings=..., window=...)
81+
```
82+
83+
Where:
84+
85+
- `bindings` is compatible with `MeteoBindings(...)`,
86+
- `window` is compatible with `MeteoWindow(...)`.
87+
88+
### `TimeStepDependencyTrait(::Type{<:MyModel})`
89+
### `ObjectDependencyTrait(::Type{<:MyModel})`
90+
91+
Parallelization traits (single-scale runtime):
92+
93+
- `TimeStepDependencyTrait`: depends or not on other timesteps;
94+
- `ObjectDependencyTrait`: depends or not on other objects.
95+
96+
Defaults are conservative (`dependent`) and can be overridden when safe.
97+
98+
## Precedence rules
99+
100+
Runtime precedence is intentionally explicit:
101+
102+
1. Input policy:
103+
explicit `InputBindings(..., policy=...)` > inferred from producer `output_policy` > `HoldLast()`.
104+
1. Timestep:
105+
`TimeStepModel(...)` > `timespec(model)` when non-default > meteo base step.
106+
1. Meteo sampling:
107+
explicit `MeteoBindings(...)`/`MeteoWindow(...)` > `meteo_hint(...)` > runtime defaults.
108+
109+
## Is everything documented?
110+
111+
For model-level traits, the documented set is now:
112+
113+
- `timespec`,
114+
- `output_policy`,
115+
- `timestep_hint`,
116+
- `meteo_hint`,
117+
- `TimeStepDependencyTrait`,
118+
- `ObjectDependencyTrait`.
119+
120+
Outside model traits, `PlantSimEngine` also exposes data-format traits such as `DataFormat` for input containers (see [Input types](working_with_data/inputs.md)).
121+
122+
## Naming conventions and API consistency
123+
124+
Current API uses two naming styles on purpose:
125+
126+
- snake_case for trait/query functions (`timespec`, `output_policy`, `timestep_hint`, `meteo_hint`);
127+
- CamelCase for `ModelSpec` pipeline transforms (`TimeStepModel`, `InputBindings`, `MeteoBindings`, `MeteoWindow`, `OutputRouting`, `ScopeModel`).
128+
129+
This distinction reflects role:
130+
131+
- snake_case: "what the model declares";
132+
- CamelCase: "what the mapping config applies".
133+
134+
For future unification, a non-breaking path would be:
135+
136+
1. keep existing names as stable API,
137+
1. avoid plain snake_case aliases that would collide with existing getter names
138+
(`input_bindings`, `meteo_bindings`, `output_routing`, `model_scope`),
139+
1. if needed, add explicit config-oriented aliases with distinct names
140+
(for example `*_config` forms) and keep current constructors,
141+
1. evaluate deprecations only after one full release cycle and user feedback.

docs/src/step_by_step/parallelization.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ That means that you can provide any compatible executor to the `executor` argume
1212
### Parallel traits
1313

1414
`PlantSimEngine.jl` uses [Holy traits](https://invenia.github.io/blog/2019/11/06/julialang-features-part-2/) to define if a model can be run in parallel.
15+
See also [Model traits](../model_traits.md) for a full inventory of model-level traits.
1516

1617
!!! note
1718
A model is executable in parallel over time-steps if it does not uses or set values from other time-steps, and over objects if it does not uses or set values from other objects.

src/mtg/model_spec_inference.jl

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,17 @@ function _input_candidates_for_var(
357357
return same_scale, cross_scale
358358
end
359359

360+
function _default_policy_for_inferred_binding(model_specs, source_scale::String, source_process::Symbol, source_var::Symbol)
361+
source_spec = model_specs[source_scale][source_process]
362+
source_model = model_(source_spec)
363+
source_output_policy = output_policy(source_model)
364+
source_var in keys(source_output_policy) || return HoldLast()
365+
return _as_schedule_policy(
366+
source_output_policy[source_var];
367+
context="output_policy for inferred binding from `$(source_scale)/$(source_process).$(source_var)`"
368+
)
369+
end
370+
360371
function _infer_input_binding_for_var(
361372
model_specs,
362373
scale::String,
@@ -376,7 +387,8 @@ function _infer_input_binding_for_var(
376387

377388
if length(same_scale) == 1
378389
c = only(same_scale)
379-
return (process=c.process, var=c.var, policy=HoldLast())
390+
policy = _default_policy_for_inferred_binding(model_specs, c.scale, c.process, c.var)
391+
return (process=c.process, var=c.var, policy=policy)
380392
elseif length(same_scale) > 1
381393
error(
382394
"Ambiguous inferred producer for input `$(input_var)` in process `$(process)` at scale `$(scale)`. ",
@@ -387,7 +399,8 @@ function _infer_input_binding_for_var(
387399

388400
if length(cross_scale) == 1
389401
c = only(cross_scale)
390-
return (process=c.process, var=c.var, scale=c.scale, policy=HoldLast())
402+
policy = _default_policy_for_inferred_binding(model_specs, c.scale, c.process, c.var)
403+
return (process=c.process, var=c.var, scale=c.scale, policy=policy)
391404
elseif length(cross_scale) > 1
392405
by_process = Dict{Symbol,Vector{NamedTuple}}()
393406
for c in cross_scale
@@ -398,7 +411,9 @@ function _infer_input_binding_for_var(
398411
proc = only(keys(by_process))
399412
scales = unique(c.scale for c in by_process[proc])
400413
if length(scales) == 1
401-
return (process=proc, var=input_var, scale=only(scales), policy=HoldLast())
414+
src_scale = only(scales)
415+
policy = _default_policy_for_inferred_binding(model_specs, src_scale, proc, input_var)
416+
return (process=proc, var=input_var, scale=src_scale, policy=policy)
402417
end
403418
# Same process name appears at multiple scales (common in multiscale
404419
# mappings). Keep scale unresolved so runtime resolves through parent links.
@@ -505,6 +520,7 @@ end
505520
506521
Fill missing `ModelSpec` fields from inference:
507522
- auto input bindings from unique same-name producers
523+
(including default policy from producer `output_policy`)
508524
- model-level hint traits (`timestep_hint`, `meteo_hint`)
509525
Explicit `ModelSpec` user values always take precedence over inferred values.
510526
"""

src/processes/models_inputs_outputs.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ timespec(::Type{<:AbstractModel}) = ClockSpec(1.0, 0.0)
5151
5252
Per-output scheduling policy for a model. Default is empty, meaning all outputs
5353
fallback to hold-last behaviour.
54+
55+
When multi-rate input bindings are inferred automatically, this trait also
56+
provides the default cross-clock policy (`HoldLast`, `Integrate`, `Aggregate`,
57+
or `Interpolate`) for each producer output.
5458
"""
5559
output_policy(model::AbstractModel) = output_policy(typeof(model))
5660
output_policy(::Type{<:AbstractModel}) = NamedTuple()

src/time/multirate.jl

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,31 @@ Use the latest available producer value.
6565
"""
6666
struct HoldLast <: SchedulePolicy end
6767

68+
function _as_schedule_policy(policy; context::AbstractString="schedule policy")
69+
if policy isa DataType
70+
policy <: SchedulePolicy || error(
71+
"Unsupported $(context) type `$(policy)`. ",
72+
"Expected a `SchedulePolicy` type or instance."
73+
)
74+
return try
75+
policy()
76+
catch
77+
error(
78+
"Unsupported $(context) type `$(policy)`: ",
79+
"this policy type cannot be instantiated without arguments. ",
80+
"Provide a policy instance instead."
81+
)
82+
end
83+
elseif policy isa SchedulePolicy
84+
return policy
85+
end
86+
87+
error(
88+
"Unsupported $(context) value `$(policy)` of type `$(typeof(policy))`. ",
89+
"Expected a `SchedulePolicy` type or instance."
90+
)
91+
end
92+
6893
const _INTERPOLATE_MODES = (:linear, :hold)
6994

7095
"""

src/time/runtime/input_resolution.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,14 @@ function resolve_inputs_from_temporal_state!(sim::GraphSimulation, node::SoftDep
400400
source_process = nothing
401401
source_var = input_var
402402
policy = HoldLast()
403+
policy_is_explicit = false
403404

404405
if !isnothing(binding) && !isnothing(binding.process)
405406
source_process = binding.process
406407
source_var = isnothing(binding.var) ? input_var : binding.var
407408
source_scale = isnothing(binding.scale) ? _source_scale_for_process(node, source_process) : binding.scale
408409
policy = binding.policy
410+
policy_is_explicit = true
409411
else
410412
candidates = _candidate_producers(node, input_var)
411413
if length(candidates) == 1
@@ -421,6 +423,9 @@ function resolve_inputs_from_temporal_state!(sim::GraphSimulation, node::SoftDep
421423
end
422424
end
423425
source_model_spec = _model_spec_for_process(sim, source_scale, source_process)
426+
if !policy_is_explicit
427+
policy = _policy_for_output(model_(source_model_spec), source_var)
428+
end
424429

425430
if policy isa HoldLast
426431
_resolve_input_holdlast(sim, node, st, consumer_scope, source_model_spec, input_var, source_scale, source_process, source_var, t)

src/time/runtime/publishers.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Return the per-output schedule policy for `var`, defaulting to `HoldLast()`.
55
"""
66
function _policy_for_output(model, var::Symbol)
77
pol = output_policy(model)
8-
var in keys(pol) ? pol[var] : HoldLast()
8+
var in keys(pol) || return HoldLast()
9+
return _as_schedule_policy(pol[var]; context="output_policy for output `$(var)` in model `$(typeof(model))`")
910
end
1011

1112
"""

0 commit comments

Comments
 (0)