Skip to content

Commit f564bb3

Browse files
committed
Same-rate hard deps: no required explicit InputBindings/OutputRouting
1 parent 93adaae commit f564bb3

5 files changed

Lines changed: 250 additions & 10 deletions

File tree

src/mtg/initialisation.jl

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,20 @@ function init_simulation(mtg, mapping; nsteps=1, outputs=nothing, type_promotion
320320
else
321321
Dict(first(m) => parse_model_specs(last(m)) for m in mapping)
322322
end
323-
scale_reachability = _scale_reachability_from_mtg(mtg)
324-
infer_model_specs_configuration!(model_specs; scale_reachability=scale_reachability)
325-
validate_model_specs_configuration(model_specs)
326323

327324
soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false)
328325

326+
scale_reachability = _scale_reachability_from_mtg(mtg)
327+
_infer_timestep_hints!(model_specs)
328+
ignored_same_rate_hard_children = _same_rate_hard_dependency_children(model_specs, soft_dep_graphs_roots)
329+
active_processes_by_scale = _active_processes_for_inference(model_specs, ignored_same_rate_hard_children)
330+
infer_model_specs_configuration!(
331+
model_specs;
332+
scale_reachability=scale_reachability,
333+
active_processes_by_scale=active_processes_by_scale
334+
)
335+
validate_model_specs_configuration(model_specs)
336+
329337
# Get the status of each node by node type, pre-initialised considering multi-scale variables:
330338
statuses, status_templates, reverse_multiscale_mapping, vars_need_init =
331339
init_statuses(mtg, mapping, soft_dep_graphs_roots; type_promotion=type_promotion, verbose=verbose, check=check)

src/mtg/model_spec_inference.jl

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,135 @@ function _scale_reachability_from_mtg(mtg)
259259
return scale_reachability
260260
end
261261

262-
function _input_candidates_for_var(model_specs, consumer_scale::String, consumer_process::Symbol, input_var::Symbol; scale_reachability=nothing)
262+
function _effective_timestep_spec(spec::ModelSpec)
263+
ts = timestep(spec)
264+
return isnothing(ts) ? timespec(model_(spec)) : ts
265+
end
266+
267+
function _timestep_signature(ts)
268+
if ts isa ClockSpec
269+
return (:clock, float(ts.dt), float(ts.phase))
270+
elseif ts isa Real
271+
return (:step, float(ts), 0.0)
272+
elseif ts isa Dates.FixedPeriod
273+
return (:period, _seconds_from_period(ts), 0.0)
274+
end
275+
return nothing
276+
end
277+
278+
function _same_timestep_signature(sig_a, sig_b)
279+
isnothing(sig_a) && return false
280+
isnothing(sig_b) && return false
281+
282+
if sig_a[1] == :period || sig_b[1] == :period
283+
return sig_a[1] == :period &&
284+
sig_b[1] == :period &&
285+
isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0)
286+
end
287+
288+
phase_a = sig_a[1] == :step ? 0.0 : sig_a[3]
289+
phase_b = sig_b[1] == :step ? 0.0 : sig_b[3]
290+
return isapprox(sig_a[2], sig_b[2]; atol=1.0e-9, rtol=0.0) &&
291+
isapprox(phase_a, phase_b; atol=1.0e-9, rtol=0.0)
292+
end
293+
294+
function _hard_dep_same_rate_as_parent(model_specs, parent_scale::String, parent_process::Symbol, child_scale::String, child_process::Symbol)
295+
parent_scale == child_scale || return false
296+
parent_specs = get(model_specs, parent_scale, nothing)
297+
isnothing(parent_specs) && return false
298+
parent_spec = get(parent_specs, parent_process, nothing)
299+
child_spec = get(parent_specs, child_process, nothing)
300+
isnothing(parent_spec) && return false
301+
isnothing(child_spec) && return false
302+
303+
parent_sig = _timestep_signature(_effective_timestep_spec(parent_spec))
304+
child_sig = _timestep_signature(_effective_timestep_spec(child_spec))
305+
return _same_timestep_signature(parent_sig, child_sig)
306+
end
307+
308+
function _collect_same_rate_hard_dependency_children!(
309+
ignored_processes_by_scale::Dict{String,Set{Symbol}},
310+
model_specs,
311+
parent_scale::String,
312+
parent_process::Symbol,
313+
child::HardDependencyNode
314+
)
315+
if _hard_dep_same_rate_as_parent(model_specs, parent_scale, parent_process, child.scale, child.process)
316+
push!(get!(ignored_processes_by_scale, child.scale, Set{Symbol}()), child.process)
317+
end
318+
319+
for nested in child.children
320+
_collect_same_rate_hard_dependency_children!(
321+
ignored_processes_by_scale,
322+
model_specs,
323+
child.scale,
324+
child.process,
325+
nested
326+
)
327+
end
328+
329+
return nothing
330+
end
331+
332+
function _soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph{Dict{String,Any}})
333+
nodes = SoftDependencyNode[]
334+
for (_, roots_at_scale) in pairs(dep_graph.roots)
335+
haskey(roots_at_scale, :soft_dep_graph) || continue
336+
append!(nodes, values(roots_at_scale[:soft_dep_graph]))
337+
end
338+
return nodes
339+
end
340+
341+
_soft_nodes_for_hard_dependency_analysis(dep_graph::DependencyGraph) = traverse_dependency_graph(dep_graph, false)
342+
343+
function _same_rate_hard_dependency_children(model_specs, dep_graph::DependencyGraph)
344+
ignored_processes_by_scale = Dict{String,Set{Symbol}}()
345+
346+
for soft_node in _soft_nodes_for_hard_dependency_analysis(dep_graph)
347+
for child in soft_node.hard_dependency
348+
_collect_same_rate_hard_dependency_children!(
349+
ignored_processes_by_scale,
350+
model_specs,
351+
soft_node.scale,
352+
soft_node.process,
353+
child
354+
)
355+
end
356+
end
357+
358+
return ignored_processes_by_scale
359+
end
360+
361+
function _active_processes_for_inference(model_specs, ignored_processes_by_scale::Dict{String,Set{Symbol}})
362+
active = Dict{String,Set{Symbol}}()
363+
for (scale, specs_at_scale) in pairs(model_specs)
364+
procs = Set{Symbol}(keys(specs_at_scale))
365+
ignored = get(ignored_processes_by_scale, scale, Set{Symbol}())
366+
for process in ignored
367+
delete!(procs, process)
368+
end
369+
active[scale] = procs
370+
end
371+
return active
372+
end
373+
374+
function _input_candidates_for_var(
375+
model_specs,
376+
consumer_scale::String,
377+
consumer_process::Symbol,
378+
input_var::Symbol;
379+
scale_reachability=nothing,
380+
active_processes_by_scale=nothing
381+
)
263382
same_scale = NamedTuple[]
264383
cross_scale = NamedTuple[]
265384

