Skip to content

Commit cdc5571

Browse files
committed
Implement model trait for hinting mandatory and prefered timesteps (also for meteo)
1 parent 022182d commit cdc5571

8 files changed

Lines changed: 426 additions & 8 deletions

File tree

docs/src/API/API_public.md

Lines changed: 9 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+
- `timestep_hint(::Type{<:AbstractModel})` (optional trait)
28+
- `meteo_hint(::Type{<:AbstractModel})` (optional trait)
2729
- `OutputRequest(...)` in `tracked_outputs` for resampled exports
2830

2931
`TimeStepModel(...)` accepts:
@@ -36,6 +38,13 @@ Period conversion detail:
3638
- Example: `TimeStepModel(Dates.Day(1))` with hourly meteo (`Dates.Hour(1)`) maps to `ClockSpec(24.0, 1.0)`,
3739
so execution times are `t = 1, 25, 49, ...`.
3840

41+
Trait-based inference detail:
42+
- If `TimeStepModel(...)` is omitted, `timestep_hint(::Type{<:Model})` may provide:
43+
: fixed period (`Dates.Day(1)`) or required range (`(Dates.Minute(1), Dates.Hour(4))`).
44+
- If `MeteoBindings(...)` / `MeteoWindow(...)` are omitted, `meteo_hint(::Type{<:Model})`
45+
: may provide `(; bindings=..., window=...)`.
46+
- Explicit mapping-level configuration always overrides hints.
47+
3948
Scope selection detail:
4049
- `ScopeModel(:global)` is the default and shares streams across the whole simulation.
4150
- `ScopeModel(:plant)` isolates streams within each plant subtree.

docs/src/model_execution.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ 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+
If users do not provide `TimeStepModel(...)`, `MeteoBindings(...)`, or `MeteoWindow(...)`,
24+
the runtime can infer defaults from model traits:
25+
- `timestep_hint(::Type{<:MyModel})`
26+
- `meteo_hint(::Type{<:MyModel})`
27+
28+
For timestep hints:
29+
- `Dates.FixedPeriod` sets a fixed inferred timestep, e.g. `Dates.Day(1)`.
30+
- `(min_period, max_period)` sets a required range. For models with only range hints,
31+
runtime computes a consensus (default: finest feasible period in the intersection).
32+
- Explicit `TimeStepModel(...)` always takes precedence.
33+
34+
For meteo hints:
35+
- return `(; bindings=..., window=...)` where `bindings` matches `MeteoBindings(...)`
36+
and `window` matches `MeteoWindow(...)`.
37+
- Explicit `MeteoBindings(...)` / `MeteoWindow(...)` always take precedence.
38+
2339
Policy parameterization:
2440
- `Integrate()` defaults to `SumReducer()`; you can pass another reducer, e.g. `Integrate(MeanReducer())` or `Integrate(vals -> maximum(vals) - minimum(vals))`.
2541
- `Aggregate()` defaults to `MeanReducer()`; you can pass reducers such as `Aggregate(MaxReducer())`.

src/PlantSimEngine.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ include("mtg/mapping/getters.jl")
7676
include("mtg/mapping/mapping.jl")
7777
include("mtg/mapping/compute_mapping.jl")
7878
include("mtg/mapping/reverse_mapping.jl")
79+
include("mtg/model_spec_inference.jl")
7980
include("mtg/model_spec_validation.jl")
8081
include("mtg/initialisation.jl")
8182
include("mtg/save_results.jl")
@@ -131,7 +132,8 @@ export add_organ!
131132
export @process, process
132133
export to_initialize, is_initialized, init_variables, dep
133134
export inputs, outputs, variables, convert_outputs
134-
export timespec, output_policy, input_bindings, meteo_bindings, meteo_window, output_routing, model_scope
135+
export timespec, output_policy, timestep_hint, meteo_hint
136+
export input_bindings, meteo_bindings, meteo_window, output_routing, model_scope
135137
export run!
136138
export fit
137139

src/mtg/initialisation.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion
316316

317317
models = Dict(first(m) => parse_models(get_models(last(m))) for m in mapping)
318318
model_specs = Dict(first(m) => parse_model_specs(last(m)) for m in mapping)
319+
infer_model_specs_configuration!(model_specs)
319320
validate_model_specs_configuration(model_specs)
320321

321322
soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false)

