From c13ef5b04bbb922495c5c81550d09bf01ae0713d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Sun, 15 Feb 2026 16:41:55 +0100 Subject: [PATCH 1/3] remove multirate=true argument from `run!` and infer it in ModelMapping and update tests + doc --- docs/src/API/API_public.md | 3 +- docs/src/model_execution.md | 5 +- docs/src/multirate/multirate_tutorial.md | 1 - docs/src/planned_features.md | 2 +- src/mtg/GraphSimulation.jl | 2 + src/mtg/initialisation.jl | 22 +- src/mtg/mapping/mapping.jl | 285 +++++++++++++++++++++-- src/run.jl | 65 ++---- src/time/runtime/output_export.jl | 2 +- test/test-mapping.jl | 17 ++ test/test-mtg-dynamic.jl | 2 +- test/test-multirate-output-export.jl | 6 - test/test-multirate-runtime.jl | 70 +++--- test/test-simulation.jl | 6 +- 14 files changed, 375 insertions(+), 113 deletions(-) diff --git a/docs/src/API/API_public.md b/docs/src/API/API_public.md index ed56491ff..f86672e6e 100644 --- a/docs/src/API/API_public.md +++ b/docs/src/API/API_public.md @@ -60,14 +60,13 @@ Scope selection detail: ```julia req_hold = OutputRequest("Leaf", :A; name=:A_hourly, process=:assim, policy=HoldLast()) req_day = OutputRequest("Leaf", :A; name=:A_daily_sum, process=:assim, policy=Integrate(), clock=ClockSpec(24.0, 1.0)) -run!(sim, meteo; multirate=true, tracked_outputs=[req_hold, req_day], executor=SequentialEx()) +run!(sim, meteo; tracked_outputs=[req_hold, req_day], executor=SequentialEx()) out = collect_outputs(sim; sink=DataFrame) # or directly: out_status, out = run!( sim, meteo; - multirate=true, tracked_outputs=[req_hold, req_day], return_requested_outputs=true, ) diff --git a/docs/src/model_execution.md b/docs/src/model_execution.md index 1cfcd70f5..dfca4b9ab 100644 --- a/docs/src/model_execution.md +++ b/docs/src/model_execution.md @@ -131,7 +131,7 @@ mapping = ModelMapping( ) ``` -When `multirate=true` is passed to `run!`, the runtime resolves inputs from producer temporal streams according to these policies. +When the `ModelMapping` declares multirate configuration, the runtime resolves inputs from producer temporal streams according to these policies. Meteo rows are also sampled at each model clock. By default, meteo variables are aggregated from the finest weather step (for example `T` and `Rh` as weighted means, `Tmin/Tmax`, and radiation quantity aliases such as `Ri_SW_q` in MJ m-2). You can override these rules with `MeteoBindings(...)` @@ -154,7 +154,7 @@ req = OutputRequest("Leaf", :carbon_assimilation; clock=ClockSpec(24.0, 1.0) ) -run!(sim, meteo; multirate=true, tracked_outputs=[req], executor=SequentialEx()) +run!(sim, meteo; tracked_outputs=[req], executor=SequentialEx()) exported = collect_outputs(sim; sink=DataFrame) ``` @@ -165,7 +165,6 @@ You can also return them directly from `run!`: out_status, exported = run!( sim, meteo; - multirate=true, tracked_outputs=[req], return_requested_outputs=true, ) diff --git a/docs/src/multirate/multirate_tutorial.md b/docs/src/multirate/multirate_tutorial.md index 8f28c7bb8..32f368eb4 100644 --- a/docs/src/multirate/multirate_tutorial.md +++ b/docs/src/multirate/multirate_tutorial.md @@ -173,7 +173,6 @@ req_plant_weekly = OutputRequest("Plant", :plant_assim_w; out_status, exported = run!( sim, meteo_hourly; - multirate=true, executor=SequentialEx(), tracked_outputs=[req_leaf_hourly, req_plant_daily, req_plant_daily_T, req_plant_weekly], return_requested_outputs=true, diff --git a/docs/src/planned_features.md b/docs/src/planned_features.md index 382ed8dd1..ca2a4bf52 100644 --- a/docs/src/planned_features.md +++ b/docs/src/planned_features.md @@ -5,7 +5,7 @@ ### Varying timesteps Model-level varying timesteps are now available experimentally for MTG simulations -through multi-rate execution (`multirate=true`) and mapping-level `ModelSpec` transforms +through mapping-declared multi-rate execution and mapping-level `ModelSpec` transforms such as `TimeStepModel`, `InputBindings`, `OutputRouting`, and `ScopeModel`. Current remaining gaps for this area are: diff --git a/src/mtg/GraphSimulation.jl b/src/mtg/GraphSimulation.jl index 9297f276c..40190ecfa 100644 --- a/src/mtg/GraphSimulation.jl +++ b/src/mtg/GraphSimulation.jl @@ -31,6 +31,7 @@ struct GraphSimulation{T,S,U,O,V,TS,MS} outputs::Dict{String,O} outputs_index::Dict{String, Int} temporal_state::TS + is_multirate::Bool end function GraphSimulation(graph, mapping; nsteps=1, outputs=nothing, type_promotion=nothing, check=true, verbose=false) @@ -47,6 +48,7 @@ get_models(g::GraphSimulation) = g.models get_model_specs(g::GraphSimulation) = g.model_specs outputs(g::GraphSimulation) = g.outputs temporal_state(g::GraphSimulation) = g.temporal_state +is_multirate(g::GraphSimulation) = g.is_multirate """ convert_outputs(sim_outputs::Dict{String,O} where O, sink; refvectors=false, no_value=nothing) diff --git a/src/mtg/initialisation.jl b/src/mtg/initialisation.jl index e9661bb8d..4352ac5a3 100644 --- a/src/mtg/initialisation.jl +++ b/src/mtg/initialisation.jl @@ -315,7 +315,11 @@ function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion end models = Dict(first(m) => parse_models(get_models(last(m))) for m in mapping) - model_specs = Dict(first(m) => parse_model_specs(last(m)) for m in mapping) + model_specs = if mapping isa ModelMapping && !isempty(mapping.info.model_specs) + deepcopy(mapping.info.model_specs) + else + Dict(first(m) => parse_model_specs(last(m)) for m in mapping) + end scale_reachability = _scale_reachability_from_mtg(mtg) infer_model_specs_configuration!(model_specs; scale_reachability=scale_reachability) validate_model_specs_configuration(model_specs) @@ -356,5 +360,19 @@ function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion outputs_index = Dict{String, Int}(s => 1 for s in keys(outputs)) temporal_state = TemporalState() - return (; mtg, statuses, status_templates, reverse_multiscale_mapping, vars_need_init, dependency_graph=dep_graph, models, model_specs, outputs, outputs_index, temporal_state) + mapping_is_multirate = mapping isa ModelMapping ? is_multirate(mapping) : false + return (; + mtg, + statuses, + status_templates, + reverse_multiscale_mapping, + vars_need_init, + dependency_graph=dep_graph, + models, + model_specs, + outputs, + outputs_index, + temporal_state, + is_multirate=mapping_is_multirate + ) end diff --git a/src/mtg/mapping/mapping.jl b/src/mtg/mapping/mapping.jl index 536b8cc3a..c91f2a9cf 100755 --- a/src/mtg/mapping/mapping.jl +++ b/src/mtg/mapping/mapping.jl @@ -95,6 +95,40 @@ abstract type AbstractScaleSetup end struct MultiScale <: AbstractScaleSetup end struct SingleScale <: AbstractScaleSetup end +""" + ModelMappingInfo + +Cached metadata computed at `ModelMapping` construction time to avoid repeated +normalization/introspection work across runtime entrypoints. +""" +struct ModelMappingInfo + validated::Bool + is_valid::Bool + is_multirate::Bool + scales::Vector{String} + models_per_scale::Dict{String,Int} + processes_per_scale::Dict{String,Vector{Symbol}} + declared_rates::Dict{String,Any} + vars_need_init::Any + model_specs::Dict{String,Dict{Symbol,ModelSpec}} + recommendations::Vector{String} +end + +function _empty_model_mapping_info() + ModelMappingInfo( + false, + false, + false, + String[], + Dict{String,Int}(), + Dict{String,Vector{Symbol}}(), + Dict{String,Any}(), + NamedTuple(), + Dict{String,Dict{Symbol,ModelSpec}}(), + String[], + ) +end + """ ModelMapping(mapping; check=true) @@ -119,9 +153,19 @@ Use `Dict(mapping)` to recover a plain dictionary. """ struct ModelMapping{S<:AbstractScaleSetup,D} <: AbstractDict{String,Tuple} where {D<:Union{Dict{String,Tuple},ModelList}} data::D + info::ModelMappingInfo end -ModelMapping{S}(data) where {S<:AbstractScaleSetup} = ModelMapping{S,typeof(data)}(data) +function _build_model_mapping(::Type{S}, data; validated::Bool) where {S<:AbstractScaleSetup} + info = try + _build_model_mapping_info(S, data; validated=validated) + catch + _empty_model_mapping_info() + end + ModelMapping{S,typeof(data)}(data, info) +end + +ModelMapping{S}(data) where {S<:AbstractScaleSetup} = _build_model_mapping(S, data; validated=false) """ model_rate(model::AbstractModel) @@ -139,17 +183,67 @@ Base.length(mapping::ModelMapping{MultiScale}) = length(mapping.data) Base.length(::ModelMapping{SingleScale}) = 1 Base.iterate(mapping::ModelMapping{MultiScale}, state...) = iterate(mapping.data, state...) # Base.iterate(mapping::ModelMapping{SingleScale}, state...) = iterate(mapping.data.models, state...) -Base.show(io::IO, mapping::ModelMapping) = print(io, "ModelMapping with scales: ", join(keys(mapping), ", ")) -# Base.show(io::IO, mapping::ModelMapping{SingleScale}) = print(io, "Single Scale ModelMapping:\n", mapping.data.models) -Base.show(io::IO, mapping::ModelMapping{SingleScale}) = print(io, "Single Scale ModelMapping") +function Base.show(io::IO, mapping::ModelMapping) + print( + io, + "ModelMapping(", + length(mapping.info.scales), + " scale", + length(mapping.info.scales) == 1 ? "" : "s", + ", multirate=", + mapping.info.is_multirate, + ")" + ) +end -function Base.show(io::IO, m::MIME"text/plain", t::ModelMapping{SingleScale}) - print(io, "Single Scale ModelMapping:\n") - show(io, m, t.data) +function _isempty_vars_need_init(vars_need_init) + vars_need_init isa NamedTuple && return isempty(keys(vars_need_init)) + vars_need_init isa AbstractDict && return all(isempty, values(vars_need_init)) + vars_need_init isa AbstractVector && return isempty(vars_need_init) + return isnothing(vars_need_init) +end + +function _show_model_mapping_plain(io::IO, mapping::ModelMapping) + info = mapping.info + println(io, "ModelMapping") + println(io, " validated: ", info.validated) + println(io, " is_valid: ", info.is_valid) + println(io, " multirate: ", info.is_multirate) + println(io, " scales (", length(info.scales), "): ", join(info.scales, ", ")) + for scale in info.scales + print(io, " - ", scale, ": ", get(info.models_per_scale, scale, 0), " model(s)") + processes = get(info.processes_per_scale, scale, Symbol[]) + if !isempty(processes) + print(io, ", processes=", join(string.(processes), ", ")) + end + rate = get(info.declared_rates, scale, nothing) + if !isnothing(rate) + print(io, ", rate=", rate) + end + println(io) + end + if _isempty_vars_need_init(info.vars_need_init) + println(io, " variables to initialize: none") + else + println(io, " variables to initialize: ", info.vars_need_init) + end + if !isempty(info.recommendations) + println(io, " recommendations:") + for recommendation in info.recommendations + println(io, " - ", recommendation) + end + end end -function Base.show(io::IO, m::MIME"text/plain", t::ModelMapping) - print(io, "ModelMapping with scales: ", join(keys(t), ", ")) +function Base.show(io::IO, m::MIME"text/plain", mapping::ModelMapping) + if get(io, :compact, false) + return show(io, mapping) + end + _show_model_mapping_plain(io, mapping) + if mapping isa ModelMapping{SingleScale} + println(io, " status:") + show(io, m, mapping.data) + end end Base.keys(mapping::ModelMapping) = keys(mapping.data) @@ -170,21 +264,21 @@ Base.getindex(mapping::ModelMapping{SingleScale}, key::Integer) = getindex(mappi Base.haskey(mapping::ModelMapping, key::String) = haskey(mapping.data, key) Base.haskey(mapping::ModelMapping, key::AbstractString) = haskey(mapping.data, String(key)) Base.eltype(::Type{ModelMapping}) = Pair{String,Tuple} -Base.copy(mapping::ModelMapping{MultiScale}) = ModelMapping(copy(mapping.data); check=false) -Base.copy(mapping::ModelMapping{SingleScale}) = ModelMapping{SingleScale,ModelList}(copy(mapping.data)) -Base.copy(mapping::ModelMapping{SingleScale}, status) = ModelMapping{SingleScale,ModelList}(copy(mapping.data, status)) +Base.copy(mapping::ModelMapping{MultiScale}) = _build_model_mapping(MultiScale, copy(mapping.data); validated=mapping.info.validated) +Base.copy(mapping::ModelMapping{SingleScale}) = _build_model_mapping(SingleScale, copy(mapping.data); validated=mapping.info.validated) +Base.copy(mapping::ModelMapping{SingleScale}, status) = _build_model_mapping(SingleScale, copy(mapping.data, status); validated=mapping.info.validated) Base.Dict(mapping::ModelMapping) = copy(mapping.data) Base.:(==)(left::ModelMapping{SingleScale}, right::ModelMapping{SingleScale}) = left.data == right.data function Base.getproperty(mapping::ModelMapping{SingleScale}, name::Symbol) - name === :data && return getfield(mapping, :data) + (name === :data || name === :info) && return getfield(mapping, name) return getproperty(getfield(mapping, :data), name) end function ModelMapping{MultiScale}(mapping::T; check::Bool=true) where {T<:AbstractDict} normalized = _normalize_multiscale_mapping(mapping) check && _check_multiscale_mapping!(normalized) - ModelMapping{MultiScale,Dict{String,Tuple}}(normalized) + _build_model_mapping(MultiScale, normalized; validated=check) end ModelMapping(mapping::AbstractDict; check::Bool=true) = ModelMapping{MultiScale}(mapping; check=check) @@ -247,7 +341,11 @@ function ModelMapping( end end - return ModelMapping{SingleScale,ModelList}(ModelList(flat_args...; status=status, type_promotion=nothing, variables_check=check, processes...)) + return _build_model_mapping( + SingleScale, + ModelList(flat_args...; status=status, type_promotion=nothing, variables_check=check, processes...); + validated=check + ) #TODO: Use the following when we merge the ModelList and ModelMapping paths (create a fake scale): single_scale_models = _single_scale_mapping_entries(args, processes, status) @@ -262,14 +360,21 @@ hard_dependencies(mapping::ModelMapping{MultiScale}; verbose::Bool=true) = hard_ inputs(mapping::ModelMapping) = inputs(mapping.data) outputs(mapping::ModelMapping) = outputs(mapping.data) variables(mapping::ModelMapping) = variables(mapping.data) -to_initialize(mapping::ModelMapping, graph=nothing) = to_initialize(mapping.data, graph) +function to_initialize(mapping::ModelMapping, graph=nothing) + isnothing(graph) && return mapping.info.vars_need_init + return to_initialize(mapping.data, graph) +end reverse_mapping(mapping::ModelMapping; all=true) = reverse_mapping(mapping.data; all=all) init_variables(mapping::ModelMapping{SingleScale}; verbose=true) = init_variables(mapping.data; verbose=verbose) -to_initialize(mapping::ModelMapping{SingleScale}) = to_initialize(mapping.data) +to_initialize(mapping::ModelMapping{SingleScale}) = mapping.info.vars_need_init to_initialize(mapping::ModelMapping{SingleScale}, graph) = to_initialize(mapping) pre_allocate_outputs(mapping::ModelMapping{SingleScale}, outs, nsteps; type_promotion=nothing, check=true) = pre_allocate_outputs(mapping.data, outs, nsteps; type_promotion=type_promotion, check=check) +mapping_info(mapping::ModelMapping) = mapping.info +is_multirate(mapping::ModelMapping) = mapping.info.is_multirate +is_valid(mapping::ModelMapping) = mapping.info.is_valid + function _all_scale_pairs(args) !isempty(args) && all(arg -> arg isa Pair && first(arg) isa Union{AbstractString,Symbol}, args) end @@ -415,8 +520,10 @@ function _check_mapped_sources_exist!(mapping::Dict{String,Tuple}) if checks_source_value && source_variable ∉ available_variables[source_scale] error( "Scale `$target_scale` maps variable `$(first(mapped_var))` to `$source_scale.$source_variable`, ", - "but `$source_variable` is not available at scale `$source_scale` (neither model output nor Status variable). ", - "Define a model output for `$source_variable`, initialize it in the source scale Status, or update the mapping." + "but `$source_variable` is not available at scale `$source_scale` ", + "(neither model output, Status variable, nor mapped output from another scale). ", + "Define a model output for `$source_variable`, initialize it in the source scale Status, ", + "or map this variable from another scale into `$source_scale` before using it." ) end @@ -455,6 +562,27 @@ function _available_variables_by_scale(mapping::Dict{String,Tuple}) !isnothing(st) && union!(vars, keys(st)) available[scale] = vars end + + # Variables produced at one scale and explicitly mapped as outputs to another + # scale are available at the target scale as runtime references. + for (source_scale, scale_mapping) in mapping + for item in scale_mapping + mapped_vars = _mapping_item_mapped_variables(item) + isempty(mapped_vars) && continue + base_model = _mapping_item_model(item) + model_outputs = Set(keys(outputs_(base_model))) + for mapped_var in mapped_vars + mapped_variable_name = first(mapped_var) isa PreviousTimeStep ? first(mapped_var).variable : first(mapped_var) + mapped_variable_name in model_outputs || continue + for (target_scale_raw, target_variable) in _as_mapping_sources(last(mapped_var)) + target_scale = isempty(target_scale_raw) ? source_scale : target_scale_raw + haskey(available, target_scale) || continue + push!(available[target_scale], target_variable) + end + end + end + end + return available end @@ -474,3 +602,122 @@ function _declared_model_rates_by_scale(mapping::Dict{String,Tuple}) end _rates_compatible(rate1, rate2) = isnothing(rate1) || isnothing(rate2) || rate1 == rate2 + +function _spec_declares_multirate(spec::ModelSpec) + model = model_(spec) + !isnothing(timestep(spec)) && return true + !isempty(keys(input_bindings(spec))) && return true + !isempty(keys(meteo_bindings(spec))) && return true + !isnothing(meteo_window(spec)) && return true + !isempty(keys(output_routing(spec))) && return true + timespec(model) != ClockSpec(1.0, 0.0) && return true + !isempty(keys(output_policy(model))) && return true + !isnothing(timestep_hint(model)) && return true + !isnothing(meteo_hint(model)) && return true + return false +end + +function _mapping_declares_multirate(model_specs::Dict{String,Dict{Symbol,ModelSpec}}, declared_rates::Dict{String,Any}) + any(!isnothing, values(declared_rates)) && return true + for specs_at_scale in values(model_specs), spec in values(specs_at_scale) + _spec_declares_multirate(spec) && return true + end + return false +end + +function _model_summary_from_mapping(mapping::Dict{String,Tuple}) + models_per_scale = Dict{String,Int}() + processes_per_scale = Dict{String,Vector{Symbol}}() + for (scale, scale_mapping) in mapping + models = get_models(scale_mapping) + models_per_scale[scale] = length(models) + processes_per_scale[scale] = [_process_name_for_mapping_check(model) for model in models] + end + return models_per_scale, processes_per_scale +end + +function _parse_model_specs_from_mapping(mapping::Dict{String,Tuple}) + Dict(scale => parse_model_specs(scale_mapping) for (scale, scale_mapping) in mapping) +end + +function _build_model_mapping_recommendations( + validated::Bool, + is_multirate::Bool, + vars_need_init +) + recommendations = String[] + if !validated + push!(recommendations, "Built with `check=false`: rebuild with `check=true` to validate consistency.") + end + if !_isempty_vars_need_init(vars_need_init) + push!(recommendations, "Initialize required variables listed above (see `to_initialize(mapping)`).") + end + if is_multirate + push!(recommendations, "Multirate is enabled from mapping metadata; `run!(mtg, mapping, ...)` auto-detects it.") + end + return recommendations +end + +function _build_model_mapping_info(::Type{SingleScale}, mapping::ModelList; validated::Bool) + specs = Dict( + "Default" => Dict{Symbol,ModelSpec}( + process(model) => as_model_spec(model) for model in values(mapping.models) + ) + ) + + declared_rates = Dict{String,Any}("Default" => nothing) + vars_need_init = try + to_initialize(mapping) + catch + NamedTuple() + end + is_multirate = false + recommendations = _build_model_mapping_recommendations(validated, is_multirate, vars_need_init) + processes = [process(model) for model in values(mapping.models)] + return ModelMappingInfo( + validated, + validated, + is_multirate, + ["Default"], + Dict("Default" => length(mapping.models)), + Dict("Default" => processes), + declared_rates, + vars_need_init, + specs, + recommendations, + ) +end + +function _build_model_mapping_info(::Type{MultiScale}, mapping::Dict{String,Tuple}; validated::Bool) + scales = collect(keys(mapping)) + models_per_scale, processes_per_scale = _model_summary_from_mapping(mapping) + declared_rates = try + _declared_model_rates_by_scale(mapping) + catch + Dict{String,Any}(scale => nothing for scale in scales) + end + model_specs = try + _parse_model_specs_from_mapping(mapping) + catch + Dict{String,Dict{Symbol,ModelSpec}}() + end + vars_need_init = try + to_initialize(mapping, nothing) + catch + Dict{String,Vector{Symbol}}() + end + is_multirate = _mapping_declares_multirate(model_specs, declared_rates) + recommendations = _build_model_mapping_recommendations(validated, is_multirate, vars_need_init) + return ModelMappingInfo( + validated, + validated, + is_multirate, + scales, + models_per_scale, + processes_per_scale, + declared_rates, + vars_need_init, + model_specs, + recommendations, + ) +end diff --git a/src/run.jl b/src/run.jl index 374a20e68..2045736a9 100644 --- a/src/run.jl +++ b/src/run.jl @@ -20,10 +20,8 @@ multi-threaded way (`executor=ThreadedEx()`, the default), or in a distributed w - `mapping`: a [`ModelMapping`](@ref) between MTG scales and models. - `nsteps`: the number of time-steps to run, only needed if no meteo is given (else it is infered from it). - `outputs`: the outputs to get in dynamic for each node type of the MTG. -- `multirate`: experimental feature flag enabling temporal stream-based input resolution for multiscale simulations. -Supports `HoldLast`, `Interpolate`, `Integrate`, and `Aggregate` policies. -In MTG multi-rate runs, non-sequential executors are currently downgraded to `SequentialEx()` with a warning. -Model timesteps shorter than the meteo base step are rejected (sub-step execution is currently unsupported). +- `tracked_outputs`: tracked outputs for MTG multi-rate exports. Supports `OutputRequest` + (or vectors of `OutputRequest`) when the `ModelMapping` declares multirate metadata. - `return_requested_outputs`: when `true` in MTG multi-rate runs, return requested resampled outputs directly as second return value. - `requested_outputs_sink`: sink used to materialize requested outputs when `return_requested_outputs=true`. @@ -126,14 +124,6 @@ function _all_modellists_collection(object) return false end -function _error_if_multirate_singlescale(multirate) - multirate || return nothing - error( - "`multirate=true` is only supported for MTG-based multiscale runs. ", - "For one scale, build a one-scale MTG and call `run!(mtg, mapping, ...; multirate=true)`." - ) -end - _single_scale_runtime_object(object) = object _single_scale_runtime_object(mapping::ModelMapping) = _modellist_from_model_mapping(mapping) @@ -155,11 +145,9 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) where {M<:Union{ModelMapping{SingleScale},ModelList}} - _error_if_multirate_singlescale(multirate) model_list = _modellist_from_model_mapping(mapping) _run_modellist_singleton( model_list, @@ -181,7 +169,6 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) @@ -199,7 +186,6 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) @@ -212,7 +198,6 @@ function run!( tracked_outputs, check, executor, - multirate, return_requested_outputs, requested_outputs_sink ) @@ -232,11 +217,9 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) where {T<:Union{AbstractArray,AbstractDict},A} - _error_if_multirate_singlescale(multirate) if _all_modellists_collection(object) Base.depwarn( "`run!` with a collection of `ModelList` is deprecated. Use a collection of `ModelMapping` objects instead.", @@ -260,9 +243,9 @@ function run!( for obj in object if isa(object, AbstractArray) - push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor, multirate=multirate)) + push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor)) else - outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor, multirate=multirate) + outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor) end end @@ -280,11 +263,9 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) where {T<:ModelList} - _error_if_multirate_singlescale(multirate) Base.depwarn( "`run!(::ModelList, ...)` is deprecated. Use `run!(ModelMapping(...), ...)` instead.", :run! @@ -310,11 +291,9 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) where {T<:ModelMapping{SingleScale}} - _error_if_multirate_singlescale(multirate) model_list = _modellist_from_model_mapping(object) _run_modellist_singleton( @@ -433,11 +412,9 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) where {T<:Union{AbstractArray,AbstractDict}} - _error_if_multirate_singlescale(multirate) if _all_modellists_collection(object) Base.depwarn( "`run!` with a collection of `ModelList` is deprecated. Use a collection of `ModelMapping` objects instead.", @@ -480,9 +457,9 @@ function run!( # Each object: for obj in object if isa(object, AbstractArray) - push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor, multirate=multirate)) + push!(outputs_collection, run!(obj, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor)) else - outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor, multirate=multirate) + outputs_collection[obj.first] = run!(obj.second, meteo, constants, extra, tracked_outputs=tracked_outputs, check=check, executor=executor) end end @@ -541,6 +518,9 @@ function _multirate_tracked_outputs(tracked_outputs) return tracked_outputs, OutputRequest[] end +_effective_multirate(mapping::ModelMapping) = is_multirate(mapping) +_effective_multirate(sim::GraphSimulation) = is_multirate(sim) + function run!( object::MultiScaleTreeGraph.Node, mapping::ModelMapping, @@ -551,12 +531,12 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) + effective_multirate = _effective_multirate(mapping) isnothing(nsteps) && (nsteps = get_nsteps(meteo)) - meteo_adjusted = if multirate && meteo isa TimeStepTable{<:Atmosphere} + meteo_adjusted = if effective_multirate && meteo isa TimeStepTable{<:Atmosphere} # Keep TimeStepTable intact in MTG multi-rate runs so model-clock meteo # sampling/aggregation can use PlantMeteo sampler APIs. meteo @@ -566,8 +546,8 @@ function run!( adjust_weather_timesteps_to_given_length(nsteps, meteo) end status_outputs, output_requests = _multirate_tracked_outputs(tracked_outputs) - !multirate && !isempty(output_requests) && error("`OutputRequest` requires `multirate=true`.") - return_requested_outputs && !multirate && error("`return_requested_outputs=true` requires `multirate=true`.") + !effective_multirate && !isempty(output_requests) && error("`OutputRequest` requires a multirate `ModelMapping`.") + return_requested_outputs && !effective_multirate && error("`return_requested_outputs=true` requires a multirate `ModelMapping`.") # NOTE : replace_mapping_status_vectors_with_generated_models is assumed to have already run if used # otherwise there might be vector length conflicts with timesteps @@ -579,7 +559,6 @@ function run!( extra; check=check, executor=executor, - multirate=multirate, tracked_outputs=output_requests, return_requested_outputs=return_requested_outputs, requested_outputs_sink=requested_outputs_sink @@ -602,7 +581,6 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) @@ -620,7 +598,6 @@ function run!( tracked_outputs=tracked_outputs, check=check, executor=executor, - multirate=multirate, return_requested_outputs=return_requested_outputs, requested_outputs_sink=requested_outputs_sink ) @@ -635,18 +612,18 @@ function run!( tracked_outputs=nothing, check=true, executor=ThreadedEx(), - multirate=false, return_requested_outputs=false, requested_outputs_sink=DataFrames.DataFrame ) + effective_multirate = _effective_multirate(object) dep_graph = object.dependency_graph models = get_models(object) timeline = _timeline_context(meteo) - meteo_sampler = multirate ? _prepare_meteo_sampler(meteo) : nothing + meteo_sampler = effective_multirate ? _prepare_meteo_sampler(meteo) : nothing effective_executor = executor # st = status(object) - if multirate + if effective_multirate if executor != SequentialEx() @warn string( "Multi-rate MTG runs currently execute sequentially. ", @@ -659,7 +636,7 @@ function run!( prepare_output_requests!(object, tracked_outputs, timeline) configure_temporal_buffers!(object, timeline) elseif return_requested_outputs - error("`return_requested_outputs=true` requires `multirate=true`.") + error("`return_requested_outputs=true` requires a multirate `ModelMapping`.") end !isnothing(extra) && error("Extra parameters are not allowed for the simulation of an MTG (already used for statuses).") @@ -670,17 +647,17 @@ function run!( if nsteps == 1 roots = collect(dep_graph.roots) for (process_key, dependency_node) in roots - run_node_multiscale!(object, dependency_node, 1, models, meteo, constants, object, check, effective_executor, multirate, timeline, meteo_sampler) + run_node_multiscale!(object, dependency_node, 1, models, meteo, constants, object, check, effective_executor, effective_multirate, timeline, meteo_sampler) end - multirate && update_requested_outputs!(object, _time_from_step(1, timeline)) + effective_multirate && update_requested_outputs!(object, _time_from_step(1, timeline)) save_results!(object, 1) else for (i, meteo_i) in enumerate(Tables.rows(meteo)) roots = collect(dep_graph.roots) for (process_key, dependency_node) in roots - run_node_multiscale!(object, dependency_node, i, models, meteo_i, constants, object, check, effective_executor, multirate, timeline, meteo_sampler) + run_node_multiscale!(object, dependency_node, i, models, meteo_i, constants, object, check, effective_executor, effective_multirate, timeline, meteo_sampler) end - multirate && update_requested_outputs!(object, _time_from_step(i, timeline)) + effective_multirate && update_requested_outputs!(object, _time_from_step(i, timeline)) # At the end of the time-step, we save the results of the simulation in the object: save_results!(object, i) end diff --git a/src/time/runtime/output_export.jl b/src/time/runtime/output_export.jl index 80deb486f..2fccd7848 100644 --- a/src/time/runtime/output_export.jl +++ b/src/time/runtime/output_export.jl @@ -3,7 +3,7 @@ Describe one online-exported multi-rate output series for MTG multi-rate runs. -Use this type in `run!(...; multirate=true, tracked_outputs=...)` to export +Use this type in `run!(...; tracked_outputs=...)` to export resampled temporal streams while simulation is running. # Arguments diff --git a/test/test-mapping.jl b/test/test-mapping.jl index 0ed6e9b93..02a79421a 100755 --- a/test/test-mapping.jl +++ b/test/test-mapping.jl @@ -85,6 +85,20 @@ end ) @test mapping_with_specs isa PlantSimEngine.ModelMapping @test any(item -> item isa ModelSpec, mapping_with_specs["Soil"]) + @test mapping_with_specs.info.validated + @test mapping_with_specs.info.is_valid + @test mapping_with_specs.info.is_multirate + @test Set(mapping_with_specs.info.scales) == Set(["Scene", "Soil", "Leaf"]) + @test mapping_with_specs.info.models_per_scale["Leaf"] == 1 + @test length(mapping_with_specs.info.processes_per_scale["Leaf"]) == 1 + @test haskey(mapping_with_specs.info.model_specs, "Leaf") + + io = IOBuffer() + show(io, MIME("text/plain"), mapping_with_specs) + summary_txt = String(take!(io)) + @test occursin("ModelMapping", summary_txt) + @test occursin("multirate: true", summary_txt) + @test occursin("scales (3)", summary_txt) dep_from_dict = dep(mapping) dep_from_struct = dep(mapping_struct) @@ -176,6 +190,9 @@ end process3=Process3Model(), status=(var1=15.0, var2=0.3) ) + @test !models_single_scale.info.is_multirate + @test models_single_scale.info.scales == ["Default"] + @test models_single_scale.info.models_per_scale["Default"] == 3 baseline_outputs = run!(models_single_scale, meteo) outputs_from_models_args = run!( diff --git a/test/test-mtg-dynamic.jl b/test/test-mtg-dynamic.jl index 952533133..5b2023bdb 100644 --- a/test/test-mtg-dynamic.jl +++ b/test/test-mtg-dynamic.jl @@ -157,7 +157,7 @@ end nsteps2 = 48 meteo2 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, Ri_PAR_f=300.0)], nsteps2)) sim2 = PlantSimEngine.GraphSimulation(mtg2, mapping2, nsteps=nsteps2, check=true, outputs=out_vars2) - out2 = run!(sim2, meteo2, multirate=true, executor=SequentialEx()) + out2 = run!(sim2, meteo2, executor=SequentialEx()) st2 = status(sim2) @test length(st2["Scene"]) == length(st2["Soil"]) == length(st2["Plant"]) == length(st2["Internode"]) == 1 diff --git a/test/test-multirate-output-export.jl b/test/test-multirate-output-export.jl index 984af5ae3..35e385a83 100644 --- a/test/test-multirate-output-export.jl +++ b/test/test-multirate-output-export.jl @@ -68,7 +68,6 @@ PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) run!( sim_stream, meteo4, - multirate=true, executor=SequentialEx(), tracked_outputs=[req_hold, req_sum2], ) @@ -83,7 +82,6 @@ PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) @test_throws "No canonical publisher found" run!( sim_stream, meteo4, - multirate=true, executor=SequentialEx(), tracked_outputs=[OutputRequest("Leaf", :X; name=:x_auto_fail)], ) @@ -100,7 +98,6 @@ PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) run!( sim_canonical, meteo4, - multirate=true, executor=SequentialEx(), tracked_outputs=[OutputRequest("Leaf", :X; name=:x_auto, policy=HoldLast())], ) @@ -123,7 +120,6 @@ PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) out_status, out_requested = run!( sim_direct, meteo4, - multirate=true, executor=SequentialEx(), tracked_outputs=[OutputRequest("Leaf", :X; name=:x_direct, policy=HoldLast())], return_requested_outputs=true, @@ -141,7 +137,6 @@ PlantSimEngine.timespec(::Type{<:MRDefaultSceneAggModel}) = ClockSpec(4.0, 1.0) ), ), meteo4; - multirate=true, executor=SequentialEx(), tracked_outputs=[OutputRequest("Leaf", :X; name=:x_mtg, policy=HoldLast())], return_requested_outputs=true, @@ -187,7 +182,6 @@ end run!( sim_defaults, meteo8, - multirate=true, executor=SequentialEx(), tracked_outputs=[ OutputRequest("Plant", :XP), diff --git a/test/test-multirate-runtime.jl b/test/test-multirate-runtime.jl index f9aa60242..cea71a0f4 100644 --- a/test/test-multirate-runtime.jl +++ b/test/test-multirate-runtime.jl @@ -295,7 +295,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( out_ok = Dict("Leaf" => (:S, :C, :B, :D, :E)) sim_ok = PlantSimEngine.GraphSimulation(mtg, mapping_ok, nsteps=1, check=true, outputs=out_ok) meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - run!(sim_ok, meteo, multirate=true, executor=SequentialEx()) + run!(sim_ok, meteo, executor=SequentialEx()) specs_leaf = PlantSimEngine.get_model_specs(sim_ok)["Leaf"] @test input_bindings(specs_leaf[:mrconsumer]).C.var == :S @@ -330,13 +330,13 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( mapping_conflict = ModelMapping( "Leaf" => ( - MRConflict1Model(), - MRConflict2Model(), + ModelSpec(MRConflict1Model()) |> TimeStepModel(1.0), + ModelSpec(MRConflict2Model()) |> TimeStepModel(1.0), ), ) sim_conflict = PlantSimEngine.GraphSimulation(mtg, mapping_conflict, nsteps=1, check=true, outputs=Dict("Leaf" => (:Z,))) # Expectation 5: two canonical publishers of the same output are rejected. - @test_throws "Ambiguous canonical publishers" run!(sim_conflict, meteo, multirate=true, executor=SequentialEx()) + @test_throws "Ambiguous canonical publishers" run!(sim_conflict, meteo, executor=SequentialEx()) # Expectation 6: models run at different clocks; slower model holds last value between runs. source_counter = Ref(0) @@ -349,7 +349,18 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) sim_clock_trait = PlantSimEngine.GraphSimulation(mtg, mapping_clock_trait, nsteps=4, check=true, outputs=Dict("Leaf" => (:X, :Y))) meteo4 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 4)) - run!(sim_clock_trait, meteo4, multirate=true, executor=SequentialEx()) + run!(sim_clock_trait, meteo4, executor=SequentialEx()) + source_counter[] = 0 + out_status_auto, out_requested_auto = run!( + mtg, + mapping_clock_trait, + meteo4; + executor=SequentialEx(), + tracked_outputs=[OutputRequest("Leaf", :X; process=:mrclocksource, policy=HoldLast(), name=:x_auto)], + return_requested_outputs=true, + ) + @test haskey(out_status_auto, "Leaf") + @test out_requested_auto[:x_auto][:, :value] == [1.0, 2.0, 3.0, 4.0] st_clock = status(sim_clock_trait)["Leaf"][1] @test st_clock.X == 4.0 @test st_clock.Y == 3.0 @@ -368,7 +379,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_clock_override = PlantSimEngine.GraphSimulation(mtg, mapping_clock_override, nsteps=4, check=true, outputs=Dict("Leaf" => (:X, :Y))) - run!(sim_clock_override, meteo4, multirate=true, executor=SequentialEx()) + run!(sim_clock_override, meteo4, executor=SequentialEx()) st_clock_override = status(sim_clock_override)["Leaf"][1] @test st_clock_override.X == 4.0 @test st_clock_override.Y == 3.0 @@ -384,7 +395,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_clock_fallback_seq = PlantSimEngine.GraphSimulation(mtg, mapping_clock_fallback_seq, nsteps=4, check=true, outputs=Dict("Leaf" => (:X, :Y))) - out_fallback_seq = run!(sim_clock_fallback_seq, meteo4, multirate=true, executor=SequentialEx()) + out_fallback_seq = run!(sim_clock_fallback_seq, meteo4, executor=SequentialEx()) out_fallback_seq_df = convert_outputs(out_fallback_seq, DataFrame) mapping_clock_fallback_threaded = ModelMapping( @@ -396,7 +407,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) sim_clock_fallback_threaded = PlantSimEngine.GraphSimulation(mtg, mapping_clock_fallback_threaded, nsteps=4, check=true, outputs=Dict("Leaf" => (:X, :Y))) @test_logs (:warn, r"Multi-rate MTG runs currently execute sequentially") begin - out_fallback_threaded = run!(sim_clock_fallback_threaded, meteo4, multirate=true, executor=ThreadedEx()) + out_fallback_threaded = run!(sim_clock_fallback_threaded, meteo4, executor=ThreadedEx()) out_fallback_threaded_df = convert_outputs(out_fallback_threaded, DataFrame) @test out_fallback_threaded_df["Leaf"][:, :X] == out_fallback_seq_df["Leaf"][:, :X] @test out_fallback_threaded_df["Leaf"][:, :Y] == out_fallback_seq_df["Leaf"][:, :Y] @@ -417,7 +428,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_cross = PlantSimEngine.GraphSimulation(mtg, mapping_cross, nsteps=4, check=true, outputs=Dict("Leaf" => (:XS,), "Plant" => (:XP,))) - run!(sim_cross, meteo4, multirate=true, executor=SequentialEx()) + run!(sim_cross, meteo4, executor=SequentialEx()) st_leaf_cross = status(sim_cross)["Leaf"][1] st_plant_cross = status(sim_cross)["Plant"][1] @test st_leaf_cross.XS == 4.0 @@ -436,7 +447,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_cross_auto = PlantSimEngine.GraphSimulation(mtg, mapping_cross_auto, nsteps=4, check=true, outputs=Dict("Leaf" => (:XS,), "Plant" => (:XP,))) - run!(sim_cross_auto, meteo4, multirate=true, executor=SequentialEx()) + run!(sim_cross_auto, meteo4, executor=SequentialEx()) st_plant_cross_auto = status(sim_cross_auto)["Plant"][1] @test st_plant_cross_auto.XP == 3.0 spec_cross_auto = PlantSimEngine.get_model_specs(sim_cross_auto)["Plant"][:mrcrossconsumer] @@ -466,7 +477,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_scoped = PlantSimEngine.GraphSimulation(scene2, mapping_scoped, nsteps=1, check=true, outputs=Dict("Plant" => (:XP,), "Leaf" => (:XS,))) - run!(sim_scoped, meteo, multirate=true, executor=SequentialEx()) + run!(sim_scoped, meteo, executor=SequentialEx()) plant_vals = sort([st.XP for st in status(sim_scoped)["Plant"]]) @test plant_vals == [1.0, 2.0] @@ -501,7 +512,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) meteo5 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 5)) sim_interp = PlantSimEngine.GraphSimulation(mtg, mapping_interp, nsteps=5, check=true, outputs=Dict("Leaf" => (:YI,))) - out_interp = run!(sim_interp, meteo5, multirate=true, executor=SequentialEx()) + out_interp = run!(sim_interp, meteo5, executor=SequentialEx()) out_interp_df = convert_outputs(out_interp, DataFrame) @test out_interp_df["Leaf"][:, :YI] == [1.0, 1.0, 3.0, 4.0, 5.0] @@ -521,7 +532,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_agg = PlantSimEngine.GraphSimulation(mtg, mapping_agg, nsteps=4, check=true, outputs=Dict("Leaf" => (:YA,))) - out_agg = run!(sim_agg, meteo4, multirate=true, executor=SequentialEx()) + out_agg = run!(sim_agg, meteo4, executor=SequentialEx()) out_agg_df = convert_outputs(out_agg, DataFrame) @test out_agg_df["Leaf"][:, :YA] == [1.0, 1.0, 2.5, 2.5] @test status(sim_agg)["Leaf"][1].YA == 2.5 @@ -544,7 +555,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_agg_max = PlantSimEngine.GraphSimulation(mtg, mapping_agg_max, nsteps=4, check=true, outputs=Dict("Leaf" => (:YA,))) - out_agg_max = run!(sim_agg_max, meteo4, multirate=true, executor=SequentialEx()) + out_agg_max = run!(sim_agg_max, meteo4, executor=SequentialEx()) out_agg_max_df = convert_outputs(out_agg_max, DataFrame) @test out_agg_max_df["Leaf"][:, :YA] == [1.0, 1.0, 3.0, 3.0] @@ -561,7 +572,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_integrate_callable = PlantSimEngine.GraphSimulation(mtg, mapping_integrate_callable, nsteps=4, check=true, outputs=Dict("Leaf" => (:YA,))) - out_integrate_callable = run!(sim_integrate_callable, meteo4, multirate=true, executor=SequentialEx()) + out_integrate_callable = run!(sim_integrate_callable, meteo4, executor=SequentialEx()) out_integrate_callable_df = convert_outputs(out_integrate_callable, DataFrame) @test out_integrate_callable_df["Leaf"][:, :YA] == [0.0, 0.0, 1.0, 1.0] @@ -579,7 +590,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) meteo6 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 6)) sim_interp_hold = PlantSimEngine.GraphSimulation(mtg, mapping_interp_hold, nsteps=6, check=true, outputs=Dict("Leaf" => (:YI,))) - out_interp_hold = run!(sim_interp_hold, meteo6, multirate=true, executor=SequentialEx()) + out_interp_hold = run!(sim_interp_hold, meteo6, executor=SequentialEx()) out_interp_hold_df = convert_outputs(out_interp_hold, DataFrame) @test out_interp_hold_df["Leaf"][:, :YI] == [1.0, 1.0, 3.0, 3.0, 5.0, 5.0] @@ -597,7 +608,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) meteo26 = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65)], 26)) sim_daily_hourly = PlantSimEngine.GraphSimulation(mtg, mapping_daily_hourly, nsteps=26, check=true, outputs=Dict("Leaf" => (:YD,))) - out_daily_hourly = run!(sim_daily_hourly, meteo26, multirate=true, executor=SequentialEx()) + out_daily_hourly = run!(sim_daily_hourly, meteo26, executor=SequentialEx()) out_daily_hourly_df = convert_outputs(out_daily_hourly, DataFrame) @test out_daily_hourly_df["Leaf"][1:24, :YD] == fill(1.0, 24) @test out_daily_hourly_df["Leaf"][25:26, :YD] == [2.0, 2.0] @@ -617,7 +628,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) meteo_hourly = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Hour(1))], 26)) sim_daily_period = PlantSimEngine.GraphSimulation(mtg, mapping_daily_period, nsteps=26, check=true, outputs=Dict("Leaf" => (:YD,))) - out_daily_period = run!(sim_daily_period, meteo_hourly, multirate=true, executor=SequentialEx()) + out_daily_period = run!(sim_daily_period, meteo_hourly, executor=SequentialEx()) out_daily_period_df = convert_outputs(out_daily_period, DataFrame) @test out_daily_period_df["Leaf"][1:24, :YD] == fill(1.0, 24) @test out_daily_period_df["Leaf"][25:26, :YD] == [2.0, 2.0] @@ -630,7 +641,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_substep_period = PlantSimEngine.GraphSimulation(mtg, mapping_substep_period, nsteps=26, check=true, outputs=Dict("Leaf" => (:XD,))) - @test_throws "shorter than simulation base step" run!(sim_substep_period, meteo_hourly, multirate=true, executor=SequentialEx()) + @test_throws "shorter than simulation base step" run!(sim_substep_period, meteo_hourly, executor=SequentialEx()) # Expectation 17: timestep hints infer a consensus for range-only models and keep explicit overrides. range_counter_a = Ref(0) @@ -645,7 +656,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ) meteo8h = Weather(repeat([Atmosphere(T=20.0, Wind=1.0, Rh=0.65, duration=Dates.Hour(1))], 8)) sim_timestep_hints = PlantSimEngine.GraphSimulation(mtg, mapping_timestep_hints, nsteps=8, check=true, outputs=Dict("Leaf" => (:XA, :XB, :XF))) - run!(sim_timestep_hints, meteo8h, multirate=true, executor=SequentialEx()) + run!(sim_timestep_hints, meteo8h, executor=SequentialEx()) specs_hints = PlantSimEngine.get_model_specs(sim_timestep_hints)["Leaf"] @test Dates.value(Dates.Second(PlantSimEngine.timestep(specs_hints[:mrrangehinta]))) == 10800 @test Dates.value(Dates.Second(PlantSimEngine.timestep(specs_hints[:mrrangehintb]))) == 10800 @@ -675,7 +686,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( Atmosphere(T=40.0, Wind=1.0, Rh=0.80, P=100.0, Ri_SW_f=400.0, duration=Dates.Hour(1), custom_var=4.0), ]) sim_meteo_default = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_default, nsteps=4, check=true, outputs=Dict("Leaf" => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_default = run!(sim_meteo_default, meteo_mr, multirate=true, executor=SequentialEx()) + out_meteo_default = run!(sim_meteo_default, meteo_mr, executor=SequentialEx()) out_meteo_default_df = convert_outputs(out_meteo_default, DataFrame) @test out_meteo_default_df["Leaf"][:, :MT] == [10.0, 10.0, 25.0, 25.0] @test out_meteo_default_df["Leaf"][:, :MTmin] == [10.0, 10.0, 20.0, 20.0] @@ -690,7 +701,6 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( mtg, mapping_meteo_default, meteo_mr; - multirate=true, executor=SequentialEx(), tracked_outputs=Dict("Leaf" => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq)), ) @@ -716,7 +726,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_meteo_custom = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_custom, nsteps=4, check=true, outputs=Dict("Leaf" => (:MRQ, :MCV))) - out_meteo_custom = run!(sim_meteo_custom, meteo_mr, multirate=true, executor=SequentialEx()) + out_meteo_custom = run!(sim_meteo_custom, meteo_mr, executor=SequentialEx()) out_meteo_custom_df = convert_outputs(out_meteo_custom, DataFrame) @test isapprox.(out_meteo_custom_df["Leaf"][:, :MRQ], [0.36, 0.36, 1.8, 1.8], atol=1.0e-9) |> all @test out_meteo_custom_df["Leaf"][:, :MCV] == [1.0, 1.0, 3.0, 3.0] @@ -740,7 +750,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_meteo_hint = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_hint, nsteps=24, check=true, outputs=Dict("Leaf" => (:HT, :HSWQ))) - out_meteo_hint = run!(sim_meteo_hint, meteo_hint_rows, multirate=true, executor=SequentialEx()) + out_meteo_hint = run!(sim_meteo_hint, meteo_hint_rows, executor=SequentialEx()) out_meteo_hint_df = convert_outputs(out_meteo_hint, DataFrame) spec_meteo_hint = PlantSimEngine.get_model_specs(sim_meteo_hint)["Leaf"][:mrmeteohintconsumer] @test PlantSimEngine.timestep(spec_meteo_hint) == Dates.Day(1) @@ -786,7 +796,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_meteo_calendar_current = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_current, nsteps=48, check=true, outputs=Dict("Leaf" => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_calendar_current = run!(sim_meteo_calendar_current, meteo_calendar, multirate=true, executor=SequentialEx()) + out_meteo_calendar_current = run!(sim_meteo_calendar_current, meteo_calendar, executor=SequentialEx()) out_meteo_calendar_current_df = convert_outputs(out_meteo_calendar_current, DataFrame) @test out_meteo_calendar_current_df["Leaf"][1, :MT] == 12.5 @test out_meteo_calendar_current_df["Leaf"][10, :MT] == 12.5 @@ -807,7 +817,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_meteo_calendar_prev = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_prev, nsteps=48, check=true, outputs=Dict("Leaf" => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - out_meteo_calendar_prev = run!(sim_meteo_calendar_prev, meteo_calendar, multirate=true, executor=SequentialEx()) + out_meteo_calendar_prev = run!(sim_meteo_calendar_prev, meteo_calendar, executor=SequentialEx()) out_meteo_calendar_prev_df = convert_outputs(out_meteo_calendar_prev, DataFrame) @test out_meteo_calendar_prev_df["Leaf"][30, :MT] == 12.5 @test out_meteo_calendar_prev_df["Leaf"][30, :MTmin] == 1.0 @@ -822,7 +832,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_meteo_calendar_prev_strict = PlantSimEngine.GraphSimulation(mtg, mapping_meteo_calendar_prev_strict, nsteps=48, check=true, outputs=Dict("Leaf" => (:MT, :MTmin, :MTmax, :MRh, :MSW, :MSWq))) - @test_throws "No period available" run!(sim_meteo_calendar_prev_strict, meteo_calendar, multirate=true, executor=SequentialEx()) + @test_throws "No period available" run!(sim_meteo_calendar_prev_strict, meteo_calendar, executor=SequentialEx()) end # Expectation 24: ambiguous same-name inferred producer is rejected at initialization. @@ -844,7 +854,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_stream_only_infer = PlantSimEngine.GraphSimulation(mtg, mapping_stream_only_infer, nsteps=1, check=true, outputs=Dict("Leaf" => (:ZZ,))) - run!(sim_stream_only_infer, meteo, multirate=true, executor=SequentialEx()) + run!(sim_stream_only_infer, meteo, executor=SequentialEx()) @test status(sim_stream_only_infer)["Leaf"][1].ZZ == 1.0 spec_stream_only_infer = PlantSimEngine.get_model_specs(sim_stream_only_infer)["Leaf"][:mrzconsumer] @test input_bindings(spec_stream_only_infer).Z.process == :mrconflict1 @@ -863,7 +873,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_lineage_infer = PlantSimEngine.GraphSimulation(mtg, mapping_lineage_infer, nsteps=1, check=true, outputs=Dict("Leaf" => (:ZZ,))) - run!(sim_lineage_infer, meteo, multirate=true, executor=SequentialEx()) + run!(sim_lineage_infer, meteo, executor=SequentialEx()) @test status(sim_lineage_infer)["Leaf"][1].ZZ == 11.0 spec_lineage_infer = PlantSimEngine.get_model_specs(sim_lineage_infer)["Leaf"][:mrzconsumer] @test input_bindings(spec_lineage_infer).Z.process == :mrancestorsource @@ -877,7 +887,7 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( ), ) sim_missing_input = PlantSimEngine.GraphSimulation(mtg, mapping_missing_input, nsteps=1, check=true, outputs=Dict("Leaf" => (:OU,))) - run!(sim_missing_input, meteo, multirate=true, executor=SequentialEx()) + run!(sim_missing_input, meteo, executor=SequentialEx()) @test status(sim_missing_input)["Leaf"][1].OU == 42.0 # Expectation 26: invalid mapping-level API configuration fails during GraphSimulation init. diff --git a/test/test-simulation.jl b/test/test-simulation.jl index 96ff67eca..b965c6e8b 100644 --- a/test/test-simulation.jl +++ b/test/test-simulation.jl @@ -43,7 +43,7 @@ end; @test_deprecated run!(mtg, mapping_dict, meteo) end -@testset "Single-scale multirate unsupported" begin +@testset "Removed multirate keyword for single-scale" begin mapping = ModelMapping( process1=Process1Model(1.0), process2=Process2Model(), @@ -52,8 +52,8 @@ end ) meteo = Atmosphere(T=20.0, Wind=1.0, Rh=0.65) - @test_throws "one-scale MTG" run!(mapping, meteo; multirate=true) - @test_throws "one-scale MTG" run!([mapping], meteo; multirate=true) + @test_throws MethodError run!(mapping, meteo; multirate=true) + @test_throws MethodError run!([mapping], meteo; multirate=true) end @testset "Simulation: 1 time-step, 0 Atmosphere" begin From 93adaae4f7a04a740f1507dec780a4947d34570b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 16 Feb 2026 11:39:24 +0100 Subject: [PATCH 2/3] Make difference between Integrate and Aggregate more clear --- docs/src/API/API_public.md | 2 ++ docs/src/model_execution.md | 2 ++ src/time/multirate.jl | 12 ++++++++++++ src/time/runtime/output_export.jl | 2 ++ test/test-multirate-scaffolding.jl | 10 ++++++++++ 5 files changed, 28 insertions(+) diff --git a/docs/src/API/API_public.md b/docs/src/API/API_public.md index f86672e6e..18a58728f 100644 --- a/docs/src/API/API_public.md +++ b/docs/src/API/API_public.md @@ -108,6 +108,8 @@ MeteoBindings( ### Parameterized window reducers `Integrate()` defaults to `SumReducer()`; `Aggregate()` defaults to `MeanReducer()`. +With the same reducer, they are runtime-equivalent. +Use `Integrate` for accumulation semantics and `Aggregate` for summary-statistics semantics. ```julia ModelSpec(DailyModel()) |> diff --git a/docs/src/model_execution.md b/docs/src/model_execution.md index dfca4b9ab..cedb57eb0 100644 --- a/docs/src/model_execution.md +++ b/docs/src/model_execution.md @@ -50,6 +50,8 @@ Inspection helpers: Policy parameterization: - `Integrate()` defaults to `SumReducer()`; you can pass another reducer, e.g. `Integrate(MeanReducer())` or `Integrate(vals -> maximum(vals) - minimum(vals))`. - `Aggregate()` defaults to `MeanReducer()`; you can pass reducers such as `Aggregate(MaxReducer())`. +- Difference between `Integrate` and `Aggregate`: with the same reducer they are runtime-equivalent. + In practice, only defaults and naming intent differ (`Integrate` for accumulation, `Aggregate` for summary statistics). - `Interpolate()` defaults to `mode=:linear, extrapolation=:linear`; use `Interpolate(; mode=:hold, extrapolation=:hold)` for hold behavior. - The same reducer objects are reused by meteo sampling (`MeteoBindings`) and by windowed policies (`Integrate`, `Aggregate`). diff --git a/src/time/multirate.jl b/src/time/multirate.jl index f7868035b..047398593 100644 --- a/src/time/multirate.jl +++ b/src/time/multirate.jl @@ -98,6 +98,12 @@ Interpolate(; mode::Symbol=:linear, extrapolation::Symbol=:linear) = Interpolate Windowed policy for consumers running at coarser clocks. Values in the consumer window are reduced with `reducer`. +Intended meaning: integrate/accumulate quantities over a window (for example +hourly flux to daily total). Default reducer is `SumReducer()`. + +Important: `Integrate(r)` and `Aggregate(r)` are runtime-equivalent when they +use the same reducer `r`; they only differ by default reducer and naming intent. + Built-in reducers can be shared with meteo sampling from `PlantMeteo`: `SumReducer()`, `MeanReducer()`, `MaxReducer()`, `MinReducer()`, `FirstReducer()`, `LastReducer()`. @@ -118,6 +124,12 @@ end Windowed aggregation policy for consumers running at coarser clocks. Values in the consumer window are reduced with `reducer`. +Intended meaning: summarize window values as a statistic (for example mean/max). +Default reducer is `MeanReducer()`. + +Important: `Aggregate(r)` and `Integrate(r)` are runtime-equivalent when they +use the same reducer `r`; they only differ by default reducer and naming intent. + Built-in reducers can be shared with meteo sampling from `PlantMeteo`: `SumReducer()`, `MeanReducer()`, `MaxReducer()`, `MinReducer()`, `FirstReducer()`, `LastReducer()`. diff --git a/src/time/runtime/output_export.jl b/src/time/runtime/output_export.jl index 2fccd7848..1edece545 100644 --- a/src/time/runtime/output_export.jl +++ b/src/time/runtime/output_export.jl @@ -19,6 +19,8 @@ resampled temporal streams while simulation is running. - `policy::SchedulePolicy=HoldLast()`: resampling policy applied at export time. Common values are `HoldLast()`, `Integrate(...)`, `Aggregate(...)`, `Interpolate(...)`. + `Integrate` and `Aggregate` are runtime-equivalent with the same reducer; + they differ by default reducer (`SumReducer` vs `MeanReducer`) and intent. - `clock=nothing`: export clock. When `nothing`, export is evaluated at each simulation step (`ClockSpec(1.0, 0.0)`). Accepted explicit values are the same as model timestep specs (`Real`, `ClockSpec`, or fixed `Dates.Period`). diff --git a/test/test-multirate-scaffolding.jl b/test/test-multirate-scaffolding.jl index 3b5dd6139..48ee8449c 100644 --- a/test/test-multirate-scaffolding.jl +++ b/test/test-multirate-scaffolding.jl @@ -58,4 +58,14 @@ using Test ts.caches[key] = HoldLastCache(1.0, 42.0) @test ts.caches[key] isa HoldLastCache @test ts.caches[key].v == 42.0 + + vals = [1.0, 2.0, 3.0] + @test Integrate().reducer isa SumReducer + @test Aggregate().reducer isa MeanReducer + @test PlantSimEngine._window_reduce(vals, Integrate()) == 6.0 + @test PlantSimEngine._window_reduce(vals, Aggregate()) == 2.0 + @test PlantSimEngine._window_reduce(vals, Integrate(MeanReducer())) == + PlantSimEngine._window_reduce(vals, Aggregate(MeanReducer())) + @test PlantSimEngine._window_reduce(vals, Integrate(SumReducer())) == + PlantSimEngine._window_reduce(vals, Aggregate(SumReducer())) end From f564bb34a6551fd4cf00e952b115b3f2fc50172a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Mon, 16 Feb 2026 12:12:59 +0100 Subject: [PATCH 3/3] Same-rate hard deps: no required explicit InputBindings/OutputRouting --- src/mtg/initialisation.jl | 14 ++- src/mtg/model_spec_inference.jl | 166 ++++++++++++++++++++++++++++-- src/time/runtime/output_export.jl | 3 + src/time/runtime/publishers.jl | 3 + test/test-multirate-runtime.jl | 74 +++++++++++++ 5 files changed, 250 insertions(+), 10 deletions(-) diff --git a/src/mtg/initialisation.jl b/src/mtg/initialisation.jl index 4352ac5a3..b659619bd 100644 --- a/src/mtg/initialisation.jl +++ b/src/mtg/initialisation.jl @@ -320,12 +320,20 @@ function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion else Dict(first(m) => parse_model_specs(last(m)) for m in mapping) end - scale_reachability = _scale_reachability_from_mtg(mtg) - infer_model_specs_configuration!(model_specs; scale_reachability=scale_reachability) - validate_model_specs_configuration(model_specs) soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false) + scale_reachability = _scale_reachability_from_mtg(mtg) + _infer_timestep_hints!(model_specs) + ignored_same_rate_hard_children = _same_rate_hard_dependency_children(model_specs, soft_dep_graphs_roots) + active_processes_by_scale = _active_processes_for_inference(model_specs, ignored_same_rate_hard_children) + infer_model_specs_configuration!( + model_specs; + scale_reachability=scale_reachability, + active_processes_by_scale=active_processes_by_scale + ) + validate_model_specs_configuration(model_specs) + # Get the status of each node by node type, pre-initialised considering multi-scale variables: statuses, status_templates, reverse_multiscale_mapping, vars_need_init = init_statuses(mtg, mapping, soft_dep_graphs_roots; type_promotion=type_promotion, verbose=verbose, check=check) diff --git a/src/mtg/model_spec_inference.jl b/src/mtg/model_spec_inference.jl index 827db779a..d3d61e6f4 100644 --- a/src/mtg/model_spec_inference.jl +++ b/src/mtg/model_spec_inference.jl @@ -259,12 +259,135 @@ function _scale_reachability_from_mtg(mtg) return scale_reachability end -function _input_candidates_for_var(model_specs, consumer_scale::String, consumer_process::Symbol, input_var::Symbol; scale_reachability=nothing) +function _effective_timestep_spec(spec::ModelSpec) + ts = timestep(spec) + return isnothing(ts) ? timespec(model_(spec)) : ts +end + +function _timestep_signature(ts) + if ts isa ClockSpec + return (:clock, float(ts.dt), float(ts.phase)) + elseif ts isa Real + return (:step, float(ts), 0.0) + elseif ts isa Dates.FixedPeriod + return (:period, _seconds_from_period(ts), 0.0) + end + return nothing +end + +function _same_timestep_signature(sig_a, sig_b) + isnothing(sig_a) && return false + isnothing(sig_b) && return false + + if sig_a[1] == :period || sig_b[1] == :period + return sig_a[1] == :period && + sig_b[1] == :period && + isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0) + end + + phase_a = sig_a[1] == :step ? 0.0 : sig_a[3] + phase_b = sig_b[1] == :step ? 0.0 : sig_b[3] + return isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0) && + isapprox(phase_a, phase_b; atol=1.0e-9, rtol=0.0) +end + +function _hard_dep_same_rate_as_parent(model_specs, parent_scale::String, parent_process::Symbol, child_scale::String, child_process::Symbol) + parent_scale == child_scale || return false + parent_specs = get(model_specs, parent_scale, nothing) + isnothing(parent_specs) && return false + parent_spec = get(parent_specs, parent_process, nothing) + child_spec = get(parent_specs, child_process, nothing) + isnothing(parent_spec) && return false + isnothing(child_spec) && return false + + parent_sig = _timestep_signature(_effective_timestep_spec(parent_spec)) + child_sig = _timestep_signature(_effective_timestep_spec(child_spec)) + return _same_timestep_signature(parent_sig, child_sig) +end + +function _collect_same_rate_hard_dependency_children!( + ignored_processes_by_scale::Dict{String,Set{Symbol}}, + model_specs, + parent_scale::String, + parent_process::Symbol, + child::HardDependencyNode +) + if _hard_dep_same_rate_as_parent(model_specs, parent_scale, parent_process, child.scale, child.process) + push!(get!(ignored_processes_by_scale, child.scale, Set{Symbol}()), child.process) + end + + for nested in child.children + _collect_same_rate_hard_dependency_children!( + ignored_processes_by_scale, + model_specs, + child.scale, + child.process, + nested + ) + end + + return nothing +end + +function _soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph{Dict{String,Any}}) + nodes = SoftDependencyNode[] + for (_, roots_at_scale) in pairs(dep_graph.roots) + haskey(roots_at_scale, :soft_dep_graph) || continue + append!(nodes, values(roots_at_scale[:soft_dep_graph])) + end + return nodes +end + +_soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph) = traverse_dependency_graph(dep_graph, false) + +function _same_rate_hard_dependency_children(model_specs, dep_graph::DependencyGraph) + ignored_processes_by_scale = Dict{String,Set{Symbol}}() + + for soft_node in _soft_nodes_for_hard_dependency_analysis(dep_graph) + for child in soft_node.hard_dependency + _collect_same_rate_hard_dependency_children!( + ignored_processes_by_scale, + model_specs, + soft_node.scale, + soft_node.process, + child + ) + end + end + + return ignored_processes_by_scale +end + +function _active_processes_for_inference(model_specs, ignored_processes_by_scale::Dict{String,Set{Symbol}}) + active = Dict{String,Set{Symbol}}() + for (scale, specs_at_scale) in pairs(model_specs) + procs = Set{Symbol}(keys(specs_at_scale)) + ignored = get(ignored_processes_by_scale, scale, Set{Symbol}()) + for process in ignored + delete!(procs, process) + end + active[scale] = procs + end + return active +end + +function _input_candidates_for_var( + model_specs, + consumer_scale::String, + consumer_process::Symbol, + input_var::Symbol; + scale_reachability=nothing, + active_processes_by_scale=nothing +) same_scale = NamedTuple[] cross_scale = NamedTuple[] for (scale, specs_at_scale) in pairs(model_specs) for (process, spec) in pairs(specs_at_scale) + if !isnothing(active_processes_by_scale) + active = get(active_processes_by_scale, scale, Set{Symbol}()) + process in active || continue + end scale == consumer_scale && process == consumer_process && continue input_var in keys(outputs_(model_(spec))) || continue _is_stream_only_output(spec, input_var) && continue @@ -283,8 +406,22 @@ function _input_candidates_for_var(model_specs, consumer_scale::String, consumer return same_scale, cross_scale end -function _infer_input_binding_for_var(model_specs, scale::String, process::Symbol, input_var::Symbol; scale_reachability=nothing) - same_scale, cross_scale = _input_candidates_for_var(model_specs, scale, process, input_var; scale_reachability=scale_reachability) +function _infer_input_binding_for_var( + model_specs, + scale::String, + process::Symbol, + input_var::Symbol; + scale_reachability=nothing, + active_processes_by_scale=nothing +) + same_scale, cross_scale = _input_candidates_for_var( + model_specs, + scale, + process, + input_var; + scale_reachability=scale_reachability, + active_processes_by_scale=active_processes_by_scale + ) if length(same_scale) == 1 c = only(same_scale) @@ -329,7 +466,7 @@ function _infer_input_binding_for_var(model_specs, scale::String, process::Symbo return nothing end -function _infer_input_bindings!(model_specs; scale_reachability=nothing) +function _infer_input_bindings!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing) for (scale, specs_at_scale) in pairs(model_specs) # When a scale is absent from the initial MTG, input producer inference at # init time is unreliable (dynamic growth may introduce it later). Keep @@ -338,6 +475,10 @@ function _infer_input_bindings!(model_specs; scale_reachability=nothing) continue end for (process, spec) in pairs(specs_at_scale) + if !isnothing(active_processes_by_scale) + active = get(active_processes_by_scale, scale, Set{Symbol}()) + process in active || continue + end current_bindings = input_bindings(spec) current_bindings isa NamedTuple || continue @@ -346,7 +487,14 @@ function _infer_input_bindings!(model_specs; scale_reachability=nothing) for input_var in model_inputs input_var in keys(current_bindings) && continue - inferred_binding = _infer_input_binding_for_var(model_specs, scale, process, input_var; scale_reachability=scale_reachability) + inferred_binding = _infer_input_binding_for_var( + model_specs, + scale, + process, + input_var; + scale_reachability=scale_reachability, + active_processes_by_scale=active_processes_by_scale + ) isnothing(inferred_binding) && continue push!(inferred, input_var => inferred_binding) end @@ -409,8 +557,12 @@ Fill missing `ModelSpec` fields from inference: - model-level hint traits (`timestep_hint`, `meteo_hint`) Explicit `ModelSpec` user values always take precedence over inferred values. """ -function infer_model_specs_configuration!(model_specs; scale_reachability=nothing) - _infer_input_bindings!(model_specs; scale_reachability=scale_reachability) +function infer_model_specs_configuration!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing) + _infer_input_bindings!( + model_specs; + scale_reachability=scale_reachability, + active_processes_by_scale=active_processes_by_scale + ) _infer_timestep_hints!(model_specs) _infer_meteo_hints!(model_specs) return model_specs diff --git a/src/time/runtime/output_export.jl b/src/time/runtime/output_export.jl index 1edece545..38e2a2bea 100644 --- a/src/time/runtime/output_export.jl +++ b/src/time/runtime/output_export.jl @@ -71,9 +71,12 @@ function _canonical_source_process(sim::GraphSimulation, scale::String, var::Sym haskey(get_models(sim), scale) || error("Unknown scale `$(scale)` in output export request.") models_at_scale = get_models(sim)[scale] specs_at_scale = get_model_specs(sim)[scale] + ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim)) + ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}()) publishers = Symbol[] for (process, model) in pairs(models_at_scale) + process in ignored_at_scale && continue var in keys(outputs_(model)) || continue spec = get(specs_at_scale, process, as_model_spec(model)) _publish_mode_for_output(spec, var) == :stream_only && continue diff --git a/src/time/runtime/publishers.jl b/src/time/runtime/publishers.jl index d36af1765..d523277cb 100644 --- a/src/time/runtime/publishers.jl +++ b/src/time/runtime/publishers.jl @@ -31,10 +31,13 @@ Ensure that each `(scale, variable)` has at most one canonical publisher. Throws when multiple producers publish the same canonical output. """ function validate_canonical_publishers(sim::GraphSimulation) + ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim)) for (scale, models_at_scale) in get_models(sim) specs_at_scale = get_model_specs(sim)[scale] + ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}()) publishers = Dict{Symbol,Vector{Symbol}}() for (process, model) in pairs(models_at_scale) + process in ignored_at_scale && continue model_spec = get(specs_at_scale, process, as_model_spec(model)) for var in keys(outputs_(model)) _publish_mode_for_output(model_spec, var) == :stream_only && continue diff --git a/test/test-multirate-runtime.jl b/test/test-multirate-runtime.jl index cea71a0f4..af5ee6b23 100644 --- a/test/test-multirate-runtime.jl +++ b/test/test-multirate-runtime.jl @@ -198,6 +198,32 @@ function PlantSimEngine.run!(::MRMissingInputConsumerModel, models, status, mete status.OU = status.U end +PlantSimEngine.@process "mrhardchild" verbose = false +struct MRHardChildModel <: AbstractMrhardchildModel end +PlantSimEngine.inputs_(::MRHardChildModel) = NamedTuple() +PlantSimEngine.outputs_(::MRHardChildModel) = (A=-Inf,) +function PlantSimEngine.run!(::MRHardChildModel, models, status, meteo, constants=nothing, extra=nothing) + status.A = 1.0 +end + +PlantSimEngine.@process "mrhardparent" verbose = false +struct MRHardParentModel <: AbstractMrhardparentModel end +PlantSimEngine.dep(::MRHardParentModel) = (mrhardchild=AbstractMrhardchildModel,) +PlantSimEngine.inputs_(::MRHardParentModel) = NamedTuple() +PlantSimEngine.outputs_(::MRHardParentModel) = (A=-Inf,) +function PlantSimEngine.run!(::MRHardParentModel, models, status, meteo, constants=nothing, extra=nothing) + run!(models.mrhardchild, models, status, meteo, constants, extra) + status.A = 5.0 +end + +PlantSimEngine.@process "mrhardconsumer" verbose = false +struct MRHardConsumerModel <: AbstractMrhardconsumerModel end +PlantSimEngine.inputs_(::MRHardConsumerModel) = (A=-Inf,) +PlantSimEngine.outputs_(::MRHardConsumerModel) = (B=-Inf,) +function PlantSimEngine.run!(::MRHardConsumerModel, models, status, meteo, constants=nothing, extra=nothing) + status.B = status.A +end + PlantSimEngine.@process "mrmeteodailyconsumer" verbose = false struct MRMeteoDailyConsumerModel <: AbstractMrmeteodailyconsumerModel end PlantSimEngine.inputs_(::MRMeteoDailyConsumerModel) = NamedTuple() @@ -879,6 +905,54 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = ( @test input_bindings(spec_lineage_infer).Z.process == :mrancestorsource @test input_bindings(spec_lineage_infer).Z.scale == "Plant" + # Expectation 24c: same-rate hard dependencies are ignored for auto bindings and canonical publisher checks. + mapping_hard_same_rate = ModelMapping( + "Leaf" => ( + ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), + ModelSpec(MRHardChildModel()) |> TimeStepModel(1.0), + ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0), + ), + ) + sim_hard_same_rate = PlantSimEngine.GraphSimulation(mtg, mapping_hard_same_rate, nsteps=1, check=true, outputs=Dict("Leaf" => (:A, :B))) + run!(sim_hard_same_rate, meteo, executor=SequentialEx()) + spec_hard_same_rate = PlantSimEngine.get_model_specs(sim_hard_same_rate)["Leaf"][:mrhardconsumer] + @test input_bindings(spec_hard_same_rate).A.process == :mrhardparent + @test status(sim_hard_same_rate)["Leaf"][1].B == 5.0 + + # Expectation 24d: different-rate hard dependencies remain strict and require explicit disambiguation. + mapping_hard_different_rate = ModelMapping( + "Leaf" => ( + ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), + ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0), + ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0), + ), + ) + @test_throws "Ambiguous inferred producer for input `A`" PlantSimEngine.GraphSimulation( + mtg, + mapping_hard_different_rate, + nsteps=1, + check=true, + outputs=Dict("Leaf" => (:A, :B)) + ) + + mapping_hard_different_rate_explicit = ModelMapping( + "Leaf" => ( + ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0), + ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0), + ModelSpec(MRHardConsumerModel()) |> + TimeStepModel(1.0) |> + InputBindings(; A=(process=:mrhardparent, var=:A)), + ), + ) + sim_hard_different_rate_explicit = PlantSimEngine.GraphSimulation( + mtg, + mapping_hard_different_rate_explicit, + nsteps=1, + check=true, + outputs=Dict("Leaf" => (:A, :B)) + ) + @test_throws "Ambiguous canonical publishers" run!(sim_hard_different_rate_explicit, meteo, executor=SequentialEx()) + # Expectation 25: missing producer remains allowed; model can rely on initialized/forced inputs. mapping_missing_input = ModelMapping( "Leaf" => (