To read values from other objects, describe which objects supply the input:
One(...) requires exactly one source object, such as the plant to which a leaf belongs.
OptionalOne(...) allows zero or one source object.
Many(...) supplies a collection, such as the areas of all leaves on a plant, ordered by object ID.
Set var= when the source variable has a different name from the input. Set application= when you need to specify which model application supplies the value, for example when using the same process more than once.
PlantSimEngine shares these values by reference: the receiving model reads the current source values without copying them. A collection with the same value type throughout uses RefVector; one with different types uses ObjectRefVector. These collections are called input carriers in the diagnostics. Use Diagnostics.input_value to read the values, Diagnostics.input_carrier to inspect their container, and Diagnostics.explain_bindings to see where they come from.
Reading the model examples
The examples below show code inside a model. A model stores its parameters in a Julia struct, declares its variables with inputs_ and outputs_, and implements its calculation as a method of PlantSimEngine.run!. PlantSimEngine calls that method for each object selected by ModelSpec.
Inside the method, model provides the parameters and status holds the current object's inputs and outputs. environment provides environmental data, constants supplies shared constants, and context gives access to connected objects and other runtime operations.
The first example shows only this method: MyModel stands for your own model type. Implement a basic model walks through the complete definition and tests.
If a model only needs to sum or multiply the values in a Many input, use the input's status field as usual. If it also needs to know which leaf or other object each value belongs to, call bound_input using context, the information PlantSimEngine passes to each run! call:
function PlantSimEngine.run!(model::MyModel, status, environment, constants, context)
irradiance = bound_input(context, :irradiance)
for (object_id, value) in zip(object_ids(irradiance), irradiance)
# Use object_id and value as one aligned pair.
end
return nothing
endThe returned BoundMany gives access to both values and their object IDs, without copying either. Its values are the same ones available through status.irradiance. Their order follows ObjectId, not the position of an organ on the plant. Use irradiance[ObjectId(:leaf_12)] to read that leaf's value, or an integer index to read a position in the collection.
Iterating over irradiance alone yields values. zip(object_ids(irradiance), irradiance) pairs each object ID with its value as the loop runs, without building a new collection. pairs(irradiance) uses integer positions as keys, so it does not provide object IDs.
Call bound_input each time the model runs. PlantSimEngine may replace the collection after an object is added, removed, or moved to a different parent, so do not store it in your model for later calls.
Some models run once for a scene or plant but compute one value per organ. For example, a light model may calculate illumination for the whole scene and then store each leaf's irradiance on that leaf. Declare the output variables in the model with Distributed, select their destinations in the scenario with outputs_to, and use object IDs to assign each result to the right leaf.
The following is a wrapper skeleton for a solver you supply. Its names and zero defaults illustrate the API; they do not define radiation units or a light calculation. Complete its status inputs, environment inputs, and scientific contracts for the chosen solver before using it:
PlantSimEngine.@process "scene light" verbose = false
struct SceneLightModel{F} <: AbstractScene_LightModel
solve::F
end
PlantSimEngine.inputs_(::SceneLightModel) = NamedTuple()
PlantSimEngine.outputs_(::SceneLightModel) = (
incident_par=Distributed(Default(0.0)),
absorbed_par=Distributed(Default(0.0)),
)
# Declare environment_inputs_ and variable_contracts_ for your solver.
function PlantSimEngine.run!(
model::SceneLightModel,
status,
environment,
constants,
context,
)
targets = output_targets(context, (:incident_par, :absorbed_par))
result = model.solve(
runtime_model(context),
environment,
object_ids(targets),
)
assign_outputs!(targets, result; id=:object_id)
return nothing
endHere solve calls your scene light calculation and returns its results as a table supported by Tables.jl, such as a DataFrame. The table needs an object_id column and one column for each declared output, for example incident_par and absorbed_par. Rows may be in any order: assign_outputs! uses ObjectId to put each result on the right leaf.
Declare the destinations on the scene application:
light_application = ModelSpec(
SceneLightModel(solve_light);
name=:scene_light,
on=One(scale=:Scene),
outputs_to=(
OutputTo(Many(scale=:Leaf, within=SceneScope())),
),
)A single OutputTo with omitted vars binds all the model's Distributed outputs, in declaration order. It is equivalent to:
OutputTo(
Many(scale=:Leaf, within=SceneScope());
vars=(:incident_par, :absorbed_par),
)Ordinary outputs, such as intercepted_total=0.0, remain on the scene's status. This also applies to local arrays, tuples, and other structured values: only Distributed(...) changes the destination. The wrappers are declarations and are never stored in object status. A destination selector may also include the execution object itself, for example to store a plant total alongside organ values. That object receives the variable because it is a selected destination; the declaration adds no implicit local field.
If you supply several OutputTo entries, each must give an explicit, nonempty tuple of variable names. Every distributed variable must occur exactly once; missing, duplicate, unknown, or local variable names are errors before destination initialization. A single declaration cannot infer variables from a model with no distributed outputs. A valid Many selector may currently match no objects and acquire destinations as the plant grows.
Distributed(Default(value)) can initialize a missing destination field. Distributed(Required(T)) requires each destination to have a compatible field already. Neither declaration makes the output optional. A consumer's Required(T) input does not provide an initial value. Existing competing writers still need explicit Updates ordering. Distributed outputs write canonical destination status, so routing a distributed variable with, for example, output_routing=(incident_par=:stream_only,) is rejected.
A kernel can request a subset, such as output_targets(context, (:absorbed_par,)). A combined request exposes exactly the named columns, in the requested order, and requires identical ordered object IDs for all of them. This also works when separate OutputTo entries select the same objects. Different destination sets cause an error at lookup, before the solver runs in the wrapper above. Use separate views only when the calculation supports separate destination sets. Compilation validates variable bindings; it cannot infer arbitrary combined-view requests inside a kernel.
Inside run!, targets.columns.incident_par and targets.columns.absorbed_par give direct access to the selected leaves' values. object_ids(targets) lists their IDs in the same order; this list cannot be modified. Write directly by position only if your calculation already uses that exact order. For a separate result table with its own IDs, use assign_outputs! to match the rows to leaves.
The empty inputs_ above means this skeleton declares no status inputs. If the solver reads areas, optical properties, or other model values, declare those inputs and bind cross-object values explicitly, for example with Many. Access to runtime_model(context) does not replace those declarations.
Likewise, declare every environmental variable the solver reads in environment_inputs_. The unspecified solve_light callback determines these requirements; this example does not assume weather fields, solar position inputs, units, or equations. Keep environment_outputs_ empty when the solver only writes object status. Declare environment outputs and use commit_environment! only when it commits accepted changes to an environment backend. Distributed status writes and environment commits are separate operations.
Use variable_contracts_ with the output variable names to specify units, basis, time meaning, aggregation, and extent. Selectors only choose objects; they do not establish these scientific properties.
The receiving leaf model remains an ordinary PlantSimEngine model:
PlantSimEngine.@process "leaf assimilation" verbose = false
struct LeafAssimilation <: AbstractLeaf_AssimilationModel end
PlantSimEngine.inputs_(::LeafAssimilation) = (
absorbed_par=Required(Float64),
)
PlantSimEngine.outputs_(::LeafAssimilation) = (assimilation=0.0,)
function PlantSimEngine.run!(
::LeafAssimilation,
status,
environment,
constants,
context,
)
# This coefficient is arbitrary and only illustrates value coupling.
status.assimilation = 0.01 * status.absorbed_par
return nothing
end
applications = (
ModelSpec(
LeafAssimilation();
name=:leaf_assimilation,
on=Many(scale=:Leaf),
),
light_application,
)PlantSimEngine knows that :scene_light supplies :absorbed_par for each selected leaf and that :leaf_assimilation needs it. It connects the two and runs the light calculation first, even though the assimilation model appears first in the tuple.
assign_outputs! supports two public forms:
assign_outputs!(targets, result_table; id=:object_id)
assign_outputs!(targets, result_ids, result_columns)The first accepts any row or column table supported by Tables.jl. The second takes an AbstractVector of IDs and a NamedTuple of value columns, which is useful when your solver already returns separate arrays. Both forms use the same rules:
| Result content | Behavior |
|---|---|
| One row for every current destination | Required |
| Unknown, duplicate, extra, or missing IDs | Rejected before any destination value changes |
| Every variable requested in the target view | Required |
| Additional columns such as solver metadata | Ignored |
| Result columns sharing memory with destination columns | Rejected, except assigning a column to itself in exactly the same order |
Only coverage=:exact is supported: you must supply one result for every selected object. If the light solver skips an organ because it has no geometry or has been removed from its scene, decide how to handle it. Either return an appropriate value or exclude that organ from the destination selector. PlantSimEngine will report a missing result instead of keeping an old value or substituting zero.
PlantSimEngine remembers how the result rows match the destination objects. You can reuse the same ID vector to avoid repeating this work, but its IDs and their order must remain unchanged. Update only the value columns. If the objects or their order change, provide a new ID vector. After an object is added, removed, or reparented, PlantSimEngine automatically rebuilds the match on the next call.
When all destination values have the same concrete Julia type, PlantSimEngine uses RefVector columns. With a stable ID order that already matches the destinations, assignments can run without new memory allocations after compilation. If the destinations hold different types, PlantSimEngine uses ObjectRefVector and converts each value to its destination type. Both use the same API; keeping types consistent is preferable when assigning results to many organs.
Call output_targets each time your model runs, just as with bound_input. Do not store the returned OutputTargets in the model: the selected objects may change as the plant grows.