PlantSimEngine.jlPlantSimEngine.jl

Migrating To The CompositeModel/Object API​#

Use this page when updating code written for an older PlantSimEngine API. It shows how earlier model mappings, input connections, and output requests are written with CompositeModel and Object. If you are starting a new simulation, follow the first tutorial.

Refining early CompositeModel/Object code​#

Some names and defaults changed during development of the CompositeModel/Object API. Update early examples as follows:

Early spelling or behaviorCurrent API
Self() searched self and descendantsSelf() selects only the current object; use Subtree() for self plus descendants
omitted tracked_outputs retained everythinguse explicit outputs=:all; the safe default is outputs=:none
tracked_outputs=requestsoutputs=requests
OutputRequest(:Leaf, :x; process=:p)OutputRequest(Many(scale=:Leaf), :x; application=:app)
repeated unnamed applications gained numbered IDsname every repeated application with ModelSpec(...; name=...)
calling run!(model) again implicitly looked like continuationuse continue!(simulation) or step!(simulation)
compiler/cache types imported from the default namespacequalify them through PlantSimEngine.Advanced

tracked_outputs has been removed. Use outputs=:all, outputs=:none, or outputs=requests directly. Singular scenario inputs and calls, OutputRequest, object overrides, and Updates(...; after=...) now identify the model application with application=... or its application ID. Model-authored Input/Call defaults may still discover a process because they cannot know scenario application names, and Many(process=...) remains an explicit multi-application discovery query.

Calling run!(model; ...) always creates a fresh result timeline starting at step one, even when object status has already been mutated by an earlier run. Continue the same timeline, environment position, temporal histories, and multirate phase with:

julia
simulation = run!(model; steps=24, outputs=requests)
continue!(simulation; steps=24)
step!(simulation)
@assert current_step(simulation) == 49

The new API stores the simulated objects and their model applications in one CompositeModel, replacing the earlier multiscale mappings.

New scenario code should be organized around:

julia
CompositeModel
Object
ModelSpec
Updates
Environment

The equations stay in a model's run! function. Models that only calculate values can be reused without referring to the simulation's plant structure:

julia
inputs_(model)
outputs_(model)
dep(model)
environment_inputs_(model)
run!(model, status, environment, constants, context)

This page maps the legacy configuration concepts to their composite-model/object equivalents.

Explicit Input Initialization​#

For each input, say whether a value must be supplied or whether the model can use a default:

julia
# Old, ambiguous
inputs_(::GrowthModel) = (
    temperature=-Inf,
    efficiency=0.8,
)

# Current
inputs_(::GrowthModel) = (
    temperature=Required(Float64),
    efficiency=Default(0.8),
)

Required(T) means object state or another application must supply the value. Default(value) means PlantSimEngine may initialize it when absent. Output literals remain initial output-state values. Plain input literals are rejected; there is no compatibility interpretation of -Inf or another sentinel.

Replace helpers that previously merged every input literal into initial state with init_variables(model). It returns only real input defaults and output initial values, omitting required inputs.

Scenario Structure​#

Legacy simulations split configuration between ModelMapping and MultiScaleModel. The unified API stores runtime entities in one CompositeModel:

ModelMapping has been removed. Historical code must be translated to the composite-model/object form below.

julia
model = CompositeModel(
    Object(:scene; scale=:Scene, kind=:scene),
    Object(:plant_1; scale=:Plant, kind=:plant, parent=:scene),
    Object(:leaf_1; scale=:Leaf, kind=:plant, parent=:plant_1),
    Object(:soil; scale=:Soil, kind=:soil, parent=:scene);
    applications=(
        ModelSpec(LeafModel(); name=:leaf_model, on=Many(scale=:Leaf)),

        ModelSpec(SoilModel(); name=:soil_model, on=One(scale=:Soil)),
    ),
    environment=(T=25.0, Rh=0.6, Wind=1.0),
)

Object labels describe the parts of the simulated system. You choose how these parts are connected. A plant may use any hierarchy of plants, axes, internodes, segments, leaves, roots, fruits, or application-specific objects.

Status type conversion​#

Some historical ModelMapping configurations changed the representation of all matching status values, for example from Float64 to Float32. Keep that scenario policy on the modern CompositeModel; do not recreate or wrap ModelMapping:

