Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c5c68ca
Simple single-variable hacky ad hoc connector test ; as a model it's …
Samuel-amap Oct 16, 2025
a3c8973
Start to implement an orchestrator struct that centralizes timestep i…
Samuel-amap Oct 16, 2025
cb77476
Many changes : new orchestrator attempt, many more fixes. Multiple ti…
Samuel-amap Oct 28, 2025
2d343f6
Addendum : prototype tests moved to a new file, remove them from the …
Samuel-amap Oct 28, 2025
da65ff7
Fix little issues + attempt at addressing main blocker : disconnect t…
Samuel-amap Oct 31, 2025
d964abd
Fix issues with simulation id and models not running properly. Many l…
Samuel-amap Oct 31, 2025
80e12de
Reintroduce error message which I may have unintentionally removed ea…
Samuel-amap Oct 31, 2025
2ee5f85
Fix bug in timestep-mapped variables filtering from var_need_init
Samuel-amap Nov 3, 2025
7045105
Rename test file to conform properly to test file naming convention
Samuel-amap Nov 3, 2025
6f058f9
Remove discarded data structures
Samuel-amap Nov 24, 2025
d3ed73c
Fix case where children weren't being run properly with prior changes
Samuel-amap Nov 24, 2025
569aa05
Update a couple of comments
Samuel-amap Nov 24, 2025
8cf4450
Add a couple of extra tests for mutli timestep mapping
Samuel-amap Nov 26, 2025
ede9431
Fix doctest error
Samuel-amap Nov 26, 2025
b608126
Departure from the previous approach : add timestep data to MultiScal…
Samuel-amap Dec 1, 2025
cba965c
More changes, the new approach now works in some cases, more to inves…
Samuel-amap Dec 2, 2025
b044252
Fix some timestep mapping -related bugs that are caught by more compl…
Samuel-amap Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/Abstract_model_structs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,47 @@ model_(m::AbstractModel) = m
get_models(m::AbstractModel) = [model_(m)] # Get the models of an AbstractModel
# Note: it is returning a vector of models, because in this case the user provided a single model instead of a vector of.
get_status(m::AbstractModel) = nothing
get_mapped_variables(m::AbstractModel) = Pair{Symbol,String}[]
get_mapped_variables(m::AbstractModel) = Pair{Symbol,String}[]


#using Dates
struct TimestepRange
lower_bound::Period
upper_bound::Period
end

# Default, no specified range, meaning the model either doesn't depend on time or uses the simulation's default (eg smallest) timestep
TimestepRange() = TimestepRange(Second(0), Second(0))
# Only a single timestep type possible
TimestepRange(p::Period) = TimestepRange(p, p)

"""
timestep_range_(tsr::TimestepRange)

Return the model's valid range for timesteps (which corresponds to the simulation base timestep in the default case).
"""
function timestep_range_(model::AbstractModel)
return TimestepRange()
end

"""
timestep_valid(tsr::TimestepRange)
"""
timestep_valid(tsr::TimestepRange) = tsr.lower_bound <= tsr.upper_bound

function is_timestep_in_range(tsr::TimestepRange, p::Period)
if !timestep_valid(tsr)
return false
end

# 0 means any timestep is valid, no timestep constraints
if tsr.upper_bound == Second(0)
return true
end

return p >= tsr.lower_bound && p <= tsr.lower_bound
end

# TODO should i set all timestep ranges to default and hope the modeler gets it right or should i force them to write something ?

# TODO properly test on models with default timestep
8 changes: 7 additions & 1 deletion src/PlantSimEngine.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import Statistics
import SHA: sha1

using PlantMeteo
using PlantMeteo.Dates

# UninitializedVar + PreviousTimeStep:
include("variables_wrappers.jl")
Expand Down Expand Up @@ -56,6 +57,9 @@ include("component_models/get_status.jl")
# Transform into a dataframe:
include("dataframe.jl")

# Timesteps. :
include("timestep/timestep_mapping.jl")

# Computing model dependencies:
include("dependencies/soft_dependencies.jl")
include("dependencies/hard_dependencies.jl")
Expand Down Expand Up @@ -102,7 +106,9 @@ include("examples_import.jl")

export PreviousTimeStep
export AbstractModel
export ModelList, MultiScaleModel
export ModelList, MultiScaleModel, TimestepMappedVariable
export MultiScaleMapping
export Orchestrator, TimestepRange, ModelTimestepMapping
export RMSE, NRMSE, EF, dr
export Status, TimeStepTable, status
export init_status!
Expand Down
24 changes: 0 additions & 24 deletions src/dependencies/dependencies.jl
Original file line number Diff line number Diff line change
Expand Up @@ -99,27 +99,3 @@ end
function dep(m::NamedTuple, nsteps=1; verbose::Bool=true)
dep(nsteps; verbose=verbose, m...)
end

function dep(mapping::Dict{String,T}; verbose::Bool=true) where {T}
# First step, get the hard-dependency graph and create SoftDependencyNodes for each hard-dependency root. In other word, we want
# only the nodes that are not hard-dependency of other nodes. These nodes are taken as roots for the soft-dependency graph because they
# are independant.
soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=verbose)
# Second step, compute the soft-dependency graph between SoftDependencyNodes computed in the first step. To do so, we search the
# inputs of each process into the outputs of the other processes, at the same scale, but also between scales. Then we keep only the
# nodes that have no soft-dependencies, and we set them as root nodes of the soft-dependency graph. The other nodes are set as children
# of the nodes that they depend on.
dep_graph = soft_dependencies_multiscale(soft_dep_graphs_roots, mapping, hard_dep_dict)
# During the building of the soft-dependency graph, we identified the inputs and outputs of each dependency node,
# and also defined **inputs** as MappedVar if they are multiscale, i.e. if they take their values from another scale.
# What we are missing is that we need to also define **outputs** as multiscale if they are needed by another scale.

