This file is the maintainer-facing summary of how PlantSimEngine works internally. It is meant for humans and coding agents making changes to the package.
PlantSimEngine is a Julia engine for composing process models on either:
- a single shared status (
ModelMapping{SingleScale}/ legacyModelList) - a multiscale MTG scene (
GraphSimulation)
The package is built around four ideas:
- Models declare
inputs_,outputs_, and optionallydep. - The engine compiles a dependency graph from those declarations.
- Runtime state is reference-based (
Status,RefVector), so coupling is often aliasing, not copying. - Multiscale and multirate configuration can change where an input comes from, how it is transported, and when it is sampled.
- Single-scale process composition with automatic soft-dependency inference.
- Hard dependencies declared explicitly and called manually from model code.
- MTG-based multiscale simulations with cross-scale variable mappings.
- Cross-scale scalar sharing through shared
Refs. - Cross-scale multi-node sharing through
RefVectors. - Cross-scale writes, where a variable computed at one scale is materialized as an input at another scale.
- Same-scale variable aliasing and renaming.
- Cycle breaking through
PreviousTimeStep. - Multi-rate execution through
ModelSpec,ClockSpec, and temporal policies. - Explicit or inferred
InputBindingsbetween producers and consumers. - Meteo resampling/aggregation per model in multi-rate MTG runs.
- Output routing (
:canonicalvs:stream_only) and online output export (OutputRequest). - Parallel single-scale execution when model traits allow it.
- All models subtype
AbstractModel. @processcreates an abstract process type such asAbstractGrowthModel.- Process identity comes from the abstract process type, not the concrete model name.
- The model execution contract is:
PlantSimEngine.run!(model, models, status, meteo, constants, extra)inputs_(model)andoutputs_(model)are the authoritative declarations.variables(model)ismerge(inputs_(model), outputs_(model)).- Do not rely on a variable being both an input and an output under the same name:
mergemeans the later declaration wins.
Statusis a wrapper around aNamedTupleofRefs.- Reading a field dereferences it. Writing a field mutates the underlying
Ref. - This aliasing behavior is intentional and is the basis of most coupling.
- In single-scale runs, vector-valued user inputs are flattened to one timestep value and updated per timestep with
set_variables_at_timestep!.
RefVectoris anAbstractVectorofBase.RefValues.- It is used when one model input must see a vector of references coming from many statuses.
- Reading a
RefVectordereferences each underlying status cell. - Writing into a
RefVectormutates the source statuses. RefVectororder follows MTG traversal order during initialization, not a semantic plant order.
MultiScaleModelwraps one model plus a multiscale mapping declaration.ModelSpecwraps one model plus scenario-level runtime configuration:multiscale,timestep,input_bindings,meteo_bindings,meteo_window,output_routing, andscope.ModelMappingis the normalized mapping container used by current entry points.- Legacy
ModelListstill exists, but it is compatibility plumbing and should not be treated as the main abstraction for new work.
DependencyGraphholds root dependency nodes plus unresolved dependencies.GraphSimulationholds the MTG, statuses, status templates, reverse mappings, dependency graph, models, model specs, outputs, and temporal state.
- Hard dependencies are declared with
dep(::ModelType). - A hard dependency means: "this model directly calls another model from inside its own
run!implementation." - Hard dependencies are represented by
HardDependencyNode. - They are executed manually by the parent model. The runtime does not automatically recurse into hard dependencies.
- Hard dependencies can be same-scale or explicitly multiscale.
Important nuance:
- A hard dependency does not become an independent soft-dependency node under the parent.
- But it still matters for graph construction, because the graph compiler aggregates the root model's hard-dependency subtree when computing that root's effective inputs and outputs.
- In multiscale graph building, if another model depends on a process that exists only as a nested hard dependency, the code resolves that dependency back to the master soft node that owns that hard subtree.
So "hard dependencies do not directly participate in the soft graph" is true for execution structure, but false if interpreted as "their IO is irrelevant to graph compilation."
- Soft dependencies are inferred by matching model inputs against outputs.
- Matching is name-based after variable flattening, not based on a richer semantic contract.
- Same-scale soft dependencies are built after hard-dependency trees are known.
- A process cannot also list one of its hard dependencies as a soft dependency.
PreviousTimeStepvariables are removed from current-step soft dependency inference.- Soft dependencies are represented by
SoftDependencyNode. - A soft node may have multiple parents.
- A node is considered runnable once all of its parent nodes have already run for the current traversal.
- If no producer output matches an input, no soft edge is added. Soft-edge construction does not itself fail on missing producers.
Single-scale graph construction is:
- Build
HardDependencyNodes for each declared process. - Attach explicit hard-dependency children under their parents.
- Traverse each hard-dependency root and collect its effective inputs and outputs.
- Build one
SoftDependencyNodeper hard-dependency root. - Infer parent and child links by matching inputs to outputs.
Multiscale graph construction is more involved:
- Normalize the user mapping into
ModelMapping. - Build per-scale hard-dependency graphs.
- Resolve multiscale hard dependencies declared across scales.
- Compute per-scale effective inputs and outputs for each hard-dependency root.
- Build one
SoftDependencyNodeper root process per scale. - Compile mapped variables and reverse mappings.
- Infer same-scale soft dependencies.
- Infer cross-scale soft dependencies from mapped variables and reverse mappings.
- If a dependency points to a nested hard dependency, redirect it to the owning soft node.
- Check the final graph for cycles.
- The graph is expected to be acyclic.
- The official way to break a same-step cycle is
PreviousTimeStep. PreviousTimeStepbreaks cycles by suppressing current-step edge creation, not by adding special scheduler logic.- In multiscale runs, cycle detection happens after the cross-scale graph is assembled.
- Single-scale
dep(...)relies mostly on builder-time guards. Multiscaledep(mapping)also runs an explicit global cycle check on the final soft graph.
PlantSimEngine distinguishes three mapping modes:
SingleNodeMapping(scale): one scalar value is read from one source scale.MultiNodeMapping(scales): one input reads a vector of values from many source nodes.SelfNodeMapping(): a source scale must expose a scalar reference to itself so other scales can share it.
The runtime carrier is MappedVar, which stores:
- the mapping mode
- the local variable name
- the source variable name
- the resolved default value
These are the important user-level forms and what they become internally:
| User form | Meaning | Runtime shape |
|---|---|---|
:x => :Plant |
scalar read from one :Plant node |
shared Ref |
:x => (:Plant => :y) |
scalar read with renaming | shared Ref |
:x => [:Leaf] |
vector read from all :Leaf nodes |
RefVector |
:x => [:Leaf, :Internode] |
vector read from several scales | RefVector |
:x => [:Leaf => :a, :Internode => :b] |
vector read with per-scale renaming | RefVector |
PreviousTimeStep(:x) => ... |
lagged mapping, excluded from same-step dependency build | lagged input |
PreviousTimeStep(:x) |
pure cycle-breaking marker | local/default value |
:x => (Symbol(\"\") => :y) |
same-scale rename | RefVariable alias |
mapped_variables(...) does not just mirror user syntax. It compiles it.
The main passes are:
- Start from effective per-scale inputs and outputs collected from hard-dependency roots.
- Add variables that are outputs of one scale but must appear as inputs at another scale.
- Convert scalar cross-scale reads into self-mapped outputs on the source scale so one shared
Refexists. - Resolve default values recursively back to the ultimate producer.
- Convert mapping descriptors into runtime carriers:
- scalar mappings become shared
Refs - multi-node mappings become empty
RefVectors - same-scale renames become
RefVariable
- scalar mappings become shared
- Reverse mapping is computed before the reference conversion pass.
- Reverse mapping answers: "when a source node is initialized, which target scale/vector inputs should receive a reference to this source variable?"
- Reverse mapping excludes scalar
SingleNodeMappingedges whenall=false, because scalar sharing is already handled by sharedRefs.
During init_node_status!:
- A copy of the scale template is made.
:node => Ref(node)is injected.- Remaining uninitialized variables may be filled from MTG attributes.
- The template becomes a
Status. - The status is pushed into
statuses[scale]. - If this node feeds any downstream
RefVector, itsRefs are pushed into those target vectors. - The status is stored on the MTG node under
:plantsimengine_status.
- MTG attribute initialization copies plain values into the status.
- If the MTG attribute itself is already a
Ref, thatRefis preserved. - The runtime cannot create a live reference directly into a dict-backed MTG attribute.
- Cross-scale sharing is reference-based once the status exists.
Multi-rate behavior is layered on top of the multiscale MTG runtime.
timespec(model)defines the model's default clock. The default isClockSpec(1.0, 0.0).ModelSpec.timestepcan override runtime clock selection.output_policy(model)declares per-output temporal policy defaults.
Supported schedule policies are:
HoldLast(): use the latest available producer value.Interpolate(): interpolate or hold/extrapolate producer streams.Integrate(): reduce values over the consumer window, default reducer isSumReducer().Aggregate(): reduce values over the consumer window, default reducer isMeanReducer().
ModelSpec is the configuration point for scenario-specific runtime behavior.
It can define:
multiscale: mapping declarationtimestep: runtime clockinput_bindings: explicit producer selection for consumer inputsmeteo_bindings: per-model weather aggregationmeteo_window: weather window selection strategyoutput_routing::canonicalor:stream_onlyscope::global,:self,:plant,:scene,ScopeId, or callable
- If explicit
InputBindingsare absent, the package tries to infer bindings from the dependency graph and mapping. - Unique same-scale producers win first.
- Unique cross-scale producers are accepted when unambiguous.
- Existing multiscale mapping hints can disambiguate some cross-scale cases.
- Ambiguity is an error and must be resolved explicitly.
For each dependency node and each status at that node's scale:
- Decide whether the model should run at the current time according to its clock.
- Resolve consumer inputs from temporal state with explicit or inferred bindings.
- Sample or aggregate meteo for the model.
- Call the model's
run!. - Publish outputs back into temporal caches and streams.
- Materialize any requested online exports.
Important consequences:
- In non-multirate MTG runs, cross-scale coupling is mostly direct aliasing through shared refs.
- In multirate MTG runs, temporal state can overwrite consumer inputs just before execution.
- Multi-rate MTG runs are currently forced to sequential execution.
A variable seen by a model may be in any of these supported configurations:
- Plain local status value initialized by the user.
- Plain local status value initialized from MTG node attributes.
- Output computed locally at the same scale.
- Same-scale alias of another local variable through
RefVariable. - Scalar value mapped from another scale through a shared
Ref. - Vector of references mapped from one or many other scales through
RefVector. - Output computed at one scale and written into another scale, which means it is injected as an input on the receiving scale during mapping compilation.
- Value marked as
PreviousTimeStep, which removes it from same-step dependency inference. - Input resolved from a hard dependency that is called manually inside another model.
- Input resolved from temporal streams instead of directly from the current status value.
- Input bound explicitly with
InputBindings. - Input bound implicitly by inference from producers and mappings.
- Input sampled with
HoldLast,Interpolate,Integrate, orAggregate. - Output published canonically into status state.
- Output published as
:stream_only, meaning it participates in temporal streams but not canonical output ownership. - Value partitioned by scope (
:global,:self,:plant,:scene, or custom scope function).
When changing dependency, mapping, or runtime code, assume all of these modes can exist in the same simulation.
- Soft-dependency order controls model order. MTG topology does not define execution order within a scale.
- Within one scale, execution order follows the order of
statuses[scale], which comes from MTG traversal at initialization time. SingleNodeMappingassumes the source node is unique at runtime. The mapping layer does not enforce uniqueness.RefVectorordering is traversal order, not a guaranteed biological ordering.- Hard dependencies are manual calls. If model code stops calling them, the declared hard dependency no longer executes.
- Hard dependencies still influence graph compilation through their effective inputs and outputs.
- Multiscale redirection from nested hard dependencies back to the owning soft node is implemented with upward walking through parent links and a defensive depth guard. Treat that path as fragile.
- MTG topology changes after
init_statusesleavestatuses, node attributes, and populatedRefVectors stale. Reinitialize after topology changes. - Same-scale renaming does not create a graph-wide shared ref. It creates a per-status alias.
parent_varsis dependency metadata, not a full provenance graph, and in multiscale builds it can be overwritten when a node has both same-scale and cross-scale parents.- Duplicate canonical publishers for one
(scale, variable)are invalid in multi-rate mode unless non-canonical producers are marked:stream_only. - User
extraarguments are not allowed in MTG runs becauseGraphSimulationalready occupies that slot. - String scale names still work in many places but are deprecated. Prefer
Symbolscales. ModelListis deprecated as the primary API. PreferModelMapping.run_node_multiscale!currently usesnode.simulation_id[1]as the visitation guard. Treat that code carefully if you touch traversal semantics.- Some variable collection helpers use set-like flattening, so collection order is not always stable. Do not attach semantics to incidental variable ordering.
src/PlantSimEngine.jl: module layout and exports.src/Abstract_model_structs.jl:AbstractModelandprocess.src/processes/process_generation.jl:@process.src/processes/models_inputs_outputs.jl: model declarations and runtime traits.src/variables_wrappers.jl:UninitializedVar,PreviousTimeStep,RefVariable.src/component_models/Status.jl: reference-based status container.src/component_models/RefVector.jl: vector of references.src/dependencies/*: hard and soft dependency graph construction and traversal.src/mtg/MultiScaleModel.jl: mapping syntax normalization.src/mtg/ModelSpec.jl: runtime configuration wrapper.src/mtg/mapping/*: mapping compilation, reverse mapping, initialization helpers.src/mtg/initialisation.jl: status creation and MTG wiring.src/mtg/GraphSimulation.jl: simulation wrapper.src/time/multirate.jl: clocks, policies, temporal storage types.src/time/runtime/*: input resolution, scopes, publishers, meteo sampling, output export.src/run.jl: single-scale and multiscale execution.
If you change dependency, mapping, or runtime behavior, re-check all of these questions:
- Does it still work for both single-scale and MTG runs?
- Does it preserve aliasing semantics for
StatusandRefVector? - Does it preserve the distinction between hard dependencies and soft dependencies?
- Does it still handle scalar mappings, vector mappings, same-scale aliasing, and cross-scale writes?
- Does it still behave correctly with
PreviousTimeStep? - Does it still work when input bindings are inferred instead of explicit?
- Does it still work in multi-rate mode with temporal policies and scoped streams?
- Does it remain correct if the producer is nested under a hard dependency?