julia
model = CompositeModel(
    objects...;
    applications=applications,
    environment=environment,
    type_promotion=Dict(Float64 => Float32),
)

Use status_transform when the conversion depends on the variable rather than only its current type:

julia
model = CompositeModel(
    objects...;
    applications=applications,
    type_promotion=Dict(Float64 => Float32),
    status_transform=(variable, value) ->
        variable === :uncertain_input ? uncertain(value) : value,
)

The variable-specific transform runs before the general type mapping. Ordinary numeric arrays are mapped element by element. The policy applies to supplied statuses, model input and output defaults, and objects registered later; it does not change model parameters or environment values.

Existing MTG Topologies​#

An existing MTG can be adapted without rebuilding its topology manually:

julia
model = CompositeModel(
    mtg;
    applications=applications,
    environment=environment,
    kind=node_kind,
    species=node_species,
    geometry=node_geometry,
)

objects_from_mtg(mtg; ...) exposes the intermediate object list when it is useful to inspect or modify labels before constructing the model. By default, the adapter uses MTG node ids and scales. Runtime Status values belong to the CompositeModel registry and are never stored in MTG attributes. To import values from node attributes, supply a function such as status=node -> import_status(node) explicitly. Set the starting values used in the simulation on its objects or through its model applications.

Multiscale Inputs​#

Replace MultiScaleModel(...) variable mappings with consumer-side ModelSpec(...; inputs=...).

Legacy:

julia
MultiScaleModel(
    AllocationModel(),
    [:leaf_carbon => [:Leaf => :leaf_carbon]],
)

Unified:

julia
ModelSpec(
    AllocationModel();
    name=:allocation,
    on=Many(scale=:Plant),
    inputs=(
        :leaf_carbon => Many(
            scale=:Leaf,
            within=Subtree(),
            var=:leaf_carbon,
        ),
    ),
)

Self() selects only the object where the consumer runs. A plant-scale allocation model uses Subtree() to read leaves below that plant. Use SceneScope() for model-wide aggregation and SelfPlant() to select the nearest containing plant and its subtree from an organ.

Same-object renaming uses the same syntax:

julia
ModelSpec(
    ConsumerModel();
    inputs=(
        :consumer_name => One(
            within=Self(),
            application=:producer,
            var=:producer_name,
        ),
    ),
)

When models run at the same rate, their inputs can read the source objects' current values through shared references. When rates differ, PlantSimEngine uses saved output histories and your chosen rule for reading them.

CompositeModel-Wide Values​#

Use an input selector on the consuming application:

julia
ModelSpec(SceneWaterBalance(); name=:scene_water, on=One(scale=:Scene), inputs=(:leaf_transpiration => Many(
            kind=:plant,
            scale=:Leaf,
            within=SceneScope(),
            application=:transpiration,
            var=:transpiration,
        ),))

Specify which objects supply the value, the source variable's name, and how to read it over time. PlantSimEngine chooses how to store the connection.

Manual Hard Calls​#

Use ModelSpec(...; calls=...) when a parent model must control child execution.

julia
ModelSpec(SceneEnergyBalance(); name=:scene_energy, on=One(scale=:Scene), calls=(:leaf_energy => Many(
            kind=:plant,
            scale=:Leaf,
            within=SceneScope(),
            application=:energy_balance,
        ),
        :soil => One(
            kind=:soil,
            scale=:Soil,
            within=SceneScope(),
            application=:soil_water,
        ),))

The parent model controls execution:

julia
function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment,
                             constants, context)
    for iteration in 1:model.max_iterations
        trial = trial_environment(model, status)
        run_call!(context, :leaf_energy; environment=trial, publish=false)
        converged(model, status) && break
    end

    accepted = accepted_environment(model, status)
    commit_environment!(context, accepted)
    run_call!(context, :leaf_energy; publish=true)
    return nothing
end

run_call! uses publish=false by default. A trial changes the called objects' current values, but does not add samples to their output histories or save updates to the shared environment. Use publish=true to record the accepted result, and commit_environment! to save accepted environment changes.

Multiple Plants And Species​#

Represent repeated plant configurations with CompositeModelTemplate and ObjectInstance.