266385
for (scale, specs_at_scale) in pairs(model_specs)
267386
for (process, spec) in pairs(specs_at_scale)
387+
if !isnothing(active_processes_by_scale)
388+
active = get(active_processes_by_scale, scale, Set{Symbol}())
389+
process in active || continue
390+
end
268391
scale == consumer_scale && process == consumer_process && continue
269392
input_var in keys(outputs_(model_(spec))) || continue
270393
_is_stream_only_output(spec, input_var) && continue
@@ -283,8 +406,22 @@ function _input_candidates_for_var(model_specs, consumer_scale::String, consumer
283406
return same_scale, cross_scale
284407
end
285408

286-
function _infer_input_binding_for_var(model_specs, scale::String, process::Symbol, input_var::Symbol; scale_reachability=nothing)
287-
same_scale, cross_scale = _input_candidates_for_var(model_specs, scale, process, input_var; scale_reachability=scale_reachability)
409+
function _infer_input_binding_for_var(
410+
model_specs,
411+
scale::String,
412+
process::Symbol,
413+
input_var::Symbol;
414+
scale_reachability=nothing,
415+
active_processes_by_scale=nothing
416+
)
417+
same_scale, cross_scale = _input_candidates_for_var(
418+
model_specs,
419+
scale,
420+
process,
421+
input_var;
422+
scale_reachability=scale_reachability,
423+
active_processes_by_scale=active_processes_by_scale
424+
)
288425

289426
if length(same_scale) == 1
290427
c = only(same_scale)
@@ -329,7 +466,7 @@ function _infer_input_binding_for_var(model_specs, scale::String, process::Symbo
329466
return nothing
330467
end
331468

332-
function _infer_input_bindings!(model_specs; scale_reachability=nothing)
469+
function _infer_input_bindings!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing)
333470
for (scale, specs_at_scale) in pairs(model_specs)
334471
# When a scale is absent from the initial MTG, input producer inference at
335472
# init time is unreliable (dynamic growth may introduce it later). Keep
@@ -338,6 +475,10 @@ function _infer_input_bindings!(model_specs; scale_reachability=nothing)
338475
continue
339476
end
340477
for (process, spec) in pairs(specs_at_scale)
478+
if !isnothing(active_processes_by_scale)
479+
active = get(active_processes_by_scale, scale, Set{Symbol}())
480+
process in active || continue
481+
end
341482
current_bindings = input_bindings(spec)
342483
current_bindings isa NamedTuple || continue
343484

@@ -346,7 +487,14 @@ function _infer_input_bindings!(model_specs; scale_reachability=nothing)
346487

347488
for input_var in model_inputs
348489
input_var in keys(current_bindings) && continue
349-
inferred_binding = _infer_input_binding_for_var(model_specs, scale, process, input_var; scale_reachability=scale_reachability)
490+
inferred_binding = _infer_input_binding_for_var(
491+
model_specs,
492+
scale,
493+
process,
494+
input_var;
495+
scale_reachability=scale_reachability,
496+
active_processes_by_scale=active_processes_by_scale
497+
)
350498
isnothing(inferred_binding) && continue
351499
push!(inferred, input_var => inferred_binding)
352500
end
@@ -409,8 +557,12 @@ Fill missing `ModelSpec` fields from inference:
409557
- model-level hint traits (`timestep_hint`, `meteo_hint`)
410558
Explicit `ModelSpec` user values always take precedence over inferred values.
411559
"""
412-
function infer_model_specs_configuration!(model_specs; scale_reachability=nothing)
413-
_infer_input_bindings!(model_specs; scale_reachability=scale_reachability)
560+
function infer_model_specs_configuration!(model_specs; scale_reachability=nothing, active_processes_by_scale=nothing)
561+
_infer_input_bindings!(
562+
model_specs;
563+
scale_reachability=scale_reachability,
564+
active_processes_by_scale=active_processes_by_scale
565+
)
414566
_infer_timestep_hints!(model_specs)
415567
_infer_meteo_hints!(model_specs)
416568
return model_specs

src/time/runtime/output_export.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,12 @@ function _canonical_source_process(sim::GraphSimulation, scale::String, var::Sym
7171
haskey(get_models(sim), scale) || error("Unknown scale `$(scale)` in output export request.")
7272
models_at_scale = get_models(sim)[scale]
7373
specs_at_scale = get_model_specs(sim)[scale]
74+
ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim))
75+
ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}())
7476

7577
publishers = Symbol[]
7678
for (process, model) in pairs(models_at_scale)
79+
process in ignored_at_scale && continue
7780
var in keys(outputs_(model)) || continue
7881
spec = get(specs_at_scale, process, as_model_spec(model))
7982
_publish_mode_for_output(spec, var) == :stream_only && continue

src/time/runtime/publishers.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@ Ensure that each `(scale, variable)` has at most one canonical publisher.
3131
Throws when multiple producers publish the same canonical output.
3232
"""
3333
function validate_canonical_publishers(sim::GraphSimulation)
34+
ignored_same_rate_hard_children = _same_rate_hard_dependency_children(get_model_specs(sim), dep(sim))
3435
for (scale, models_at_scale) in get_models(sim)
3536
specs_at_scale = get_model_specs(sim)[scale]
37+
ignored_at_scale = get(ignored_same_rate_hard_children, scale, Set{Symbol}())
3638
publishers = Dict{Symbol,Vector{Symbol}}()
3739
for (process, model) in pairs(models_at_scale)
40+
process in ignored_at_scale && continue
3841
model_spec = get(specs_at_scale, process, as_model_spec(model))
3942
for var in keys(outputs_(model))
4043
_publish_mode_for_output(model_spec, var) == :stream_only && continue

test/test-multirate-runtime.jl

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,32 @@ function PlantSimEngine.run!(::MRMissingInputConsumerModel, models, status, mete
198198
status.OU = status.U
199199
end
200200

201+
PlantSimEngine.@process "mrhardchild" verbose = false
202+
struct MRHardChildModel <: AbstractMrhardchildModel end
203+
PlantSimEngine.inputs_(::MRHardChildModel) = NamedTuple()
204+
PlantSimEngine.outputs_(::MRHardChildModel) = (A=-Inf,)
205+
function PlantSimEngine.run!(::MRHardChildModel, models, status, meteo, constants=nothing, extra=nothing)
206+
status.A = 1.0
207+
end
208+
209+
PlantSimEngine.@process "mrhardparent" verbose = false
210+
struct MRHardParentModel <: AbstractMrhardparentModel end
211+
PlantSimEngine.dep(::MRHardParentModel) = (mrhardchild=AbstractMrhardchildModel,)
212+
PlantSimEngine.inputs_(::MRHardParentModel) = NamedTuple()
213+
PlantSimEngine.outputs_(::MRHardParentModel) = (A=-Inf,)
214+
function PlantSimEngine.run!(::MRHardParentModel, models, status, meteo, constants=nothing, extra=nothing)
215+
run!(models.mrhardchild, models, status, meteo, constants, extra)
216+
status.A = 5.0
217+
end
218+
219+
PlantSimEngine.@process "mrhardconsumer" verbose = false
220+
struct MRHardConsumerModel <: AbstractMrhardconsumerModel end
221+
PlantSimEngine.inputs_(::MRHardConsumerModel) = (A=-Inf,)
222+
PlantSimEngine.outputs_(::MRHardConsumerModel) = (B=-Inf,)
223+
function PlantSimEngine.run!(::MRHardConsumerModel, models, status, meteo, constants=nothing, extra=nothing)
224+
status.B = status.A
225+
end
226+
201227
PlantSimEngine.@process "mrmeteodailyconsumer" verbose = false
202228
struct MRMeteoDailyConsumerModel <: AbstractMrmeteodailyconsumerModel end
203229
PlantSimEngine.inputs_(::MRMeteoDailyConsumerModel) = NamedTuple()
@@ -879,6 +905,54 @@ PlantSimEngine.meteo_hint(::Type{<:MRMeteoHintConsumerModel}) = (
879905
@test input_bindings(spec_lineage_infer).Z.process == :mrancestorsource
880906
@test input_bindings(spec_lineage_infer).Z.scale == "Plant"
881907

908+
# Expectation 24c: same-rate hard dependencies are ignored for auto bindings and canonical publisher checks.
909+
mapping_hard_same_rate = ModelMapping(
910+
"Leaf" => (
911+
ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0),
912+
ModelSpec(MRHardChildModel()) |> TimeStepModel(1.0),
913+
ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0),
914+
),
915+
)
916+
sim_hard_same_rate = PlantSimEngine.GraphSimulation(mtg, mapping_hard_same_rate, nsteps=1, check=true, outputs=Dict("Leaf" => (:A, :B)))
917+
run!(sim_hard_same_rate, meteo, executor=SequentialEx())
918+
spec_hard_same_rate = PlantSimEngine.get_model_specs(sim_hard_same_rate)["Leaf"][:mrhardconsumer]
919+
@test input_bindings(spec_hard_same_rate).A.process == :mrhardparent
920+
@test status(sim_hard_same_rate)["Leaf"][1].B == 5.0
921+
922+
# Expectation 24d: different-rate hard dependencies remain strict and require explicit disambiguation.
923+
mapping_hard_different_rate = ModelMapping(
924+
"Leaf" => (
925+
ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0),
926+
ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0),
927+
ModelSpec(MRHardConsumerModel()) |> TimeStepModel(1.0),
928+
),
929+
)
930+
@test_throws "Ambiguous inferred producer for input `A`" PlantSimEngine.GraphSimulation(
931+
mtg,
932+
mapping_hard_different_rate,
933+
nsteps=1,
934+
check=true,
935+
outputs=Dict("Leaf" => (:A, :B))
936+
)
937+
938+
mapping_hard_different_rate_explicit = ModelMapping(
939+
"Leaf" => (
940+
ModelSpec(MRHardParentModel()) |> TimeStepModel(1.0),
941+
ModelSpec(MRHardChildModel()) |> TimeStepModel(2.0),
942+
ModelSpec(MRHardConsumerModel()) |>
943+
TimeStepModel(1.0) |>
944+
InputBindings(; A=(process=:mrhardparent, var=:A)),
945+
),
946+
)
947+
sim_hard_different_rate_explicit = PlantSimEngine.GraphSimulation(
948+
mtg,
949+
mapping_hard_different_rate_explicit,
950+
nsteps=1,
951+
check=true,
952+
outputs=Dict("Leaf" => (:A, :B))
953+
)
954+
@test_throws "Ambiguous canonical publishers" run!(sim_hard_different_rate_explicit, meteo, executor=SequentialEx())
955+
882956
# Expectation 25: missing producer remains allowed; model can rely on initialized/forced inputs.
883957
mapping_missing_input = ModelMapping(
884958
"Leaf" => (

0 commit comments

Comments
 (0)