# Checking that the graph is acyclic:
iscyclic, cycle_vec = is_graph_cyclic(dep_graph; warn=false)
# Note: we could do that in `soft_dependencies_multiscale` but we prefer to keep the function as simple as possible, and
# usable on its own.

iscyclic && error("Cyclic dependency detected in the graph. Cycle: \n $(print_cycle(cycle_vec)) \n You can break the cycle using the `PreviousTimeStep` variable in the mapping.")
# Third step, we identify which
return dep_graph
end
98 changes: 16 additions & 82 deletions src/dependencies/dependency_graph.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,35 @@ mutable struct HardDependencyNode{T} <: AbstractDependencyNode
dependency::NamedTuple
missing_dependency::Vector{Int}
scale::String
inputs
outputs
#inputs
#outputs
parent::Union{Nothing,<:AbstractDependencyNode}
children::Vector{HardDependencyNode}
end

mutable struct TimestepMapping
variable_from::Symbol
variable_to::Symbol
timestep_to::Period
mapping_function::Function
mapping_data_template
mapping_data::Dict{Int, Any} # TODO fix type stability : Int is the node id, Any is a vector of n elements of the variable's type, n being the # of required timesteps
end

# can hard dependency nodes also handle timestep mapped variables... ?
mutable struct SoftDependencyNode{T} <: AbstractDependencyNode
value::T
process::Symbol
scale::String
inputs
outputs
#inputs
#outputs
hard_dependency::Vector{HardDependencyNode}
parent::Union{Nothing,Vector{SoftDependencyNode}}
parent_vars::Union{Nothing,NamedTuple}
children::Vector{SoftDependencyNode}
simulation_id::Vector{Int} # id of the simulation
timestep::Period
timestep_mapping_data::Union{Nothing, Vector{TimestepMapping}} # TODO : this approach might not play too well with parallelisation over MTG nodes
end

# Add methods to check if a node is parallelizable:
Expand Down Expand Up @@ -77,82 +89,4 @@ function Base.show(io::IO, ::MIME"text/plain", t::DependencyGraph)
else
draw_dependency_graph(io, t)
end
end

"""
variables_multiscale(node, organ, mapping, st=NamedTuple())

Get the variables of a HardDependencyNode, taking into account the multiscale mapping, *i.e.*
defining variables as `MappedVar` if they are mapped to another scale. The default values are
taken from the model if not given by the user (`st`), and are marked as `UninitializedVar` if
they are inputs of the node.

Return a NamedTuple with the variables and their default values.

# Arguments

- `node::HardDependencyNode`: the node to get the variables from.
- `organ::String`: the organ type, *e.g.* "Leaf".
- `vars_mapping::Dict{String,T}`: the mapping of the models (see details below).
- `st::NamedTuple`: an optional named tuple with default values for the variables.

# Details

The `vars_mapping` is a dictionary with the organ type as key and a dictionary as value. It is
computed from the user mapping like so:
"""
function variables_multiscale(node, organ, vars_mapping, st=NamedTuple())
node_vars = variables(node) # e.g. (inputs = (:var1=-Inf, :var2=-Inf), outputs = (:var3=-Inf,))
ins = node_vars.inputs
ins_variables = keys(ins)
outs_variables = keys(node_vars.outputs)
defaults = merge(node_vars...)
map((inputs=ins_variables, outputs=outs_variables)) do vars # Map over vars from :inputs and vars from :outputs
vars_ = Vector{Pair{Symbol,Any}}()
for var in vars # e.g. var = :carbon_biomass
if var in keys(st)
#If the user has given a status, we use it as default value.
default = st[var]
elseif var in ins_variables
# Otherwise, we use the default value given by the model:
# If the variable is an input, we mark it as uninitialized:
default = UninitializedVar(var, defaults[var])
else
# If the variable is an output, we use the default value given by the model:
default = defaults[var]
end

if haskey(vars_mapping[organ], var)
organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][var])
push!(vars_, var => MappedVar(organ_mapped, var, organ_mapped_var, default))
#* We still check if the variable also exists wrapped in PreviousTimeStep, because one model could use the current
#* values, and another one the previous values.
if haskey(vars_mapping[organ], PreviousTimeStep(var, node.process))
organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][PreviousTimeStep(var, node.process)])
push!(vars_, var => MappedVar(organ_mapped, PreviousTimeStep(var, node.process), organ_mapped_var, default))
end
elseif haskey(vars_mapping[organ], PreviousTimeStep(var, node.process))
# If not found in the current time step, we check if the variable is mapped to the previous time step:
organ_mapped, organ_mapped_var = _node_mapping(vars_mapping[organ][PreviousTimeStep(var, node.process)])
push!(vars_, var => MappedVar(organ_mapped, PreviousTimeStep(var, node.process), organ_mapped_var, default))
else
# Else we take the default value:
push!(vars_, var => default)
end
end
return (; vars_...,)
end
end

function _node_mapping(var_mapping::Pair{String,Symbol})
# One organ is mapped to the variable:
return SingleNodeMapping(first(var_mapping)), last(var_mapping)
end

function _node_mapping(var_mapping)
# Several organs are mapped to the variable:
organ_mapped = MultiNodeMapping([first(i) for i in var_mapping])
organ_mapped_var = [last(i) for i in var_mapping]

return organ_mapped, organ_mapped_var
end
Loading
Loading