julia
oil_palm = CompositeModelTemplate(
    (
        ModelSpec(LeafEnergy(); on=Many(scale=:Leaf)),

        ModelSpec(Allocation(); on=One(scale=:Plant), inputs=(:leaf_carbon => Many(
                scale=:Leaf,
                within=Subtree(),
                var=:leaf_carbon,
            ),)),
    );
    kind=:plant,
    species=:oil_palm,
)

palm_1 = ObjectInstance(
    :palm_1,
    oil_palm;
    root=Object(:plant_1; scale=:Plant, parent=:scene),
    objects=(Object(:palm_1_leaf_1; scale=:Leaf, parent=:plant_1),),
)

palm_2 = ObjectInstance(
    :palm_2,
    oil_palm;
    root=Object(:plant_2; scale=:Plant, parent=:scene),
    objects=(Object(:palm_2_leaf_1; scale=:Leaf, parent=:plant_2),),
)

model = CompositeModel(
    Object(:scene; scale=:Scene, kind=:scene),
    palm_1,
    palm_2,
)

Unmodified instances share model objects and parameters. Use instance overrides for one plant and Override(...) for exceptional organs.

Multirate Inputs​#

Replace TimeStepModel(...) with ModelSpec(...; every=...). Put temporal policy and window information on the consuming ModelSpec(...; inputs=...) selector.

julia
ModelSpec(HourlyLeafModel(); name=:leaf_flux, on=Many(scale=:Leaf), every=Hour(1))

ModelSpec(DailyPlantModel(); name=:daily_plant, on=Many(scale=:Plant), inputs=(:leaf_fluxes => Many(
            scale=:Leaf,
            within=Subtree(),
            application=:leaf_flux,
            var=:flux,
            policy=Integrate(),
            window=Day(1),
        ),), every=Day(1))

Use HoldLast(), Interpolate(), Integrate(), or Aggregate() according to the physical meaning of the input. PreviousTimeStep(:x) => selector expresses an explicit lag and breaks a same-timestep dependency cycle.

If the input selector omits policy=..., the model compiler uses the producer's output_policy(...) trait for the selected source variable when the publisher is unique. An explicit selector policy always wins over the trait.

If a model defines timespec(::Type{<:MyModel}), the model scheduler uses that cadence when the application has no explicit ModelSpec(...; every=...). A scenario-level ModelSpec(...; every=...) always wins over the model trait.

If the clock falls back to the model base step, timestep_hint(...) required bounds are validated against that base step. The hint is a compatibility constraint, not a scheduling override.

Ordered Variable Updates​#

When several models intentionally write the same variable, declare the order on the later application:

julia
ModelSpec(CarbonAllocation(); name=:allocation, on=Many(scale=:Leaf))

ModelSpec(LeafPruning(); name=:pruning, on=Many(scale=:Leaf), updates=Updates(:leaf_biomass; after=:allocation))

Do not encode this coupling in either model implementation. The scenario owns the writer order.

Environment And Microclimate​#

Models declare sampled environment variables with environment_inputs_. The scenario binds each object/application to the active environment backend:

julia
ModelSpec(LeafEnergy(); name=:leaf_energy, on=Many(scale=:Leaf), environment=Environment(provider=:grid))

Spatial bindings are cached. The default resolver uses the object's geometry, then the nearest ancestor geometry, then the backend's global behavior. Changing geometry invalidates only affected environment bindings.

Per-model source remapping also moves to Environment(...):

julia
ModelSpec(LeafGasExchange(); name=:gas_exchange, on=Many(scale=:Leaf), environment=Environment(provider=:global, sources=(CO2=:Ca,)))

The model still declares and reads CO2; the model samples Ca from the active environment backend and exposes it to the model as environment.CO2. Diagnostics.explain_environment_bindings(...) reports both required_inputs and source_inputs, so remapped meteorology is visible to users and agents.

Model authors can also provide default source remaps with environment_hint(::Type{<:Model}) = (bindings=(CO2=(source=:Ca,),),). CompositeModel applications use those defaults when the scenario does not provide an explicit source for the same variable. Environment(; sources=...) remains the scenario-level override.

For global Weather tables, sampling follows the application's ModelSpec(...; every=...). A slower model receives a PlantMeteo windowed sample using its environment_hint reducer and window instead of receiving only the current raw weather row. A scenario source override preserves that reducer:

julia
environment_hint(::Type{<:GasExchange}) = (
    bindings=(CO2=(source=:Ca, reducer=MeanReducer()),),
)

ModelSpec(GasExchange(); on=Many(scale=:Leaf), every=Hour(2), environment=Environment(provider=:global, sources=(CO2=:canopy_CO2,)))

Every leaf still reads environment.CO2; the two-hour mean is computed from :canopy_CO2. The sampled row is computed once per application and timestep, then reused for all selected leaves.

Growth, Pruning, And Movement​#

Use the public lifecycle operations:

julia
register_object!(model, new_leaf; parent=:plant_1)
new_leaf_status = add_organ!(
    parent_node,
    model,
    :+,
    :Leaf,
    3;
    initial_status=(biomass=0.0,),
)
remove_object!(model, :old_leaf)
reparent_object!(model, :leaf_2, :axis_3)
move_object!(model, :leaf_3, new_geometry)

For MTG-backed growth, prefer add_organ!: it creates the MTG node and its model object together and, by default, reuses the status initialization policy from CompositeModel(mtg; status=...). Use register_object! when adapting another topology backend or when a complete Object already exists.

After the application that adds or removes objects finishes, PlantSimEngine updates which models run, where they get their inputs, and which other models they can call. It also checks for conflicting outputs and updates the schedule. New objects can run applications that are still due later in the same step. An application that already ran is only repeated for a new object through an explicit Initializer call; see manual calls. Changing geometry alone updates the affected environment connections.

PlantSimEngine also updates the groups of similar objects it runs together. Diagnostics.explain_execution_plan(scene_or_simulation) lists those groups and their model and value types. An object using a replacement model appears in a separate group.

Output Collection​#

run!(model; steps=...) returns a Simulation. Use final_state(sim) for the latest values of a single object, outputs(sim) for saved output histories, Diagnostics.explain_outputs(sim) for a report about those outputs, and collect_outputs(sim) to gather them into a table.

julia
request = OutputRequest(
    Many(scale=:Leaf),
    :transpiration;
    name=:leaf_transpiration_daily,
    application=:leaf_energy,
    policy=Integrate(),
    clock=Day(1),
)

sim = run!(model; steps=48, outputs=request)
daily = collect_outputs(sim, :leaf_transpiration_daily)

Output requests collect saved results after the run. They use the same temporal policies as multirate inputs and export dynamic objects only over the interval where that object published samples. If several model applications implement the same process, add application=:application_name to select one explicitly. This is also the way to request a named :stream_only publisher. outputs=:none retains no user streams. Passing explicit requests retains only their application/variable streams plus streams needed by temporal ModelSpec(...; inputs=...). Use Diagnostics.explain_output_retention(sim) to inspect why each retained stream was kept. For results needed only as inputs to other models, PlantSimEngine keeps just enough history for the chosen time rule. Results you explicitly request keep their full history so you can collect them after the run.

Inspecting The Compiled Scenario​#

Use structured explanations instead of inspecting internal dictionaries:

julia
Diagnostics.explain_objects(model)
Diagnostics.explain_instances(model)
Diagnostics.explain_scopes(model)
Diagnostics.explain_applications(model)
Diagnostics.explain_bindings(model)
Diagnostics.explain_calls(model)
Diagnostics.explain_environment_bindings(model)
Diagnostics.explain_schedule(model)
Diagnostics.explain_writers(model)

These reports identify the objects and models used by the simulation, where their values come from, and how they exchange values over time. People and coding agents can inspect the same reports.

Migration Table​#

Legacy configurationCompositeModel/object replacement
ModelMapping scale assemblyCompositeModel objects plus model applications
ModelMapping status type remappingCompositeModel(...; type_promotion=..., status_transform=...)
MultiScaleModel(...)consumer ModelSpec(...; inputs=...)
TimeStepModel(...)ModelSpec(...; every=...)
InputBindings(...)source, policy, and window on ModelSpec(...; inputs=...)
MeteoBindings(...)automatic environment binding or Environment(...)
ScopeModel(...)ModelSpec(...; on=...) and selector scopes
SameScale() renameinputs=(:local => One(within=Self(), var=:source),)

The executable MAESPA migration in examples/maespa_model_example.jl demonstrates two plant species, shared soil, plant-local aggregation, model-wide iterative energy balance, hourly and daily models, and automatic environment binding.