src/mtg/mapping/getters.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ get_model_specs(m) = [as_model_spec(i) for i in m if !isa(i, Status)]
8080
8181
Return a process-indexed dictionary of normalized `ModelSpec`.
8282
"""
83-
parse_model_specs(m) = Dict(process(model_(spec)) => spec for spec in get_model_specs(m))
83+
parse_model_specs(m) = Dict{Symbol,ModelSpec}(process(model_(spec)) => spec for spec in get_model_specs(m))
8484

8585

8686
# Same, for the status (if any provided):

src/mtg/model_spec_inference.jl

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
const _TIMESTEP_HINT_FIELDS = (:required, :preferred)
2+
3+
"""
4+
timestep_hint(model::AbstractModel)
5+
timestep_hint(::Type{<:AbstractModel})
6+
7+
Optional model trait used to infer a timestep when `ModelSpec.timestep` is not provided.
8+
9+
Supported return values:
10+
- `nothing` (default): no hint
11+
- `Dates.FixedPeriod`: fixed required timestep
12+
- `(min_period, max_period)`: required timestep range (`Dates.FixedPeriod` pair)
13+
- `NamedTuple`: with `required` (one of the forms above) and optional `preferred`
14+
(`:finest`, `:coarsest`, or a `Dates.FixedPeriod` within the required range)
15+
"""
16+
timestep_hint(model::AbstractModel) = timestep_hint(typeof(model))
17+
timestep_hint(::Type{<:AbstractModel}) = nothing
18+
19+
"""
20+
meteo_hint(model::AbstractModel)
21+
meteo_hint(::Type{<:AbstractModel})
22+
23+
Optional model trait used to infer weather sampling when `ModelSpec` does not provide
24+
`MeteoBindings(...)` and/or `MeteoWindow(...)`.
25+
26+
Expected return value is a `NamedTuple` with optional fields:
27+
- `bindings`: compatible with `MeteoBindings(...)`
28+
- `window`: compatible with `MeteoWindow(...)`
29+
"""
30+
meteo_hint(model::AbstractModel) = meteo_hint(typeof(model))
31+
meteo_hint(::Type{<:AbstractModel}) = nothing
32+
33+
struct _ResolvedTimeStepHint
34+
fixed::Union{Nothing,Dates.FixedPeriod}
35+
range::Union{Nothing,Tuple{Dates.FixedPeriod,Dates.FixedPeriod}}
36+
preferred::Union{Nothing,Symbol,Dates.FixedPeriod}
37+
end
38+
39+
_seconds_from_period(p::Dates.FixedPeriod) = float(Dates.value(Dates.Millisecond(p))) * 1.0e-3
40+
41+
function _period_from_seconds(seconds::Float64)
42+
ms = round(Int, seconds * 1000.0)
43+
return Dates.Millisecond(ms)
44+
end
45+
46+
function _normalize_required_timestep_hint(scale::String, process::Symbol, required)
47+
if required isa Dates.FixedPeriod
48+
_seconds_from_period(required) > 0.0 || error(
49+
"Invalid `timestep_hint` required period for process `$(process)` at scale `$(scale)`: ",
50+
"period must be > 0, got `$(required)`."
51+
)
52+
return required, nothing
53+
elseif required isa Tuple
54+
length(required) == 2 || error(
55+
"Invalid `timestep_hint` required tuple for process `$(process)` at scale `$(scale)`: ",
56+
"expected `(min_period, max_period)`."
57+
)
58+
minp, maxp = required
59+
minp isa Dates.FixedPeriod || error(
60+
"Invalid `timestep_hint` min period for process `$(process)` at scale `$(scale)`: ",
61+
"expected `Dates.FixedPeriod`, got `$(typeof(minp))`."
62+
)
63+
maxp isa Dates.FixedPeriod || error(
64+
"Invalid `timestep_hint` max period for process `$(process)` at scale `$(scale)`: ",
65+
"expected `Dates.FixedPeriod`, got `$(typeof(maxp))`."
66+
)
67+
min_sec = _seconds_from_period(minp)
68+
max_sec = _seconds_from_period(maxp)
69+
min_sec > 0.0 || error(
70+
"Invalid `timestep_hint` range lower bound for process `$(process)` at scale `$(scale)`: ",
71+
"period must be > 0, got `$(minp)`."
72+
)
73+
max_sec > 0.0 || error(
74+
"Invalid `timestep_hint` range upper bound for process `$(process)` at scale `$(scale)`: ",
75+
"period must be > 0, got `$(maxp)`."
76+
)
77+
min_sec <= max_sec || error(
78+
"Invalid `timestep_hint` range for process `$(process)` at scale `$(scale)`: ",
79+
"lower bound `$(minp)` must be <= upper bound `$(maxp)`."
80+
)
81+
return nothing, (minp, maxp)
82+
end
83+
84+
error(
85+
"Invalid `timestep_hint` required value for process `$(process)` at scale `$(scale)`: ",
86+
"expected `Dates.FixedPeriod` or `(Dates.FixedPeriod, Dates.FixedPeriod)`, got `$(typeof(required))`."
87+
)
88+
end
89+
90+
function _normalize_timestep_hint(scale::String, process::Symbol, hint)
91+
isnothing(hint) && return _ResolvedTimeStepHint(nothing, nothing, nothing)
92+
93+
if hint isa Dates.FixedPeriod || hint isa Tuple
94+
fixed, range = _normalize_required_timestep_hint(scale, process, hint)
95+
return _ResolvedTimeStepHint(fixed, range, nothing)
96+
elseif hint isa NamedTuple
97+
extra = setdiff(collect(keys(hint)), collect(_TIMESTEP_HINT_FIELDS))
98+
isempty(extra) || error(
99+
"Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ",
100+
"unsupported fields $(extra)."
101+
)
102+
haskey(hint, :required) || error(
103+
"Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ",
104+
"field `required` is mandatory when using NamedTuple form."
105+
)
106+
fixed, range = _normalize_required_timestep_hint(scale, process, hint.required)
107+
preferred = haskey(hint, :preferred) ? hint.preferred : nothing
108+
if !isnothing(preferred)
109+
if preferred isa Symbol
110+
preferred in (:finest, :coarsest) || error(
111+
"Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ",
112+
"supported symbols are `:finest` and `:coarsest`."
113+
)
114+
elseif preferred isa Dates.FixedPeriod
115+
_seconds_from_period(preferred) > 0.0 || error(
116+
"Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ",
117+
"period must be > 0, got `$(preferred)`."
118+
)
119+
if !isnothing(range)
120+
lo, hi = range
121+
preferred_sec = _seconds_from_period(preferred)
122+
lo_sec = _seconds_from_period(lo)
123+
hi_sec = _seconds_from_period(hi)
124+
lo_sec <= preferred_sec <= hi_sec || error(
125+
"Invalid `timestep_hint.preferred=$(preferred)` for process `$(process)` at scale `$(scale)`: ",
126+
"preferred period must be inside required range `($(lo), $(hi))`."
127+
)
128+
elseif !isnothing(fixed)
129+
_seconds_from_period(preferred) == _seconds_from_period(fixed) || error(
130+
"Invalid `timestep_hint.preferred=$(preferred)` for process `$(process)` at scale `$(scale)`: ",
131+
"when `required` is fixed (`$(fixed)`), `preferred` must match it."
132+
)
133+
end
134+
else
135+
error(
136+
"Invalid `timestep_hint.preferred` for process `$(process)` at scale `$(scale)`: ",
137+
"expected `:finest`, `:coarsest`, or `Dates.FixedPeriod`, got `$(typeof(preferred))`."
138+
)
139+
end
140+
end
141+
return _ResolvedTimeStepHint(fixed, range, preferred)
142+
end
143+
144+
error(
145+
"Invalid `timestep_hint` for process `$(process)` at scale `$(scale)`: ",
146+
"expected `nothing`, `Dates.FixedPeriod`, `(min,max)` tuple, or NamedTuple, got `$(typeof(hint))`."
147+
)
148+
end
149+
150+
function _resolve_range_consensus(
151+
range_specs::Vector{Tuple{String,Symbol,ModelSpec,_ResolvedTimeStepHint}}
152+
)
153+
isempty(range_specs) && return nothing
154+
155+
lo = maximum(_seconds_from_period(s[4].range[1]) for s in range_specs)
156+
hi = minimum(_seconds_from_period(s[4].range[2]) for s in range_specs)
157+
lo <= hi || error(
158+
"No feasible inferred timestep consensus for models without explicit `TimeStepModel(...)`. ",
159+
"Collected required ranges are incompatible:\n",
160+
join(
161+
[
162+
" - $(scale)/$(process): ($(hint.range[1]), $(hint.range[2]))" for (scale, process, _, hint) in range_specs
163+
],
164+
"\n"
165+
)
166+
)
167+
168+
preferred_periods = Float64[]
169+
finest_votes = 0
170+
coarsest_votes = 0
171+
for (_, _, _, hint) in range_specs
172+
pref = hint.preferred
173+
if pref isa Dates.FixedPeriod
174+
sec = _seconds_from_period(pref)
175+
lo <= sec <= hi && push!(preferred_periods, sec)
176+
elseif pref == :coarsest
177+
coarsest_votes += 1
178+
elseif pref == :finest
179+
finest_votes += 1
180+
end
181+
end
182+
183+
chosen_sec = if !isempty(preferred_periods) && all(isapprox(v, first(preferred_periods); atol=1.0e-6, rtol=0.0) for v in preferred_periods)
184+
first(preferred_periods)
185+
elseif coarsest_votes > finest_votes
186+
hi
187+
else
188+
lo
189+
end
190+
191+
return _period_from_seconds(chosen_sec)
192+
end
193+
194+
function _infer_timestep_hints!(model_specs)
195+
range_specs = Tuple{String,Symbol,ModelSpec,_ResolvedTimeStepHint}[]
196+
197+
for (scale, specs_at_scale) in pairs(model_specs)
198+
for (process, spec) in pairs(specs_at_scale)
199+
!isnothing(timestep(spec)) && continue
200+
201+
hint = _normalize_timestep_hint(scale, process, timestep_hint(model_(spec)))
202+
if !isnothing(hint.fixed)
203+
specs_at_scale[process] = ModelSpec(spec; timestep=hint.fixed)
204+
elseif !isnothing(hint.range)
205+
push!(range_specs, (scale, process, spec, hint))
206+
end
207+
end
208+
end
209+
210+
consensus = _resolve_range_consensus(range_specs)
211+
isnothing(consensus) && return nothing
212+
213+
for (scale, process, spec, _) in range_specs
214+
model_specs[scale][process] = ModelSpec(spec; timestep=consensus)
215+
end
216+
217+
return nothing
218+
end
219+
220+
function _normalize_meteo_hint(scale::String, process::Symbol, hint)
221+
isnothing(hint) && return (bindings=nothing, window=nothing)
222+
223+
hint isa NamedTuple || error(
224+
"Invalid `meteo_hint` for process `$(process)` at scale `$(scale)`: ",
225+
"expected NamedTuple with optional fields `bindings` and `window`, got `$(typeof(hint))`."
226+
)
227+
228+
allowed = (:bindings, :window)
229+
extra = setdiff(collect(keys(hint)), collect(allowed))
230+
isempty(extra) || error(
231+
"Invalid `meteo_hint` for process `$(process)` at scale `$(scale)`: ",
232+
"unsupported fields $(extra)."
233+
)
234+
235+
bindings = haskey(hint, :bindings) ? _normalize_meteo_bindings(hint.bindings) : nothing
236+
window = haskey(hint, :window) ? _normalize_meteo_window(hint.window) : nothing
237+
return (bindings=bindings, window=window)
238+
end
239+
240+
function _infer_meteo_hints!(model_specs)
241+
for (scale, specs_at_scale) in pairs(model_specs)
242+
for (process, spec) in pairs(specs_at_scale)
243+
hint = _normalize_meteo_hint(scale, process, meteo_hint(model_(spec)))
244+
245+
current_bindings = meteo_bindings(spec)
246+
has_explicit_bindings = !(current_bindings isa NamedTuple && isempty(keys(current_bindings)))
247+
new_bindings = has_explicit_bindings ? current_bindings : (isnothing(hint.bindings) ? current_bindings : hint.bindings)
248+
249+
current_window = meteo_window(spec)
250+
new_window = isnothing(current_window) ? (isnothing(hint.window) ? current_window : hint.window) : current_window
251+
252+
if (new_bindings !== current_bindings) || (new_window !== current_window)
253+
specs_at_scale[process] = ModelSpec(spec; meteo_bindings=new_bindings, meteo_window=new_window)
254+
end
255+
end
256+
end
257+
258+
return nothing
259+
end
260+
261+
"""
262+
infer_model_specs_configuration!(model_specs)
263+
264+
Fill missing `ModelSpec` fields from model-level hint traits.
265+
Explicit `ModelSpec` user values always take precedence over inferred values.
266+
"""
267+
function infer_model_specs_configuration!(model_specs)
268+
_infer_timestep_hints!(model_specs)
269+
_infer_meteo_hints!(model_specs)
270+
return model_specs
271+
end

0 commit comments

Comments
 (0)