.
-
-[^8]: Barczi, J.-F., Rey, H., Caraglio, Y., Reffye, P. de, Barthélémy, D., Dong, Q. X., & Fourcaud, T. AmapSim: A Structural Whole-plant Simulator Based on Botanical Knowledge and Designed to Host External Functional Models. Annals of botany, 101(8), 1125-1138 (2008).
+PlantSimEngine helps you build plant simulations from process models that you
+can inspect, test, reuse, and replace. You choose the scientific equations,
+the plants, organs or other entities they describe, and the exchanges between
+them. The engine connects their inputs and outputs and runs the resulting
+simulation in Julia.
+
+This is useful when your research question requires a particular combination
+of processes or assumptions:
+
+- **Crop modelling:** assemble development, light interception, biomass, or
+ water-balance processes at the resolution your question needs. Start with
+ [a coupled simulation for a canopy](../journeys/users/one_object.md).
+- **Canopy ecophysiology:** connect weather, local conditions and organ processes,
+ including calculations that must iterate together. The
+ [MAESPA-style synthesis](../journeys/users/maespa_synthesis.md) demonstrates
+ how these calculations can work together, using teaching models.
+- **Functional–structural plant modelling:** apply processes to organs,
+ connect their results to the plant, and update the structure during growth.
+ Start with [one multiscale plant](../journeys/users/one_plant.md).
+
+The scientific models, parameter sets, input data, and validation for your
+species or experiment come from your own work or model packages such as
+[PlantBiophysics.jl](https://github.com/VEZY/PlantBiophysics.jl). PlantSimEngine
+provides the tools for connecting and running them.
+
+## Compare hypotheses without rewriting the whole simulation
+
+A process can have several model implementations. For example, you may want
+to compare two biomass-production equations or introduce water limitation
+into a previously radiation-driven model. Each implementation keeps its own
+parameters and equations; the scenario specifies where it runs and how it
+receives inputs.
+
+Replacement depends on the model's interface: the values it needs and produces,
+and what those values mean. The new model must provide the outputs other
+models need, and its own inputs must be available. A variable contract records
+information such as units, whether a quantity is per plant or per unit area,
+and whether it is a rate or an accumulated amount. The same variable name
+alone is insufficient. Moving from a quantity per unit ground area to a total
+per plant, for example, needs an explicit conversion.
+
+To replace a model only for selected plants, its inputs, outputs, and other
+requirements must match the original model. A replacement that needs an extra
+input, such as soil water content, requires changes to the simulation setup. See
+[model compatibility and replacement](../step_by_step/model_switching.md).
+
+Model authors can read each equation together with its inputs and outputs.
+Researchers assembling models can see which equations and assumptions were
+chosen in the simulation setup. Both can test a component
+before investigating its behaviour in the full system.
+
+## Choose the representation that answers your question
+
+An object can represent a leaf, a plant, a canopy layer, a soil compartment,
+or another entity you define. You can work with one object, several objects,
+or a hierarchy imported from a multiscale tree graph. Geometry is optional.
+Different models can be applied to different selections of these objects.
+
+The same process equation can be reused over compatible objects while the
+scenario handles their selection and connections. A plant-level model can,
+for example, read values from its own leaves. The modeller decides how to combine these values, for example by summing leaf
+areas, and supplies any required area, mass, or time conversion.
+Changing resolution also requires checking the assumptions and validity of
+the chosen models. See [value coupling across objects](../guides/multiscale/value_coupling.md).
+
+When organs appear, disappear, or move within the plant structure,
+PlantSimEngine updates the affected model selections and connections. This lets
+growth change which organs contribute to a plant-level calculation while
+retaining their identities and historical outputs. Follow
+[Modify Plant Structure](../journeys/users/structure_changes.md).
+
+## Connect different time steps and control scientific iteration
+
+Canopy exchange may run hourly while development runs daily. Each model
+can run at its own time step, with explicit rules for reading values produced
+at another time step. The appropriate averaging, accumulation,
+or rate-to-amount conversion is a scientific choice. The
+[cadence tutorial](../journeys/users/cadences.md) makes those choices explicit.
+
+Some calculations also need a controller: an energy-balance algorithm may
+call gas-exchange models repeatedly while finding an accepted leaf temperature.
+Explicit model calls let the controller manage that iteration and record the
+accepted result once. See [advanced execution](../journeys/users/advanced_execution.md).
+
+## Understand what the simulation will do
+
+The `Authoring` and `Diagnostics` interfaces expose model declarations,
+missing inputs, the sources of values, execution order, local environmental
+conditions, and which results are saved. You can inspect why a particular leaf
+receives a value, which model supplies it, and when it is updated. The
+[graph viewer](../guides/graph_visualizer_editor.md) provides another view of
+how the models are connected.
+
+These reports help separate a coupling problem from a problem in an equation
+or its assumptions. Declared units and physical meanings help reveal incompatible connections;
+scientific validation still needs suitable observations, reference results,
+and tests. See the [model authoring API](../API/API_public.md) and
+[model testing guide](../guides/modelers/repository_and_tests.md).
+
+## Keep repeated simulations practical
+
+PlantSimEngine prepares model selections and connections before repeated
+execution, avoids copying input values where possible, and processes groups
+of similar objects together. Model equations remain ordinary Julia
+calculations whose performance can be measured and improved.
+
+There is application-level performance evidence: the
+[2025 PlantBiophysics.jl paper](https://doi.org/10.1093/insilicoplants/diaf021)
+reports a median of 5.3 microseconds for one leaf and one time step of its
+coupled energy-balance, photosynthesis, and stomatal-conductance benchmark.
+That result concerns the implementations, versions, inputs, and hardware used
+in the study. For your scenario, measure initialization, repeated execution,
+structural changes, and saving results separately using the
+[benchmarking guidance](../developers.md). A public parallel or distributed
+executor remains [planned work](../planned_features.md).
+
+## AI-assisted model development
+
+An AI coding agent is software that can read and edit code and run tests with
+your development tools. Small process models, explicit inputs and outputs,
+declared units, and reports on model connections give it concrete information
+to read and check. An agent can help draft a model, compare declared
+interfaces, assemble a scenario, inspect its connections, and run tests with
+the tools available in your development environment.
+
+PlantSimEngine provides a versioned [AI agent skill](../agent_skill.md), a
+[catalog of available models](../API/model_catalog.md), and the same authoring tools
+used by people. You supply the coding agent and its execution environment.
+Model assumptions, physical conversions, supporting references, and scientific
+validation remain the responsibility of the researcher. Begin with
+[Implement a basic model](../journeys/modelers/basic_model.md) to see the
+complete model, an independent equation test, and its use in a simulation.
+
+## How this fits among plant-modelling tools
+
+Modularity and multiscale modelling have a substantial history.
+[APSIM](https://docs.apsim.info/docs/development/software/interfaces) supports
+replaceable model interfaces;
+[DSSAT](https://dssat.net/frontpage/) combines crop models with data and
+experimental workflows;
+[OpenAlea](https://openalea.readthedocs.io/en/latest/packages/modelling.html)
+provides components, multiscale structures, and plant-geometry tools; and
+[GroIMP](https://grogra.de/) integrates FSPM modelling and visualisation.
+[Cropbox](https://doi.org/10.1093/insilicoplants/diac021) also uses Julia and
+dependency analysis for declarative crop modelling.
+
+PlantSimEngine's contribution is the combination described here: readable
+process equations, explicit inputs and outputs, a choice of how to represent
+plants, different time steps, controlled iteration, changing structures, and
+calculations you can inspect. It is a useful fit when you want to develop or assemble
+that scientific model combination yourself, while keeping its choices visible
+and its components independently testable.
diff --git a/docs/src/journeys/modelers/basic_model.md b/docs/src/journeys/modelers/basic_model.md
index 8b9be91a1..d2c07c063 100644
--- a/docs/src/journeys/modelers/basic_model.md
+++ b/docs/src/journeys/modelers/basic_model.md
@@ -1,165 +1,171 @@
# Implement a basic model
-**New concept:** the complete one-step model boundary: process identity,
-parameters, ports, scientific contracts, and a readable five-argument kernel.
-This page uses the tested canonical fixture shipped with the PlantSimEngine
-skill, first directly and then through the ordinary runtime.
+This teaching example describes daily biomass production as:
-Before creating a type, follow [New process or new model?](@ref). The example
-below is one hypothesis for the `biomass_production` process. Its coefficients
-are pedagogical, not a calibrated crop model.
+**biomass increment = radiation-use efficiency × intercepted radiation**
-## Read the complete model
+For an efficiency of 1.5 g dry matter per mol of photons and 10 mol of
+intercepted photons per plant, the result is 15 g dry matter per plant.
+These values show how to write a model; they are not a calibrated crop model.
-This is the complete, copyable source shipped with the package. The model
-declaration comes first; `direct_example` tests its kernel and
-`single_object_scenario` shows the smallest runtime composition.
+We will give that equation a name, declare its variables and units, test it,
+and run it on two plants. Before adding your own model, use
+[New process or new model?](@ref) to choose its process.
+
+## Name the process and its model
+
+A **process** identifies the scientific calculation. A **model** implements
+one hypothesis for it. This example declares a biomass-production process:
```@eval
-using Markdown, PlantSimEngine
-source = read(joinpath(
- pkgdir(PlantSimEngine), "skills", "plantsimengine", "assets", "minimal-model.jl",
-), String)
-Markdown.MD([Markdown.Code("julia", source)])
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "PlantSimEngine.@process", "const INTERCEPTED_PAR_CONTRACT",
+)
```
-## Load and inspect it
+The model stores one fixed parameter, `rue`. The `{T}` allows its numerical
+type to follow the supplied parameter.
-The documentation and agent skill use the same executable source instead of
-maintaining two copies:
+```@eval
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "struct RadiationUseEfficiency", "PlantSimEngine.inputs_",
+)
+```
-```@example modeler_basic
-using Dates, PlantSimEngine
-
-asset = joinpath(
- pkgdir(PlantSimEngine),
- "skills",
- "plantsimengine",
- "assets",
- "minimal-model.jl",
+## Declare the values the equation uses
+
+| Value | Role | Meaning |
+|---|---|---|
+| `rue` | Fixed parameter | g dry matter per mol intercepted photons |
+| `intercepted_par` | Input | Daily intercepted photons, mol per plant |
+| `biomass_increment` | Output | Daily biomass production, g dry matter per plant |
+
+`Required(Real)` says the simulation must supply a real-valued input.
+`zero(model.rue)` initializes the output with the parameter's numerical type.
+This model reads no environmental variables directly.
+
+```@eval
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "PlantSimEngine.inputs_(::RadiationUseEfficiency)",
+ "PlantSimEngine.variable_contracts_",
)
-include(asset)
-using .MinimalModelExample
+```
+
+A `VariableContract` describes a variable's units and meaning. It records,
+for example, whether a value is per plant or per square metre, and whether it
+is a rate or a daily total. This helps check that two connected models
+interpret a value in the same way. Here both quantities are daily totals
+for one plant:
-boundary = RadiationUseEfficiency(1.5f0)
-description = Authoring.describe_model(boundary)
-validation = Authoring.validate_model(boundary; strict=true)
-(
- runtime_process=process(boundary),
- described_process=description.process,
- inputs=inputs(boundary),
- outputs=outputs(boundary),
- contracts=variable_contracts(boundary),
- description_provenance=description.provenance,
- field_provenance=description.field_provenance,
- structurally_valid=validation.valid,
+```@eval
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "const INTERCEPTED_PAR_CONTRACT", "\"\"\"",
)
```
-The loaded declaration is deliberately short. `RadiationUseEfficiency{T}`
-stores `rue`; its schemas declare `intercepted_par` and
-`biomass_increment`, plus explicitly empty environment inputs and outputs. The
-two ports receive complete contracts, and the complete kernel equation is
-`status.biomass_increment = model.rue * status.intercepted_par` followed by
-`return nothing`. This description is derived from the asset included above,
-so the tutorial does not maintain a second untested copy of its source.
+Read these settings as follows: `unit` names the measurement unit,
+`basis=:plant` says it refers to one plant, and `temporal=:day` with
+`aggregation=:total` says it is a daily total. `extent=:extensive` means the
+amounts from several plants can be added to obtain their combined amount.
+
+Attach those descriptions to the corresponding variables:
-The model struct stores only the fixed radiation-use-efficiency parameter.
-`Required(Real)` is a type requirement, not an initial value. The output
-initial value follows the parameter's numeric type. The complete
-`VariableContract` declarations state that intercepted radiation and biomass
-increment are daily, plant-scale totals with explicit units.
+```@eval
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "PlantSimEngine.variable_contracts_(::RadiationUseEfficiency)",
+ "PlantSimEngine.Authoring.model_metadata",
+)
+```
-The kernel reads as the scientific calculation from input to output. It does
-not select objects, find producers, choose a cadence, or retain output rows;
-those are scenario responsibilities.
+These descriptions help check connections, but do not convert values. If one
+model supplies radiation per square metre and another needs radiation per
+plant, write the conversion in an [adapter model](../../guides/coupling.md).
-## Test the kernel directly
+## Write the equation
-Use a minimal `Status` before involving the compiler:
+The `run!` function reads the parameter from `model`, reads the input from
+`status`, and writes its result back to `status`:
-```@example modeler_basic
-direct = direct_example(Float32)
-(
- biomass_increment=direct.biomass_increment,
- value_type=typeof(direct.biomass_increment),
+```@eval
+Main.DocsSources.section(
+ "skills/plantsimengine/assets/minimal-model.jl",
+ "function PlantSimEngine.run!(", "\"\"\"Run the kernel directly",
)
```
-This test isolates the equation and proves that the fixture preserves
-`Float32`. Model packages should also test edge cases and supported enriched
-number types.
+The other arguments provide environmental data, constants, and tools for
+calling other models or changing the simulated objects. This equation does
+not need them. Choose which plants use the model and when it runs in the
+simulation setup below.
-## Compose it on one object
+## Test one calculation
-The asset provides the smallest full scenario with the required input supplied
-as initial status:
+These displayed definitions come from the package's executable
+`skills/plantsimengine/assets/minimal-model.jl` example. To load the complete
+example in your session:
```@example modeler_basic
-one_object = single_object_scenario(Float32)
-(
- initialization=Diagnostics.explain_initialization(one_object),
- final=final_state(run!(one_object)),
-)
+using Dates, Test, PlantSimEngine
+asset = joinpath(pkgdir(PlantSimEngine), "skills", "plantsimengine", "assets", "minimal-model.jl")
+include(asset)
+using .MinimalModelExample
+
+model = RadiationUseEfficiency(1.5f0)
+status = Status(intercepted_par=10.0f0, biomass_increment=0.0f0)
+PlantSimEngine.run!(model, status, NamedTuple(), nothing, nothing)
+
+@test status.biomass_increment == 15.0f0
+@test status.biomass_increment isa Float32
+status.biomass_increment
+```
+
+The `f0` notation chooses `Float32`. The tests check the equation and that
+the implementation preserves this numerical type. Also check the declarations:
+
+```@example modeler_basic
+validation = Authoring.validate_model(model; strict=true)
+@test validation.valid
+validation.valid
```
-`Diagnostics.explain_initialization` distinguishes supplied inputs, model
-defaults, produced outputs, environment bindings, and unresolved requirements.
-Inspect this report before running a larger scenario.
+That check finds missing or inconsistent model declarations. To check whether
+the equation describes real plants, compare its results with appropriate
+observations or reference results.
-## Reuse the same kernel over several objects
+## Run the model on two plants
-Object selection remains outside the model. Change only the application
-multiplicity and provide each object with initial radiation:
+Each plant has its own intercepted radiation. The same model applies to both:
```@example modeler_basic
-development = RadiationUseEfficiency(1.5f0)
-several_objects = CompositeModel(
- Object(
- :plant_1;
- scale=:Plant,
- status=Status(intercepted_par=10.0f0),
- ),
- Object(
- :plant_2;
- scale=:Plant,
- status=Status(intercepted_par=6.0f0),
- );
+plants = CompositeModel(
+ Object(:plant_1; scale=:Plant, status=Status(intercepted_par=10.0f0)),
+ Object(:plant_2; scale=:Plant, status=Status(intercepted_par=6.0f0));
applications=(
- ModelSpec(
- development;
- name=:biomass_production,
- on=Many(scale=:Plant),
- ),
+ ModelSpec(model; name=:biomass_production, on=Many(scale=:Plant)),
),
+ environment=(duration=Day(1),),
)
-final_state(run!(several_objects), Many(scale=:Plant))
+simulation = run!(plants)
+result_1 = final_state(simulation, :plant_1).biomass_increment
+result_2 = final_state(simulation, :plant_2).biomass_increment
+@test (result_1, result_2) == (15.0f0, 9.0f0)
+(plant_1=result_1, plant_2=result_2)
```
-Do not loop over objects inside `RadiationUseEfficiency.run!`.
-PlantSimEngine compiles homogeneous targets and invokes the one-target kernel
-for each selected object.
-
-## Continue the authoring path
-
-- [Port an existing model](@ref) explains the readable-kernel convention.
-- [Model repository layout and tests](@ref) shows where to place alternatives,
- documentation, and each test level.
-- [Implement Cross-Object Values](@ref) adds `One`, `Many`, and `Subtree()`
- bindings.
-- [Coupling models](@ref) explains when `OptionalOne` is valid and
- distinguishes value coupling, hard calls, and explicit physical adapters.
-- [Model compatibility and replacement](@ref) checks whether another
- hypothesis is genuinely substitutable.
-
-## Model-author recap
-
-- **You implemented:** one immutable parameter type, declared ports, complete
- scientific contracts, and a continuous one-target kernel.
-- **PlantSimEngine inferred:** initialization, target execution, and generic
- status construction.
-- **The scenario author keeps explicit:** object identities, target
- multiplicity, initial radiation, cadence, and output retention.
-- **New API names:** `AbstractModel`, `inputs_`, `outputs_`,
- `variable_contracts_`, `VariableContract`, `Required`, `Status`, and `run!`.
+PlantSimEngine calls the equation once for each selected plant. Each plant
+keeps its own input and result, so you do not need to write a loop over plants
+inside the model.
+
+## Continue with your own model
+
+- [Port an existing model](@ref): separate a calculation from its original script.
+- [Model repository layout and tests](@ref): organize a package and its checks.
+- [Implement Cross-Object Values](@ref): read another object's result or sum several.
+- [Model compatibility and replacement](@ref): compare another hypothesis.
+- [Loaded model catalog](@ref): discover and inspect models already loaded.
diff --git a/docs/src/journeys/modelers/cross_object_values.md b/docs/src/journeys/modelers/cross_object_values.md
index e14cd548c..f7bb971bc 100644
--- a/docs/src/journeys/modelers/cross_object_values.md
+++ b/docs/src/journeys/modelers/cross_object_values.md
@@ -1,21 +1,34 @@
# Implement Cross-Object Values
-**New concept:** scalar and vector-like inputs use the same one-step kernel
-contract. Object selection and topology remain scenario concerns.
+A model reads its inputs from `status`, whether the values came from the
+same object, another object, or several objects. The scenario chooses those
+sources. This page shows what the model author writes in each case.
-See [Build One Multiscale Plant](@ref) for the simulation-user construction
-journey.
+The equations and numbers below are teaching examples. For the corresponding
+scenario walkthrough, see [Build One Multiscale Plant](@ref).
-Every block below executes during the documentation build using the tested,
-shipped example models.
+## Read one value from another object
-## Model 4: consume one scalar from another object
+The development example calculates:
-`ToyDevelopmentModel` already declares `stress=Default(1.0)`. A scenario may
-replace that fallback with one live scalar from a soil object:
+**growth increment = efficiency × thermal time × stress factor**
+
+Here is its actual input declaration and calculation, extracted from
+`examples/ToyModelDeveloper.jl`:
+
+```@eval
+Main.DocsSources.section(
+ "examples/ToyModelDeveloper.jl",
+ "PlantSimEngine.inputs_(::ToyDevelopmentModel)",
+ "\"\"\"\n ToyDailyDevelopmentModel",
+)
+```
+
+`stress=Default(1.0)` means the model can run without stress reduction when
+that is appropriate. A scenario can instead supply a soil object's value:
```@example modeler_cross_object
-using Dates, PlantMeteo, PlantSimEngine, DataFrames
+using Dates, Test, PlantSimEngine
using PlantSimEngine.Examples
cross_object = CompositeModel(
@@ -27,131 +40,92 @@ cross_object = CompositeModel(
name=:development,
on=One(scale=:Leaf),
inputs=(
- :stress => One(
- scale=:Soil,
- within=SceneScope(),
- var=:stress,
- from_status=true,
+ stress=One(
+ scale=:Soil, within=SceneScope(),
+ var=:stress, from_status=true,
),
),
),
),
)
-cross_simulation = run!(cross_object)
-(
- leaf=final_state(cross_simulation, :leaf),
- binding=only(Diagnostics.explain_bindings(cross_object)),
-)
+simulation = run!(cross_object)
+growth = final_state(simulation, :leaf).growth
+@test growth == 2.0
+growth
```
-The model kernel still reads only `status.stress`. It does not search for soil,
-know object ids, or copy the scalar each step. The scenario's `One` selector
-resolves a shared `Ref`.
+`from_status=true` reads the value we stored in the soil object's `Status`.
+When a soil model calculates that value instead, select that model as described in
+[Coupling models](@ref). The development equation itself stays unchanged.
-## Model 5: consume a vector-like multiscale value
+## Read several values and combine them
-`ToyMaintenanceRespirationModel` runs once per leaf and publishes `Rm`.
-`ToyPlantRmModel` declares one vector-like input and reduces it:
+Suppose leaf respiration amounts are available for the same interval. A
+plant-level model can add them:
-```@example modeler_cross_object
-plant_respiration = ToyPlantRmModel()
-plant_status = Status(Rm_organs=[0.2, 0.3], Rm=-Inf)
-PlantSimEngine.run!(
- plant_respiration,
- plant_status,
- NamedTuple(),
- nothing,
- nothing,
-)
-(
- inputs=PlantSimEngine.inputs_(plant_respiration),
- outputs=PlantSimEngine.outputs_(plant_respiration),
- total=plant_status.Rm,
+**plant respiration = sum of leaf respiration**
+
+The model asks for a vector of real values and sums it. It does not need to
+know how many leaves exist:
+
+```@eval
+Main.DocsSources.section(
+ "examples/ToyMaintenanceRespirationModel.jl",
+ "struct ToyPlantRmModel",
)
```
-The scenario decides that `Rm_organs` means all descendant leaf outputs:
+Test the equation with an ordinary vector first:
```@example modeler_cross_object
-respiration = ToyMaintenanceRespirationModel(
- 2.0,
- 0.06,
- 25.0,
- 0.5,
- 0.02,
-)
+plant_respiration = ToyPlantRmModel()
+sample = Status(Rm_organs=[0.2, 0.3], Rm=0.0)
+PlantSimEngine.run!(plant_respiration, sample, nothing, nothing, nothing)
+@test sample.Rm == 0.5
+sample.Rm
+```
-multiscale = CompositeModel(
+## Choose which leaves contribute
+
+This scenario supplies two illustrative leaf amounts, then selects only the
+leaves belonging to the plant:
+
+```@example modeler_cross_object
+plant = CompositeModel(
Object(:plant; scale=:Plant),
- Object(
- :leaf_1;
- scale=:Leaf,
- parent=:plant,
- status=Status(carbon_biomass=10.0),
- ),
- Object(
- :leaf_2;
- scale=:Leaf,
- parent=:plant,
- status=Status(carbon_biomass=20.0),
- );
+ Object(:leaf_1; scale=:Leaf, parent=:plant, status=Status(Rm=0.2)),
+ Object(:leaf_2; scale=:Leaf, parent=:plant, status=Status(Rm=0.3));
applications=(
ModelSpec(
- respiration;
- name=:maintenance,
- on=Many(scale=:Leaf),
- ),
- ModelSpec(
- ToyPlantRmModel();
- name=:plant_maintenance,
+ plant_respiration;
+ name=:plant_respiration,
on=One(scale=:Plant),
inputs=(
- :Rm_organs => Many(
- scale=:Leaf,
- within=Subtree(),
- application=:maintenance,
- var=:Rm,
+ Rm_organs=Many(
+ scale=:Leaf, within=Subtree(),
+ var=:Rm, from_status=true,
),
),
),
),
- environment=Atmosphere(
- T=25.0,
- Wind=1.0,
- Rh=0.7,
- duration=Hour(1),
- ),
-)
-
-multiscale_simulation = run!(multiscale)
-(
- plant=final_state(multiscale_simulation, :plant),
- leaves=final_state(multiscale_simulation, Many(scale=:Leaf)),
-)
-```
-
-```@example modeler_cross_object
-select(
- DataFrame(Diagnostics.explain_bindings(multiscale)),
- :application_id,
- :input,
- :source_ids,
- :carrier_kind,
- :copy_semantics,
+ environment=(duration=Day(1),),
)
+plant_result = final_state(run!(plant), :plant).Rm
+@test plant_result == 0.5
+plant_result
```
-The `Many` carrier is a live `RefVector`; the aggregation kernel operates on an
-`AbstractVector` and stays independent of object count and identity.
+`Many` gathers the selected values so the model can read them like a vector.
+`Subtree()` searches this plant and its descendants. With `scale=:Leaf`,
+only its leaves contribute, so another plant's leaves are not included.
-## Model-author recap
+Before adding real leaf values, check that they use the same units and time
+interval. Also check whether they describe a whole leaf or one square metre
+of leaf area. For values per square metre, multiply each value by its leaf's
+area before adding them to obtain a plant total.
-- **You implemented:** scalar or vector-compatible input schemas and ordinary
- one-step arithmetic.
-- **PlantSimEngine inferred:** shared `Ref` and `RefVector` carriers plus
- producer-before-consumer order.
-- **The scenario author keeps explicit:** cross-object scope, multiplicity,
- source application, and variable remapping.
-- **New API names:** `One`, `Many`, `SceneScope`, `Subtree`, `from_status`,
- `RefVector`, and `input_carrier`.
+Use `Diagnostics.explain_bindings(plant)` to check which leaves supply the
+values. PlantSimEngine keeps the inputs connected to those leaves' current
+results. The equation only needs to add the values; it does not need to look
+up individual leaves or count them.
diff --git a/docs/src/journeys/modelers/environment_and_cadence.md b/docs/src/journeys/modelers/environment_and_cadence.md
index a8b927ac6..cfa4a30a7 100644
--- a/docs/src/journeys/modelers/environment_and_cadence.md
+++ b/docs/src/journeys/modelers/environment_and_cadence.md
@@ -1,142 +1,106 @@
# Implement Environment And Cadence Traits
-**New concept:** model-authored runtime traits. Environment declarations name
-the fields a kernel samples, while cadence and output-policy traits state when
-the same kernel runs and how its values cross clocks.
+A model may need air temperature or may be intended to run once a day.
+Describe these requirements alongside the model's equation. The simulation
+setup then chooses where the temperature comes from and how often the model
+runs. This update frequency is also called its **cadence**.
-Simulation users configure providers in [Understand Environments](@ref) and
-application clocks in [Give Models Different Cadences](@ref).
+Start with [Understand Environments](@ref) and
+[Give Models Different Cadences](@ref) for the scenario-user perspective.
-## Model 6: declare sampled environment inputs
+## Declare an environmental input
-`ToyMaintenanceRespirationModel` reads object state and sampled temperature.
-The tested contract names that environment field explicitly:
+This teaching model simply copies the environmental temperature to an output.
+It shows how to read environmental data before adding a biological equation.
+These are its actual definitions in `examples/ToySpatialEnvironment.jl`:
-```@example modeler_environment_time
-using Dates, PlantMeteo, PlantSimEngine, DataFrames
-using PlantSimEngine.Examples
-
-respiration = ToyMaintenanceRespirationModel(
- 2.0,
- 0.06,
- 25.0,
- 0.5,
- 0.02,
-)
-(
- inputs=PlantSimEngine.inputs_(respiration),
- environment_inputs=PlantSimEngine.environment_inputs_(respiration),
- environment_outputs=PlantSimEngine.environment_outputs_(respiration),
- outputs=PlantSimEngine.outputs_(respiration),
+```@eval
+Main.DocsSources.section(
+ "examples/ToySpatialEnvironment.jl",
+ "struct ToyEnvironmentReaderModel",
+ "\"\"\"\n ToyEnvironmentControllerModel",
)
```
-The kernel reads model parameters from `model`, object state from `status`, and
-forcing from `environment`:
+`environment_inputs_` declares `T`, and `run!` reads it from
+`environment.T`. The model does not need to know whether the temperature
+comes from a weather file or varies with position in the canopy.
+
+Test that read directly:
```@example modeler_environment_time
-direct_status = Status(carbon_biomass=10.0, Rm=-Inf)
-PlantSimEngine.run!(
- respiration,
- direct_status,
- (T=25.0,),
- nothing,
- nothing,
-)
-direct_status
+using Dates, Test, PlantSimEngine
+using PlantSimEngine.Examples
+
+reader = ToyEnvironmentReaderModel()
+sample = Status(temperature_seen=0.0)
+PlantSimEngine.run!(reader, sample, (T=25.0,), nothing, nothing)
+@test sample.temperature_seen == 25.0
+sample.temperature_seen
```
+Then supply an environment through the simulation:
+
```@example modeler_environment_time
model = CompositeModel(
- Object(
- :leaf;
- scale=:Leaf,
- status=Status(carbon_biomass=10.0),
- );
- applications=(
- ModelSpec(
- respiration;
- name=:maintenance,
- on=One(scale=:Leaf),
- ),
- ),
- environment=Atmosphere(
- T=25.0,
- Wind=1.0,
- Rh=0.7,
- duration=Hour(1),
- ),
-)
-
-(
- declared=PlantSimEngine.environment_inputs_(respiration),
- final=final_state(run!(model)),
+ reader;
+ environment=(T=25.0, duration=Hour(1)),
)
+result = final_state(run!(model)).temperature_seen
+@test result == 25.0
+result
```
-The model does not name a provider or inspect raw weather storage.
-`Environment(...)` remains scenario configuration.
+A scientific model should also declare the temperature's units and meaning
+with `variable_contracts_`, as in [Implement a basic model](@ref).
+Use [Port an existing model](@ref) for an equation that combines environmental
+temperature with object state.
-## Model 7: declare cadence and output semantics
+## Give a model a default cadence
-`ToyDailyDevelopmentModel` accumulates one increment whenever it runs. Its
-model-level traits say “every 24 simulation steps, starting at step 1” and
-“consumers may hold the last daily value between publications”:
+The next teaching model adds a fixed increment whenever it runs. By default,
+it runs every 24 simulation steps. Other models can keep reading its latest
+result until it runs again:
-```@example modeler_environment_time
-daily = ToyDailyDevelopmentModel(2.0)
-(
- clock=PlantSimEngine.timespec(daily),
- outputs=PlantSimEngine.output_policy(daily),
+```@eval
+Main.DocsSources.section(
+ "examples/ToyModelDeveloper.jl",
+ "PlantSimEngine.inputs_(::ToyDailyDevelopmentModel)",
)
```
-`ClockSpec` is expressed in simulation steps. Use
-`ModelSpec(...; every=Day(1))` when a scenario should express a
-duration-relative cadence or override the model default.
+`timespec` sets the default update frequency. `output_policy` describes how
+other models read the result between updates. Here `HoldLast` tells them to
+use the latest available value; it does not run this equation again.
+
+`ClockSpec(24.0, 1.0)` means every 24 base steps, starting at step 1. It
+corresponds to a day only when the base step is an hour. In a scenario,
+`every=Day(1)` expresses the intended duration directly:
```@example modeler_environment_time
+daily = ToyDailyDevelopmentModel(2.0)
daily_model = CompositeModel(
Object(:plant; scale=:Plant);
applications=(
ModelSpec(
- daily;
- name=:daily_development,
- on=One(scale=:Plant),
+ daily; name=:daily_development, on=One(scale=:Plant),
+ every=Day(1),
),
),
- environment=[
- (duration=Hour(1),)
- for _ in 1:25
- ],
+ environment=[(duration=Hour(1),) for _ in 1:25],
)
-daily_simulation =
- run!(daily_model; steps=25, outputs=:all)
-(
- traits=(
- clock=PlantSimEngine.timespec(daily),
- outputs=PlantSimEngine.output_policy(daily),
- ),
- schedule=DataFrame(Diagnostics.explain_schedule(daily_model)),
- final=final_state(daily_simulation),
- retained=DataFrame(
- Diagnostics.explain_outputs(daily_simulation),
- ),
-)
+daily_simulation = run!(daily_model; steps=25, outputs=:all)
+growth = final_state(daily_simulation).daily_growth
+@test growth == 4.0
+growth
```
-The model runs at steps 1 and 25. A downstream application can override
-`HoldLast` with an explicit selector policy when its scientific interpretation
-requires `Integrate`, `Aggregate`, or `Interpolate`.
-
-## Model-author recap
+The model runs at steps 1 and 25, adding 2 each time. When connecting another
+model, you can choose to average or add results over an interval instead of
+keeping the last value. The choice depends on what the variable represents;
+see [Give Models Different Cadences](@ref).
-- **You implemented:** declared environment reads, a model default clock, and
- per-output temporal meaning.
-- **PlantSimEngine inferred:** environment validation/sampling, execution
- steps, and the default cross-clock policy.
-- **The scenario author keeps explicit:** concrete environment provider,
- simulation base step, cadence overrides, and input-policy overrides.
-- **New API names:** `environment_inputs_`, `timespec`, `ClockSpec`,
- `output_policy`, and `HoldLast`.
+Give a model a default update frequency only when its equations require one.
+Choose weather data, timing for a particular study, and results to save in
+the simulation setup.
diff --git a/docs/src/journeys/modelers/hard_dependencies.md b/docs/src/journeys/modelers/hard_dependencies.md
index 2e3307487..e92ae156e 100644
--- a/docs/src/journeys/modelers/hard_dependencies.md
+++ b/docs/src/journeys/modelers/hard_dependencies.md
@@ -1,131 +1,111 @@
# Implement A Hard Dependency
-**New concept:** a process-level hard dependency, used only when the parent
-kernel must control another model's execution. Value dependencies should stay
-in `inputs_`.
+Use a **hard dependency** when one model must decide when or how often another
+model runs. For example, an energy-balance calculation may evaluate gas
+exchange at several trial temperatures before accepting a solution.
-Simulation users wire and inspect advanced calls in
-[Control Advanced Execution](@ref).
+When a model only needs another model's result, use an ordinary input instead.
+[Coupling models](@ref) explains the difference, and
+[Control Advanced Execution](@ref) covers scenario configuration.
-The examples below execute the shipped, tested
-`ToySelectiveCallControllerModel`.
+## Declare the model you need to call
-## Model 8: declare and execute the call
+The teaching model below acts as a **controller**: it chooses when to run
+another model. It selects one leaf, tries two prescribed temperatures, and
+finally accepts a third. This shows how to run trials; it does not solve an
+energy-balance equation.
-`ToySelectiveCallControllerModel` declares a reusable default by process,
-scale, and relative scope. It cannot know future application names:
+Its declaration asks for temperature-reading models on the current plant's leaves.
+These definitions are extracted from `examples/ToyAdvancedControl.jl`:
-```@example modeler_hard_dependency
-using PlantSimEngine, DataFrames
-using PlantSimEngine.Examples
+```@eval
+Main.DocsSources.section(
+ "examples/ToyAdvancedControl.jl",
+ "PlantSimEngine.dep(::ToySelectiveCallControllerModel)",
+ "function PlantSimEngine.outputs_",
+)
+```
-controller = ToySelectiveCallControllerModel(
- (28.0, 31.0),
- 22.0;
- selected_object=:sun_leaf,
+`Call` declares which models the controller needs. The controller chooses
+when to run them. Asking for a process on the current plant's leaves lets
+you reuse it without knowing the names a future simulation will give those
+model applications.
+
+## Run trials and accept one result
+
+Here is the actual controller calculation:
+
+```@eval
+Main.DocsSources.section(
+ "examples/ToyAdvancedControl.jl",
+ "function PlantSimEngine.run!(\n model::ToySelectiveCallControllerModel,",
+ "\"\"\"\n ToyStockWriterModel",
)
-PlantSimEngine.dep(controller)
```
-Inside `run!`, the controller inspects the vector-like target collection,
-restricts the declared call by object id, runs trials with `publish=false`, and
-publishes one accepted execution with `publish=true`. The complete scenario
-below executes that real kernel, so this page does not duplicate an untested
-source excerpt.
+`call_targets` lists the models and objects that match the `Call` declaration.
+Each match is called a **target**. Here the controller chooses the target for
+one leaf. Each trial uses `publish=false`, so its result is not saved as an
+accepted output sample. The final call uses `publish=true` to save the
+accepted result for output history and time-based connections.
+
+For a real solver, you must decide how to calculate each trial and when a
+solution is close enough. You must also handle any values changed by a
+rejected trial: `publish=false` does not restore them automatically.
-The shipped model is exercised by the example-model contract suite. Build a
-scenario without a `calls` keyword to use that model default:
+## Compose a small scenario
```@example modeler_hard_dependency
+using Test, PlantSimEngine
+using PlantSimEngine.Examples
+
+controller = ToySelectiveCallControllerModel(
+ (28.0, 31.0), 22.0; selected_object=:sun_leaf,
+)
environment = ToySpatialEnvironment(
- Dict(
- :sun => (T=26.0,),
- :shade => (T=18.0,),
- );
+ Dict(:sun => (T=26.0,), :shade => (T=18.0,));
step_seconds=3600.0,
)
model = CompositeModel(
Object(:plant; scale=:Plant),
- Object(
- :sun_leaf;
- scale=:Leaf,
- parent=:plant,
- geometry=(cell=:sun,),
- ),
- Object(
- :shade_leaf;
- scale=:Leaf,
- parent=:plant,
- geometry=(cell=:shade,),
- );
+ Object(:sun_leaf; scale=:Leaf, parent=:plant, geometry=(cell=:sun,)),
+ Object(:shade_leaf; scale=:Leaf, parent=:plant, geometry=(cell=:shade,));
applications=(
ModelSpec(
ToyEnvironmentReaderModel();
- name=:reader,
- on=Many(scale=:Leaf),
+ name=:reader, on=Many(scale=:Leaf),
environment=Environment(backend=environment),
),
- ModelSpec(
- controller;
- name=:controller,
- on=One(scale=:Plant),
- ),
+ ModelSpec(controller; name=:controller, on=One(scale=:Plant)),
),
)
-
-DataFrame(Diagnostics.explain_calls(model))
-```
-
-```@example modeler_hard_dependency
simulation = run!(model; outputs=:all)
+accepted = final_state(simulation, :plant)
+@test accepted.trial_temperature_seen == 31.0
+@test accepted.accepted_temperature_seen == 22.0
(
- controller=final_state(simulation, :plant),
- leaves=final_state(simulation, Many(scale=:Leaf)),
- publications=filter(
- row -> row.application_id == :reader,
- DataFrame(Diagnostics.explain_outputs(simulation)),
- ),
+ last_trial=accepted.trial_temperature_seen,
+ accepted=accepted.accepted_temperature_seen,
)
```
-`origin=:model_default` identifies the `dep(model)` contract. A concrete
-scenario can replace it with `ModelSpec(...; calls=...)` when application
-identity or target selection differs.
-
-## Keep the common path bulk and concrete
-
-The selective example above intentionally materializes one public target
-because it chooses an object and gives each trial its own sampled value. If the
-algorithm executes every resolved target with the same already-sampled
-environment, use the bulk `run_call!` path with `context`, `:readers`,
-`sampled_environment=environment`, and `publish=false`.
-
-This executes the compiler's cached typed batches directly. It avoids creating
-or indexing `CallTarget` wrappers inside a timestep loop.
-
-Some iterative algorithms must inspect a singular dependency model before
-executing it. Keep that dispatch concrete with
-`reader_model = call_model(context, :reader)`, let the application build its
-own scientifically meaningful trial state from that concrete model, then
-execute the same declared call in bulk. PlantSimEngine does not define or infer
-that application-specific trial builder.
-
-`call_model` requires exactly one resolved target. Use `call_targets` when the
-algorithm also needs target status, object selection, a custom order, or
-several distinct sampled environments.
-
-The dependency definition is immutable after compilation, while its selected
-objects are not. Growth, removal, and reparenting refresh the affected target
-buffers once at the lifecycle barrier; normal timesteps continue through the
-same compiled plan.
-
-## Model-author recap
-
-- **You implemented:** a process-level `Call` requirement and explicit
- execution inside the parent kernel.
-- **PlantSimEngine inferred:** call-only scheduling, resolved targets, nested
- context, and publication boundaries.
-- **The scenario author keeps explicit:** application overrides and any
- architecture-specific target choice.
-- **New API names:** `dep`, `Call`, `call_model`, `call_targets`,
- `CallTargets`, `run_call!`, `sampled_environment`, and `publish`.
+The controller's `dep` declaration describes the models it calls by default.
+To choose different models or objects in a simulation, set
+`ModelSpec(...; calls=...)`. Use `Diagnostics.explain_calls(model)` to check
+which models and objects were selected.
+
+## Choose the simplest call operation
+
+| Your algorithm needs… | Use |
+|---|---|
+| To run all selected models and objects | `run_call!(context, :readers)` |
+| To read one called model's parameters or type | `call_model(context, :reader)` |
+| To choose objects, read their current values, or give them different trial values | `call_targets(context, :readers)`, then call the selected target |
+
+`call_model` requires exactly one match. If you have already prepared the
+environmental values that all selected objects should use, pass them as
+`sampled_environment=value` to `run_call!`.
+
+When objects are added, removed, or moved to a different parent through the
+PlantSimEngine functions, the selection is updated. Use the call functions
+above so your controller uses that updated selection.
diff --git a/docs/src/journeys/modelers/mutable_environment.md b/docs/src/journeys/modelers/mutable_environment.md
index 5b233ffdf..1e7a10739 100644
--- a/docs/src/journeys/modelers/mutable_environment.md
+++ b/docs/src/journeys/modelers/mutable_environment.md
@@ -1,109 +1,107 @@
# Implement A Mutable Environment Controller
-**New concept:** accepted mutable environment state. A controller declares
-which variables it may commit, evaluates typed trial states, and commits one
-accepted state explicitly.
-
-Simulation users first encounter this workflow in
-[Modify The Environment](@ref). Backend packages implement the separate
-[Environment Backend Extensions](@ref) contract.
-
-The examples below execute the shipped, tested
-`ToyEnvironmentControllerModel`.
+Some models also change their environment. A canopy energy-balance model,
+for example, may try several temperatures before deciding which temperature
+other models should use. A model that manages these trials is called a
+**controller**.
+
+This teaching example uses prescribed temperatures to explain that sequence.
+It does not implement a physical canopy solver. Start with
+[Modify The Environment](@ref) for the scenario perspective and
+[Implement A Hard Dependency](@ref) for model calls.
+
+## Declare the accepted values you can write
+
+A controller declares both the model it calls and the environmental variables
+it may update. Here `environment_outputs_` names temperature `T`. These are
+the actual declarations in `examples/ToySpatialEnvironment.jl`:
+
+```@eval
+Main.DocsSources.section(
+ "examples/ToySpatialEnvironment.jl",
+ "PlantSimEngine.inputs_(::ToyEnvironmentControllerModel)",
+ "function PlantSimEngine.run!(\n model::ToyEnvironmentControllerModel,",
+)
+```
-## Model 9: declare commit permission
+The variables in `outputs_` record the temperatures seen during the
+calculation. The variable in `environment_outputs_` is the temperature the
+controller may change in the shared environment.
-`ToyEnvironmentControllerModel` declares its reader hard dependency and the
-environment variable it may commit:
+## Evaluate a trial, then commit an accepted state
-```@example modeler_mutable_environment
-using PlantSimEngine, DataFrames
-using PlantSimEngine.Examples
+The controller's implementation makes the sequence explicit:
-controller = ToyEnvironmentControllerModel(30.0, 22.0)
-(
- dependency=PlantSimEngine.dep(controller),
- commit_schema=PlantSimEngine.environment_outputs_(controller),
+```@eval
+Main.DocsSources.section(
+ "examples/ToySpatialEnvironment.jl",
+ "function PlantSimEngine.run!(\n model::ToyEnvironmentControllerModel,",
)
```
-Its tested kernel keeps trial, commit, and accepted publication separate. It
-runs the reader against the trial environment with `publish=false`, calls
-`commit_environment!` only for the accepted environment, then publishes one
-accepted reader execution. The complete scenario below executes that source
-instead of repeating it as an unchecked excerpt.
+The first call gives the reader a trial temperature. Its result is not saved
+as an accepted output sample. `commit_environment!` then stores the accepted
+temperature in the shared environment. The final reader call saves the
+result calculated at that temperature.
-`environment_outputs_` is commit permission, not object-status output.
-PlantSimEngine validates that the accepted state provides every declared
-variable before invoking the backend through the controller's compiled handle.
+For a scientific controller, you must choose how to calculate trials and
+when to accept a solution. You must also handle any values changed by a
+rejected trial: `publish=false` does not restore them automatically.
-## Compose the controller
+## Connect the controller to a provider
-The reader needs only a provider. The controller additionally receives
-`sink=:cells`; the backend defines what that sink means:
+An **environment provider** supplies data such as temperature to the models.
+This example stores temperature in a named canopy cell. The reader can read
+that temperature. The controller also gets `sink=:cells`, which allows it to
+write accepted temperatures into the provider's cells.
```@example modeler_mutable_environment
+using Test, PlantSimEngine
+using PlantSimEngine.Examples
+
environment = ToySpatialEnvironment(
- Dict(:canopy => (T=20.0,));
- step_seconds=3600.0,
+ Dict(:canopy => (T=20.0,)); step_seconds=3600.0,
)
+controller = ToyEnvironmentControllerModel(30.0, 22.0)
model = CompositeModel(
- Object(
- :leaf;
- scale=:Leaf,
- geometry=(cell=:canopy,),
- );
+ Object(:leaf; scale=:Leaf, geometry=(cell=:canopy,));
applications=(
ModelSpec(
ToyEnvironmentReaderModel();
- name=:reader,
- on=One(scale=:Leaf),
+ name=:reader, on=One(scale=:Leaf),
environment=Environment(backend=environment),
),
ModelSpec(
controller;
- name=:controller,
- on=One(scale=:Leaf),
- environment=Environment(
- backend=environment,
- sink=:cells,
- ),
+ name=:controller, on=One(scale=:Leaf),
+ environment=Environment(backend=environment, sink=:cells),
),
),
)
-(
- call=DataFrame(Diagnostics.explain_calls(model)),
- environment=DataFrame(
- Diagnostics.explain_environment_bindings(model),
- ),
-)
-```
-
-```@example modeler_mutable_environment
simulation = run!(model; outputs=:all)
+state = final_state(simulation)
+@test state.trial_temperature_seen == 30.0
+@test state.accepted_temperature_seen == 22.0
+@test environment.cells[:canopy].T == 22.0
(
- final=final_state(simulation),
- committed=environment.cells[:canopy],
- publications=filter(
- row -> row.application_id == :reader,
- DataFrame(Diagnostics.explain_outputs(simulation)),
- ),
+ trial=state.trial_temperature_seen,
+ accepted=state.accepted_temperature_seen,
+ committed_temperature=environment.cells[:canopy].T,
)
```
-The rejected `T=30` trial mutates only the reader's trial status. The accepted
-`T=22` state is committed once and produces the reader's only retained sample.
-If the controller itself runs as an unpublished ancestor call, PlantSimEngine
-also suppresses its descendant publications and environment writes.
-
-## Model-author recap
-
-- **You implemented:** declared commit variables, typed trial construction,
- acceptance logic, explicit commit, and one accepted publication.
-- **PlantSimEngine inferred:** permission validation, backend/handle routing,
- nested trial suppression, and retained output history.
-- **The scenario author keeps explicit:** provider, commit sink, concrete
- backend, and any hard-call override.
-- **New API names:** `environment_outputs_`, `commit_environment!`,
- `Environment`, `environment`, and `publish`.
+The temperature starts at 20, the trial uses 30, and the accepted value is 22.
+Use `Diagnostics.explain_environment_bindings(model)` to check where models
+read and write environmental data. `Diagnostics.explain_outputs(simulation)`
+shows the saved results.
+
+When accepting a solution, supply a value for every variable declared in
+`environment_outputs_`. If another controller calls this one as a trial with
+`publish=false`, none of its nested calls can save accepted output samples
+or change the shared environment.
+
+To add a new kind of spatial environment provider, see
+[Environment Backend Extensions](@ref). When writing a process model, you
+can normally use an existing provider through `Environment` and the call
+and commit functions shown here.
diff --git a/docs/src/journeys/users/advanced_execution.md b/docs/src/journeys/users/advanced_execution.md
index b7db5eea3..948cad334 100644
--- a/docs/src/journeys/users/advanced_execution.md
+++ b/docs/src/journeys/users/advanced_execution.md
@@ -1,17 +1,20 @@
# Control Advanced Execution
-## New concept: parent-controlled execution and explicit publication
+## Let one model control another's calculations
-Most coupling should remain a value dependency through `inputs`. Use a hard
-call only when a parent algorithm must decide whether, when, or how often
-another model runs—for example, while iterating toward an accepted leaf
-temperature.
+Use `inputs` when a model simply needs a value calculated by another model.
+Sometimes a model also needs to control when the other model runs, or repeat
+its calculation several times. For example, a heat-balance solver might try
+several leaf temperatures before accepting a result. PlantSimEngine calls
+this a **hard call**. You declare it with `calls` and run it with `run_call!`.
-## Declare parent-controlled targets
+## Choose which models the controller can call
-Start with one plant and two leaves. The reader application is selected by the
-controller's `calls` declaration, so it is call-only rather than independently
-scheduled:
+Start with one plant and two leaves. A controller on the plant can call a
+temperature reader on either leaf. In this example, the readers run only when
+the controller calls them; the simulation does not also run them separately.
+The `Diagnostics.explain_calls` table shows which leaves the controller can
+call:
```@example journey_advanced_execution
using PlantSimEngine, DataFrames
@@ -77,16 +80,18 @@ select(
)
```
-`run_call!(context, :readers)` executes every resolved target and returns a
-vector-like `CallTargets` collection. `One` still returns a collection of one;
-`OptionalOne` returns zero or one; `Many` returns zero or more.
+`run_call!(context, :readers)` runs the reader for every selected leaf and
+returns a `CallTargets` collection that you can loop over. Even `One` returns
+a collection, containing one target. `OptionalOne` returns zero or one target;
+`Many` returns zero or more.
-## Inspect, select, iterate, then publish once
+## Try several values and save the accepted result
-`ToySelectiveCallControllerModel` needs different treatment per target, so its
-kernel first calls `call_targets(context, :readers)` without executing
-anything. It records the total, restricts the same declared call to
-`:sun_leaf`, runs two temperature trials, and accepts one result:
+The controller in this example wants to run only the sun leaf's reader.
+`call_targets(context, :readers)` lists the available targets without running
+them. The controller counts both leaves, selects `:sun_leaf`, tries two
+temperatures, then records one accepted result. The repeated calls follow
+this pattern:
```@example journey_advanced_execution
function run_selected_trials!(
@@ -109,9 +114,10 @@ function run_selected_trials!(
end
```
-`publish=false` is the default. Trials may update the called model's current
-status for convergence checks, but they neither append output samples nor
-commit mutable environment state. Publish exactly the accepted execution.
+`publish=false` is the default. Each trial updates the called model's status,
+so the controller can inspect it and decide whether to continue. The trial
+does not add a result to the output history or save changes to the environment.
+Use `publish=true` for the accepted calculation.
```@example journey_advanced_execution
simulation = run!(model; outputs=:all)
@@ -121,11 +127,11 @@ simulation = run!(model; outputs=:all)
)
```
-The controller resolved two targets but selected only `:sun_leaf`. Object
-selection uses `call_targets(context, name; objects=(ObjectId(:sun_leaf),))`;
-it does not depend on iteration order. Two trials
-left no history; its accepted call published once, while `:shade_leaf` was
-never executed:
+The controller found two leaves but ran only `:sun_leaf`. It selects that leaf
+by name with `call_targets(context, name; objects=(ObjectId(:sun_leaf),))`,
+so changing the order of the leaves would not change the selection. The two
+trials were not recorded. The accepted calculation recorded one result, and
+the reader on `:shade_leaf` did not run:
```@example journey_advanced_execution
filter(
@@ -134,21 +140,28 @@ filter(
)
```
-Use `run_call!(context, name; environment=trial_state)` when every target
-should sample the same provider-aware trial state through its own compiled
-handle. If the caller has already sampled the model-facing environment, use
-`run_call!(context, name; sampled_environment=value)` to execute all targets
-through cached typed batches. Use `call_targets` and
-`run_call!(target; sampled_environment=...)` only for selection, custom order,
-status inspection, or distinct already-sampled environments. For one target,
-`call_model(context, name)` provides allocation-free access to its concrete
-model when an algorithm must dispatch on model type or read its parameters.
+There are two ways to supply trial conditions:
-## Order intentional duplicate writers
+- `run_call!(context, name; environment=trial_state)` lets each target read
+ its own local values from the trial environment. Use this when, for example,
+ a trial canopy temperature field gives different values to different leaves.
+- `run_call!(context, name; sampled_environment=value)` passes values you have
+ already chosen, such as `(T=22.0,)`, directly to all targets.
-One canonical variable normally has one writer. Two applications that both
-claim `stock` therefore fail compilation unless their relationship is
-intentional and ordered. `Updates` makes that ownership explicit:
+Both forms run all selected targets together. Use `call_targets` followed by
+`run_call!(target; sampled_environment=...)` when you need to choose individual
+targets, change their order, inspect their status, or give each one different
+values. For a call with one target, `call_model(context, name)` gives access to
+the model itself, for example to read a parameter. It preserves the concrete
+model type and does not allocate memory.
+
+## Let two models update the same value
+
+Normally, only one model may set a given variable on an object. If two models
+both set `stock`, PlantSimEngine needs to know which value to keep. If the
+second model is meant to change the first model's result, use `Updates` to
+specify their order. Without that instruction, this configuration is rejected
+before either model runs:
```@example journey_advanced_execution
writer_model = CompositeModel(
@@ -184,10 +197,10 @@ select(
)
```
-`initial_stock` owns the first canonical write and `adjusted_stock` explicitly
-updates it afterward. The alternative value is useful as a retained comparison
-but must not replace canonical status, so `output_routing` marks it
-`:stream_only`.
+`initial_stock` sets `stock` first, then `adjusted_stock` changes it. The third
+model calculates an alternative value that we want to keep for comparison.
+`output_routing=(stock=:stream_only,)` records that alternative in its own
+output history without replacing the object's `stock` value.
```@example journey_advanced_execution
writer_simulation = run!(writer_model; outputs=:all)
@@ -202,18 +215,9 @@ writer_simulation = run!(writer_model; outputs=:all)
)
```
-The canonical result is `8`; the three application-specific streams retain
-`4`, `8`, and `99`. A stream-only output is not a fallback writer and is not
-selected by an application-free `OutputRequest`; request its application
-explicitly when retaining only selected outputs.
-
-## Page recap
-
-- **You added:** one declared hard call, selective trials, one accepted
- publication, explicit update ordering, and one stream-only alternative.
-- **PlantSimEngine inferred:** the call-only schedule, concrete targets,
- canonical writer ownership, and update edge.
-- **You keep explicit:** when each target runs, `publish`, per-target forcing,
- intentional duplicate writers, and non-canonical streams.
-- **New API names:** `calls`, `call_targets`, `run_call!`, `CallTargets`,
- `Updates`, `output_routing`, and `:stream_only`.
+The object's final `stock` is `8`. The three models' output histories keep
+their respective values: `4`, `8`, and `99`. The `:stream_only` setting never
+lets the alternative replace the stored `stock`, even if no other model sets
+it. If you use `OutputRequest` to save selected results, name
+`:alternative_stock` explicitly to retain its output; requesting `stock`
+without an application name does not select this alternative.
diff --git a/docs/src/journeys/users/cadences.md b/docs/src/journeys/users/cadences.md
index 61eaeba03..9d34960bc 100644
--- a/docs/src/journeys/users/cadences.md
+++ b/docs/src/journeys/users/cadences.md
@@ -1,22 +1,25 @@
# Give Models Different Cadences
-## New concept: application clocks and temporal input policies
+## Run models at different intervals
-Running a whole composite over many timesteps was introduced on the first
-journey. This page changes one thing: applications no longer all run at the
-environment base step.
+Canopy development might need an update once a day, while light interception
+needs one every hour. A model's **cadence** is how often it runs. This page
+shows how an hourly light model uses a daily LAI value, then how to calculate
+daily water uptake from hourly rates.
-Choose a base step that divides every application cadence: hourly and
-90-minute applications can share a 30-minute base step. A duration such as
-`every=Minute(90)` on an hourly base step is rejected. Fixed durations include
-subsecond periods; calendar months and adaptive timesteps are not application
-cadences.
+The simulation's **base step** is its smallest time interval. Each model's
+cadence must be a whole number of base steps: hourly and 90-minute models can
+share a 30-minute base step. PlantSimEngine rejects `every=Minute(90)` with
+an hourly base step. You can use fixed durations, including fractions of a
+second. Calendar months and timesteps that change during a run are not
+supported as model cadences.
## Hold a daily state for an hourly model
-Reuse the thermal-time, LAI, and light chain. The environment advances hourly;
-thermal time and LAI run daily; light interception runs hourly. `HoldLast`
-makes each hourly light execution read the latest published daily LAI.
+Reuse the models that calculate thermal time, LAI, and absorbed light. The
+weather advances hourly. Thermal time and LAI update daily, while light is
+calculated every hour. `HoldLast` tells the light model to keep using the most
+recent LAI value until a new one is calculated.
```@example journey_cadences
using PlantSimEngine, Dates, DataFrames
@@ -28,24 +31,24 @@ hourly_forcing = [
]
model = CompositeModel(
- Object(:plant; scale=:Plant, kind=:plant);
+ Object(:canopy; scale=:Canopy, kind=:canopy, status=Status(TT_cu=600.0));
applications=(
ModelSpec(
ToyDegreeDaysCumulModel();
name=:degree_days,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
every=Day(1),
),
ModelSpec(
ToyLAIModel();
name=:lai,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
every=Day(1),
),
ModelSpec(
Beer(0.6);
name=:light,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
inputs=(
:LAI => One(
within=Self(),
@@ -64,7 +67,26 @@ model = CompositeModel(
simulation = run!(model; steps=25, outputs=:all)
```
-The schedule reports physical cadence in seconds and in base steps:
+First inspect the outputs at the beginning of the run and around the daily
+update. LAI is in m² of leaves per m² of ground; absorbed PAR is in μmol m⁻²
+of ground s⁻¹. The initial 600 °C d is an illustrative development stage,
+chosen so the LAI changes are visible.
+
+```@example journey_cadences
+rows = collect_outputs(simulation; sink=DataFrame)
+light_samples = rows[(rows.variable .== :aPPFD) .& in.(rows.timestep, Ref((1, 2, 24, 25))),
+ [:timestep, :value]]
+light_samples
+```
+
+The daily models run at steps 1 and 25. They start at the beginning of the
+simulation, so the first update does not wait for a whole day of weather.
+The hourly light model uses the first LAI value until the next daily update.
+If your equation needs a full preceding day, decide what it should do at the
+start, before that history is available.
+
+Check how often each model runs, in seconds (`dt_seconds`) and in base steps
+(`dt_steps`):
```@example journey_cadences
select(
@@ -75,8 +97,9 @@ select(
)
```
-The temporal binding is explicit even though the producer and consumer share
-an object:
+You can also check which rule each input uses to read earlier values. The LAI
+input uses `HoldLast` because we chose it in `inputs` above. Sharing a canopy
+object does not choose that rule for us:
```@example journey_cadences
select(
@@ -89,8 +112,8 @@ select(
)
```
-The daily applications publish at steps 1 and 25; the hourly application
-publishes on all 25 steps:
+There are two results for each daily model, at steps 1 and 25. The hourly
+model has a result at every step. The `nsamples` column counts them:
```@example journey_cadences
select(
@@ -101,46 +124,28 @@ select(
)
```
-`HoldLast` is appropriate because LAI is a state: between daily updates, its
-latest value remains meaningful.
+`HoldLast` fits this example because LAI describes the canopy at a given time.
+The daily development model assumes that LAI stays unchanged between updates.
## Integrate a rate into an amount
-`Integrate(reducer)` can integrate rates using sample durations. The default
-`Integrate()` only sums values; it does not multiply by elapsed time. The
-explicit reducer below uses durations in seconds. If a leaf publishes a constant
-rate in units per second, integrating 24 hourly samples produces a daily
-amount. The consumer below sums the independently integrated amounts from two
-leaves.
+To turn a water-uptake rate into an amount, multiply each rate by the time it
+represents, then add the amounts. `Integrate(reducer)` lets you supply that
+calculation as a function, called a **reducer**. Be careful: `Integrate()`
+without this function only adds the values; it does not multiply by time.
+
+The function below uses durations in seconds. For a rate in mg per second,
+24 hourly values give a daily amount in mg. The plant then adds the amounts
+from its two leaves. The constant rates are teaching values, not predictions
+of water demand.
+
+Load the [teaching models](../../guides/time/teaching_models.jl). One copies a
+leaf's supplied water-uptake rate; the other sums amounts from leaves. Their
+source is shown in [Hourly, Daily, And Weekly Models](../../guides/time/hourly_daily_weekly.md).
```@example journey_cadences
-PlantSimEngine.@process "cadence_hourly_flux" verbose = false
-PlantSimEngine.@process "cadence_daily_amount" verbose = false
-
-struct CadenceHourlyFlux <: AbstractCadence_Hourly_FluxModel end
-struct CadenceDailyAmount <: AbstractCadence_Daily_AmountModel end
-
-PlantSimEngine.inputs_(::CadenceHourlyFlux) = (rate=Required(Real),)
-PlantSimEngine.outputs_(::CadenceHourlyFlux) = (flux=0.0,)
-PlantSimEngine.run!(
- ::CadenceHourlyFlux,
- status,
- environment,
- constants,
- context,
-) = (status.flux = status.rate)
-
-PlantSimEngine.inputs_(::CadenceDailyAmount) = (
- leaf_amounts=Required(AbstractVector{<:Real}),
-)
-PlantSimEngine.outputs_(::CadenceDailyAmount) = (amount=0.0,)
-PlantSimEngine.run!(
- ::CadenceDailyAmount,
- status,
- environment,
- constants,
- context,
-) = (status.amount = sum(status.leaf_amounts))
+include(joinpath(pkgdir(PlantSimEngine), "docs", "src", "guides", "time", "teaching_models.jl"))
+using .TeachingTimeModels
```
```@example journey_cadences
@@ -150,31 +155,31 @@ flux_model = CompositeModel(
:leaf_1;
scale=:Leaf,
parent=:plant,
- status=Status(rate=1.0),
+ status=Status(rate_mg_s=1.0),
),
Object(
:leaf_2;
scale=:Leaf,
parent=:plant,
- status=Status(rate=2.0),
+ status=Status(rate_mg_s=2.0),
);
applications=(
ModelSpec(
- CadenceHourlyFlux();
+ HourlyWaterRate();
name=:hourly_flux,
on=Many(scale=:Leaf),
every=Hour(1),
),
ModelSpec(
- CadenceDailyAmount();
+ SumWaterAmounts();
name=:daily_amount,
on=One(scale=:Plant),
inputs=(
- :leaf_amounts => Many(
+ :amounts_mg => Many(
scale=:Leaf,
within=Subtree(),
application=:hourly_flux,
- var=:flux,
+ var=:water_rate_mg_s,
policy=Integrate((values, durations_seconds) -> sum(values .* durations_seconds)),
window=Day(1),
),
@@ -185,45 +190,45 @@ flux_model = CompositeModel(
environment=[(duration=Hour(1),) for _ in 1:25],
)
-flux_simulation = run!(flux_model; steps=25)
-amount = final_state(flux_simulation, One(scale=:Plant)).amount
+flux_simulation = run!(flux_model; steps=25, outputs=:all)
+amount = final_state(flux_simulation, One(scale=:Plant)).water_amount_mg
@assert amount == 259200.0
amount
```
-The result is `(1 + 2) × 24 × 3600 = 259200` rate-seconds. Use
-`Aggregate(reducer)` instead when the desired quantity is a mean, minimum,
-maximum, or another reduction of observations rather than a time integral.
-
-!!! note "Temporal policies and scientific contracts"
-
- This numerical example leaves variable contracts undeclared. In this
- release, temporal policies do not transform `VariableContract` metadata:
- connected ports must still declare identical contracts. A rate contract
- and a total contract therefore cannot be connected directly through
- `Integrate()`.
-
- For contracted models, put the rate-to-amount calculation in a named
- adapter. Its incoming binding uses the producer's rate contract and its
- output declares the amount contract consumed downstream. For a varying
- rate, accumulate at the producer cadence or use a correctly averaged
- rate; multiplying one held sample by a long duration is valid only when
- the rate is constant over that interval. Keep contracts on both sides;
- see [Coupling models](@ref).
-
-There is no same-step feedback cycle in either example, so
-`PreviousTimeStep` is not needed. It should be introduced only when a real
-scientific dependency intentionally reads the preceding step to break such a
-cycle.
-
-## Page recap
-
-- **You added:** daily and hourly application clocks, `HoldLast` for a state,
- and then `Integrate` for a rate.
-- **PlantSimEngine inferred:** the base-step ratios, publication schedule, and
- bounded temporal storage needed by the consumers.
-- **You keep explicit:** each application cadence, the physical meaning of its
- temporal policy, and the integration window.
-- **New API names:** `every`, `HoldLast`, `Integrate`, `Aggregate`,
- `window`, `Diagnostics.explain_schedule`, and
- `Diagnostics.explain_outputs`.
+The complete-day result is `(1 + 2) × 24 × 3600 = 259200 mg` of water.
+The first execution has only one hour of available history, so it produces
+10800 mg; step 25 has a complete 24-hour rolling window:
+
+```@example journey_cadences
+water_rows = collect_outputs(flux_simulation; sink=DataFrame)
+daily_rows = water_rows[water_rows.application_id .== :daily_amount, [:timestep, :value]]
+@assert daily_rows.value == [10800.0, 259200.0]
+daily_rows
+```
+
+Each window looks back over a fixed duration; it does not automatically start
+at midnight. The
+[weekly example](../../guides/time/hourly_daily_weekly.md) extends this same
+calculation to seven days and checks the first result separately. Use
+`Aggregate(reducer)` to calculate a mean, minimum, maximum, or another
+statistic from the values in a window.
+
+!!! note "When models declare units and physical meaning"
+
+ A `VariableContract` describes a variable's units and physical meaning.
+ The teaching models above do not declare these contracts. If your models
+ do declare them, the two sides of a connection must match. `Integrate()`
+ changes the values but does not change their declared contract, so it
+ cannot directly connect a rate contract to an amount contract.
+
+ Instead, write a small conversion model. Its input declares the rate
+ contract, and its output declares the amount contract expected by the
+ next model. If the rate changes, calculate each amount when the rate
+ updates, or use a rate correctly averaged over the interval. Multiplying
+ one held value by a long duration is only correct when the rate stays
+ constant over that interval. See [Coupling models](@ref).
+
+Neither example needs `PreviousTimeStep`: no two models wait for each other's
+result in the same step. Use that option only when an equation should read
+the preceding step. See [Diagnosing Dependency Cycles](@ref) for an example.
diff --git a/docs/src/journeys/users/environments.md b/docs/src/journeys/users/environments.md
index d3ab194d7..470506b34 100644
--- a/docs/src/journeys/users/environments.md
+++ b/docs/src/journeys/users/environments.md
@@ -1,34 +1,23 @@
# Understand Environments
-## New concept: declared sampling from global and spatial sources
+## Give models weather and light
-The first simulation used a weather file as supplied forcing. This page now
-makes that contract explicit. A model declares the names it reads from its
-model-facing environment:
+An **environment** supplies values such as air temperature and incident light.
+Here you will first use weather data whose column names differ from the names
+your models expect. Then you will give a sunny canopy more light than a shaded
+one. The models and weather values are chosen to explain these operations.
+
+## Match weather names to model inputs
+
+Suppose your weather data use the names `air_temperature` and `incident_par`,
+but your models expect `T` and `Ri_PAR_f`. Use `sources` in `Environment(...)`
+to say which weather variable supplies each model input. `provider=:global`
+means that the weather values do not depend on the object's position.
```@example journey_environments
using PlantSimEngine, Dates, DataFrames
using PlantSimEngine.Examples
-(
- degree_days=PlantSimEngine.environment_inputs_(
- ToyDegreeDaysCumulModel(),
- ),
- light=PlantSimEngine.environment_inputs_(Beer(0.6)),
-)
-```
-
-`ToyDegreeDaysCumulModel` reads `environment.T`; `Beer` reads
-`environment.Ri_PAR_f`. These are not status inputs and are not outputs owned
-by the target object.
-
-## Global sampling and source names
-
-The source does not need to use the model-facing names. Here a global provider
-has `air_temperature` and `incident_par`; `Environment(...; sources=...)`
-remaps them for the two model applications.
-
-```@example journey_environments
forcing = (
air_temperature=20.0,
incident_par=300.0,
@@ -36,12 +25,12 @@ forcing = (
)
global_model = CompositeModel(
- Object(:plant; scale=:Plant, kind=:plant);
+ Object(:canopy; scale=:Canopy, kind=:canopy);
applications=(
ModelSpec(
ToyDegreeDaysCumulModel();
name=:degree_days,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
environment=Environment(
provider=:global,
sources=(T=:air_temperature,),
@@ -50,12 +39,12 @@ global_model = CompositeModel(
ModelSpec(
ToyLAIModel();
name=:lai,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
),
ModelSpec(
Beer(0.6);
name=:light,
- on=One(scale=:Plant),
+ on=One(scale=:Canopy),
environment=Environment(
provider=:global,
sources=(Ri_PAR_f=:incident_par,),
@@ -71,8 +60,33 @@ global_state = final_state(global_simulation)
(TT_cu=global_state.TT_cu, LAI=global_state.LAI, aPPFD=global_state.aPPFD)
```
-The environment diagnostic distinguishes the variables seen by each model from
-the actual source names:
+`T` and `air_temperature` are in °C. `Ri_PAR_f` and `incident_par` are
+mean PAR fluxes in W m⁻² of ground; changing a source name does not convert
+its units. `aPPFD` is absorbed PAR in μmol m⁻² of ground s⁻¹.
+
+## Inspect the input names
+
+A model declares which external values it needs. You can inspect those names
+without changing the model:
+
+```@example journey_environments
+using PlantSimEngine, Dates, DataFrames
+using PlantSimEngine.Examples
+
+(
+ degree_days=PlantSimEngine.environment_inputs_(
+ ToyDegreeDaysCumulModel(),
+ ),
+ light=PlantSimEngine.environment_inputs_(Beer(0.6)),
+)
+```
+
+`ToyDegreeDaysCumulModel` reads `environment.T`; `Beer` reads
+`environment.Ri_PAR_f`. These values come from the weather data. They are
+separate from values stored on the canopy, such as its LAI.
+
+The table below shows the names each model expects (`required_inputs`) and the
+names it reads from the weather data (`source_inputs`):
```@example journey_environments
select(
@@ -85,17 +99,19 @@ select(
)
```
-Global sampling has no spatial handle. The forcing above is intentionally
-strict: apart from timeline `duration`, it exposes only the two remapped source
-variables. Removing either source makes `validate_environment_inputs` fail
-before simulation.
+The `handle` column is empty because neither model needs a spatial location
+to read this weather. The example supplies only `duration` and the two weather
+variables. If either weather variable is missing,
+`validate_environment_inputs` reports it before you run the simulation.
-## Spatial sampling
+## Give each canopy its own light
-Spatial backends keep the same model-facing declaration. They additionally
-compile an opaque handle for each application/object target. The small
-`ToySpatialEnvironment` example maps object geometry to either a sunny or
-shaded cell:
+When weather or light varies across space, an **environment backend** supplies
+the values at each object's location. The models still read the same input
+names. The small `ToySpatialEnvironment` example below stores a light value in
+each of two cells, named `:sun` and `:shade`. Each canopy's `geometry` says
+which cell to use. These light values are supplied directly; this example does
+not calculate how light travels through a canopy.
```@example journey_environments
spatial_environment = ToySpatialEnvironment(
@@ -108,16 +124,16 @@ spatial_environment = ToySpatialEnvironment(
spatial_model = CompositeModel(
Object(
- :sun_leaf;
- scale=:Leaf,
- kind=:leaf,
+ :sun_canopy;
+ scale=:Canopy,
+ kind=:canopy,
geometry=(cell=:sun,),
status=Status(LAI=2.0),
),
Object(
- :shade_leaf;
- scale=:Leaf,
- kind=:leaf,
+ :shade_canopy;
+ scale=:Canopy,
+ kind=:canopy,
geometry=(cell=:shade,),
status=Status(LAI=2.0),
);
@@ -125,18 +141,24 @@ spatial_model = CompositeModel(
ModelSpec(
Beer(0.6);
name=:light,
- on=Many(scale=:Leaf),
+ on=Many(scale=:Canopy),
environment=Environment(backend=spatial_environment),
),
),
)
spatial_simulation = run!(spatial_model)
-spatial_states = final_state(spatial_simulation, Many(scale=:Leaf))
+spatial_states = final_state(spatial_simulation, Many(scale=:Canopy))
Dict(id => state.aPPFD for (id, state) in spatial_states)
```
-The one `Many` application retains two distinct compiled handles:
+Both canopies have LAI = 2 m² m⁻². The sunny canopy receives four times the
+incident PAR of the shaded canopy, so its absorbed PAR is also four times
+as large. Both results use ground area, not individual leaf area.
+
+The table below lets you check that each canopy reads from its intended cell.
+PlantSimEngine stores this location in a value called a **handle**, so it does
+not need to find the cell again at every step:
```@example journey_environments
select(
@@ -148,19 +170,6 @@ select(
)
```
-The scientific `Beer` kernel is unchanged. It sees only
-`environment.Ri_PAR_f`; the backend owns the meaning of each handle. Backend
-authors can inspect the implementation of `ToySpatialEnvironment` in the
-environment extension reference.
-
-## Page recap
-
-- **You added:** explicit environment declarations, global source remapping,
- and then a spatial backend with object geometry.
-- **PlantSimEngine inferred:** global sampling, validation of required source
- names, and one cached spatial handle per application/object target.
-- **You keep explicit:** model-facing environment names, scenario source
- remaps, provider/backend choice, and geometry used by a spatial backend.
-- **New API names:** `environment_inputs_`, `Environment`,
- `validate_environment_inputs`, `ToySpatialEnvironment`, and
- `Diagnostics.explain_environment_bindings`.
+The `Beer` equation is unchanged. It reads `environment.Ri_PAR_f` in both
+examples; the environment supplies the correct value. To provide your own
+spatial data, see [Environment Backend Extensions](@ref).
diff --git a/docs/src/journeys/users/maespa_synthesis.md b/docs/src/journeys/users/maespa_synthesis.md
index 3b298f3a1..ec64280b6 100644
--- a/docs/src/journeys/users/maespa_synthesis.md
+++ b/docs/src/journeys/users/maespa_synthesis.md
@@ -1,21 +1,27 @@
# MAESPA-Style Synthesis
-## New concept: synthesis without another runtime mechanism
-
-This page is an integrated reference, not an onboarding example. It combines
-the ideas developed independently in the earlier journeys into a small
-MAESPA-style stand: two species, five leaves, hourly canopy and soil exchange,
-daily allocation and LAI, iterative leaf calls, and accepted mutable canopy
-air.
-
-If any individual mechanism is unfamiliar, follow its focused link in
-[How the pieces compose](@ref) before reading the implementation.
+Bring the earlier tutorials together in a small MAESPA-style stand with two
+species and five leaves. Canopy and soil exchange run hourly; carbon
+allocation and LAI run daily. Within each hour, a controller repeatedly runs
+the leaf models to find a consistent canopy air temperature and humidity.
+This is an advanced example; start with the individual tutorials if these
+steps are unfamiliar.
+
+This is an uncalibrated teaching example inspired by MAESPA's process
+structure. It is not a validated MAESPA implementation. Leaf illumination is
+uniform with complete absorption, the canopy has one air layer, and the soil
+water model uses prescribed withdrawals and bounds rather than a complete
+soil hydraulic balance. The example tests coupling and carbon accounting;
+its results should not be used to predict a real stand's behaviour.
+
+The table in [How the pieces compose](@ref) links each part of this example
+to the tutorial that explains it.
## Run the reference case
-The complete, tested source lives in
-`examples/maespa_model_example.jl`. Run 25 hours so both hourly and daily
-applications cross a day boundary:
+The complete, tested source is in `examples/maespa_model_example.jl`. Run it
+with 25 hourly weather records. The daily models run on the first step and
+again 24 hours later, so this is long enough to see two calls:
```@example journey_maespa_synthesis
using PlantSimEngine, DataFrames
@@ -32,8 +38,9 @@ model = result.model
nothing
```
-The model contains one scene, one soil object, and two template instances with
-different species parameters and leaf counts:
+The model contains one object for the whole scene, one soil object, and two
+plants with the same model connections. Each plant has its own template,
+species parameters, and number of leaves:
```@example journey_maespa_synthesis
(
@@ -45,11 +52,13 @@ different species parameters and leaf counts:
)
```
-## Inspect the compiled architecture
+## Check when models run and where their inputs come from
-The execution schedule makes the two cadences and parent-controlled
-applications visible. Leaf energy balance and soil water are call-only under
-the scene controller; allocation and LAI run daily:
+Check when each calculation runs. The scene controller decides when to call
+leaf energy balance and soil water; these models do not run independently.
+Allocation and LAI run once per day. In the table, `root_scheduled` means a
+model runs directly from the simulation schedule, `manual_call_only` means
+another model calls it, and `dt_steps` gives the interval in hourly steps:
```@example journey_maespa_synthesis
schedule = DataFrame(Diagnostics.explain_schedule(result.compiled))
@@ -72,8 +81,10 @@ select(
)
```
-The scene energy-balance application resolves all five leaves through one
-`Many` hard call and the soil through one `One` hard call:
+The scene energy-balance model calls all five leaf models with `Many` and the
+single soil model with `One`. These are **hard calls**: the calling model
+controls when the other models run. The table lists the models and objects
+called from `:scene_eb`:
```@example journey_maespa_synthesis
calls = DataFrame(Diagnostics.explain_calls(result.compiled))
@@ -86,9 +97,14 @@ select(
)
```
-Each plant allocation application receives a live vector of only its own
-descendant leaves. The scene receives stand-wide vectors and one scalar soil
-potential:
+Each plant's allocation model reads carbon values from its own leaves. The
+scene model reads areas from all leaves and water potential from the soil.
+Here `AllocA` and `AllocB` use the same fixed-fraction allocation equation
+with different parameters. For two different allocation rules running on
+two plants together, see [Instantiate Several Plants](several_plants.md).
+Inspect `source_ids` below to check where each input comes from. The values
+are shared by reference, meaning the reader sees the current source values
+without copying them:
```@example journey_maespa_synthesis
bindings = DataFrame(Diagnostics.explain_bindings(result.compiled))
@@ -115,11 +131,17 @@ select(
## Follow trial canopy air to its accepted state
-The scene controller reads above-canopy `:forcing`, iterates leaf models
-against typed trial canopy air with `publish=false`, commits the converged
-state to `sink=:canopy`, and then publishes one accepted leaf execution. Leaf
-applications read the committed `:canopy` provider. Those routes are compiled
-into opaque handles:
+The scene controller starts from the above-canopy weather, named `:forcing`.
+It tries different canopy air conditions and runs the leaf models for each
+trial with `publish=false`, so these intermediate results are not saved in
+the output history. Once the calculation converges, it commits the accepted
+air conditions with `sink=:canopy` and runs the leaves once more to publish
+their accepted results.
+
+The leaf models then read the accepted conditions from `:canopy`. The table
+below shows which environmental variables each model reads or changes. Its
+`handle` column contains an internal identifier used to retrieve those
+conditions; you do not need to interpret that identifier:
```@example journey_maespa_synthesis
environment_bindings = DataFrame(
@@ -142,16 +164,34 @@ select(
)
```
-`MaespaSingleLayerEnvironment` is intentionally a one-layer canopy backend.
-Its handle still separates forcing, canopy, and commit-sink routes per
-application/object. A voxel or multilayer backend can replace it without
-changing the model-facing environment contract; the two-cell proof is in
-[Modify The Environment](@ref), and backend implementation belongs in
+`MaespaSingleLayerEnvironment` supplies one set of air conditions for the
+whole canopy. It keeps the above-canopy weather separate from the canopy
+conditions that the controller changes. You could replace it with an
+environment that represents several layers or 3D cells, while keeping the
+same variables available to the process models. See the two-cell example in
+[Modify The Environment](@ref) and the implementation guide in
[Environment Backend Extensions](@ref).
-## Check the scientific handoffs
+## Check units and carbon accounting
+
+Check the units and conversions as values pass between models:
+
+| Quantity | Meaning and units |
+|---|---|
+| `Ri_SW_f`, `Ri_PAR_f` | Incoming radiation in W m⁻²; PAR is `PlantMeteo.Constants().PAR_fraction` of shortwave energy. |
+| Leaf `aPPFD` | Absorbed photon flux in µmol photons m[leaf]⁻² s⁻¹: `Ri_PAR_f * constants.J_to_umol`, assuming uniform illumination and complete absorption. |
+| Leaf `A` | Net CO₂ assimilation in µmol CO₂ m[leaf]⁻² s⁻¹. |
+| Leaf `leaf_carbon` | Cumulative net assimilation in g elemental C: sum of `A * leaf_area * duration_seconds * 12e-6`. |
+| Plant `daily_growth` | Net C since this plant's previous allocation, in g C per allocation interval. The first call covers only the start of the simulation, not a full day. |
+| Plant carbon pools | Allocated g elemental C, not g dry matter; the unassigned allocation fraction remains in `reserve_pool`. |
+| `scene_transpiration` | Accepted water loss in mm over the current hourly forcing interval. |
+
+Every leaf receives the same above-canopy irradiance here. There is no
+shading, scattering, or leaf-angle calculation; a radiation model would need
+to replace that assumption for a realistic stand.
-The final snapshots expose canonical state independently of retained history:
+Use `final_state` to read the latest values, whether or not you saved their
+history:
```@example journey_maespa_synthesis
scene = final_state(simulation, :model)
@@ -160,18 +200,43 @@ plants = final_state(simulation, Many(scale=:Plant))
(
lai=scene.lai,
- canopy_temperature=scene.canopy_tair,
- transpiration=scene.scene_transpiration,
- soil_water_potential=soil.psi_soil,
- daily_growth=Dict(
+ canopy_temperature_C=scene.canopy_tair,
+ hourly_transpiration_mm=scene.scene_transpiration,
+ soil_water_potential_MPa=soil.psi_soil,
+ allocation_interval_g_C=Dict(
id => state.daily_growth
for (id, state) in plants
),
)
```
-Retained output counts confirm the cadence boundary: hourly scene and leaf
-variables have 25 samples, while daily LAI and allocation variables have two:
+Allocation reads cumulative leaf C without resetting it. Each plant stores
+the cumulative amount it has already accounted for and allocates only the
+difference at the next daily call. This prevents the same carbon being
+allocated again on later days. Carbon gained or lost since the last
+allocation remains pending until the next one:
+
+```@example journey_maespa_synthesis
+DataFrame([
+ (
+ plant=id,
+ cumulative_net_C_g=sum(state.leaf_carbon),
+ accounted_C_g=state.accounted_carbon,
+ pools_C_g=state.leaf_pool + state.wood_pool + state.reserve_pool,
+ pending_C_g=sum(state.leaf_carbon) - state.accounted_carbon,
+ )
+ for (id, state) in plants
+])
+```
+
+At each allocation, the three pools sum to `accounted_carbon`. Adding pending
+C recovers cumulative net assimilation. These accounts can increase or
+decrease: negative net assimilation reduces them. The example does not model initial
+biomass, construction respiration, dry-matter conversion, or limits on
+withdrawing reserves, so the pools are not predictions of organ mass.
+
+Count the saved values to check how often the models ran. Hourly scene and
+leaf variables have 25 samples; daily LAI and allocation variables have two:
```@example journey_maespa_synthesis
output_summary = DataFrame(Diagnostics.explain_outputs(simulation))
@@ -198,45 +263,32 @@ select(
## How the pieces compose
-| Construct in this synthesis | Role here | Focused journey |
+| Part of the example | What it does here | Tutorial |
|---|---|---|
-| `CompositeModelTemplate` and two `ObjectInstance`s | Reuse one species-specific application set across several plants | [Instantiate Several Plants](@ref) |
-| Scene, plant, internode, leaf, and soil objects | Represent one registry without prescribing plant architecture | [Build One Multiscale Plant](@ref) |
-| Scalar and `Many` live-reference bindings | Couple soil-to-scene and leaf-to-plant/scene values | [Build One Multiscale Plant](@ref) |
-| Hourly and daily applications with `HoldLast` | Keep canopy exchange and allocation on scientific cadences | [Give Models Different Cadences](@ref) |
-| Forcing and canopy providers with compiled handles | Sample global forcing and committed canopy state through one contract | [Understand Environments](@ref) |
-| Typed trials and explicit accepted commit | Iterate canopy air without publishing rejected states | [Modify The Environment](@ref) |
-| Nested hard calls and accepted publication | Let scene energy balance control leaf and soil execution | [Control Advanced Execution](@ref) |
-| `Simulation`, final state, and retained streams | Separate current canonical state from requested history | [Couple Models On One Object](@ref) |
-
-This 25-hour reference keeps plant topology fixed because organogenesis is not
-part of its scientific question. A growth model can add, reparent, or remove
-organs through the same registry and refresh machinery; that independent
-lifecycle is demonstrated in [Modify Plant Structure](@ref). Keeping it out of
-this synthesis prevents canopy iteration, daily allocation, and topology
-mutation from becoming one inseparable example.
-
-## Reference invariants
-
-The automated example test verifies the important handoffs rather than exact
-floating-point trajectories:
-
-- the stand contains two isolated instances and five correctly routed leaves;
-- plant allocation vectors contain only descendant leaves;
-- the scene call resolves every leaf and the shared soil application;
-- hourly and daily output counts match their cadences;
-- accepted canopy air is committed separately from above-canopy forcing;
-- leaf fluxes are finite and aggregate consistently at scene scale;
-- both species grow, while their parameterized allocations remain distinct.
-
-## Page recap
-
-- **You added:** no new primitive; you assembled the earlier topology,
- cadence, hard-call, environment, and output mechanisms into one stand.
-- **PlantSimEngine inferred:** application order, plant-local bindings,
- concrete call targets, environment handles, and the hourly/daily schedule.
-- **You keep explicit:** species parameters, topology, scientific iteration,
- accepted state, output requests, and the invariants used to validate the
- result.
-- **New API names:** none. Every API in this synthesis was introduced on a
- focused earlier journey.
+| `CompositeModelTemplate` and two `ObjectInstance`s | Reuse the same models for plants with different species parameters | [Instantiate Several Plants](@ref) |
+| Scene, plant, internode, leaf, and soil objects | Represent the chosen plant structure and shared soil | [Build One Multiscale Plant](@ref) |
+| `One` and `Many` inputs | Read one soil value or a collection of leaf values | [Build One Multiscale Plant](@ref) |
+| Hourly and daily models with `HoldLast` | Keep using the last daily value between daily calculations | [Give Models Different Cadences](@ref) |
+| Above-canopy weather and canopy conditions | Supply each model with the environment it needs | [Understand Environments](@ref) |
+| Trial air conditions and an accepted result | Find consistent canopy conditions, then save the accepted result | [Modify The Environment](@ref) |
+| Hard calls | Let scene energy balance decide when leaf and soil models run | [Control Advanced Execution](@ref) |
+| `Simulation`, `final_state`, and saved outputs | Read current values and analyse changes over time | [Couple Models On One Object](@ref) |
+
+Plant structure stays fixed during this 25-hour example. To add or remove
+organs with a growth model, follow [Modify Plant Structure](@ref).
+
+## What the tests check
+
+The automated tests check that the parts work together as intended:
+
+- the stand contains two plants, with five leaves assigned to the right plant;
+- each plant's allocation model reads only its own leaves;
+- the scene controller calls every leaf and the shared soil model;
+- hourly and daily output counts match their scheduled intervals;
+- accepting new canopy conditions does not replace the above-canopy weather;
+- leaf fluxes are finite and their totals agree with the scene results;
+- PAR energy is bounded by shortwave energy and converted to photon units;
+- a longer run with 73 hourly samples covers three daily intervals plus the
+ initial call, with no carbon added by rejected trial calculations;
+- each plant allocates every C increment once, preserves cumulative leaf C,
+ and conserves the sum of its leaf, wood, reserve, and pending C accounts.
diff --git a/docs/src/journeys/users/mental_model.md b/docs/src/journeys/users/mental_model.md
index ffec0a6b2..fa80d89ca 100644
--- a/docs/src/journeys/users/mental_model.md
+++ b/docs/src/journeys/users/mental_model.md
@@ -1,55 +1,63 @@
# A Mental Model For PlantSimEngine
-## New concept: composition
-
-PlantSimEngine is a framework for composing and running scientific models. It
-does not prescribe a plant architecture, and it is not itself a library of
-crop, tree, or organ equations. Model packages provide those equations;
-PlantSimEngine connects them to simulated entities, orders their execution, and
-manages time, environments, and retained results.
-
-Seven ideas are enough to read a PlantSimEngine simulation:
-
-| Idea | Meaning |
-|:--|:--|
-| **Process** | A scientific responsibility, such as thermal time or light interception |
-| **Model** | One reusable implementation of a process |
-| **Application** | One configured use of a model in a simulation |
-| **Object** | A simulated entity with stable identity, such as a scene, plant, leaf, or soil layer |
-| **Status** | The current values owned by an object |
-| **Environment** | Values sampled from outside object status, such as weather or microclimate |
-| **Simulation** | A running timeline, including the live model, current step, and any retained output streams |
-
-The distinction between a model and an application is important. A model
-author writes an equation once. A simulation author can then apply it to one
-whole plant, every leaf, selected soil layers, or several named groups without
-putting an object loop inside the equation.
-
-Objects are equally general. Scales and parent/child links describe the
-topology chosen by the simulation author; PlantSimEngine does not require a
-particular hierarchy. A simple simulation can have one object. A detailed one
-can have scenes, several plants, organs, voxels, and shared resources.
-
-Values reach a model in two ways:
-
-- status inputs come from the target object or from outputs of other model
- applications;
-- environment inputs are sampled from the environment selected for that
- application and object.
-
-Before running, PlantSimEngine compiles applications into concrete
-application/object targets, resolves value connections, and determines a valid
-execution order. During the timestep loop, model kernels work with their own
-parameters, the resolved status view, the sampled environment, constants, and
-a runtime context.
-
-## Page recap
-
-- **You added:** no configuration yet—only the vocabulary used by every later
- journey.
-- **PlantSimEngine infers:** application order and unambiguous value
- connections once a simulation is assembled.
-- **You keep explicit:** scientific equations, object topology, ambiguous
- cross-object connections, time policies, and requested output history.
-- **New API names:** none yet. The next page introduces `CompositeModel`,
- `run!`, `Simulation`, `final_state`, and `collect_outputs`.
+PlantSimEngine connects scientific models and runs them together. For example,
+one model can calculate the light absorbed by a canopy, and another can use
+that light to calculate biomass production. You choose the equations and the
+parts of the plant or environment they describe.
+
+This page explains the terms used in the guides. If you have not run an
+example yet, start with [your first simulation](one_object.md).
+
+## Models describe calculations; objects describe what is simulated
+
+A **process** is something you want to calculate, such as photosynthesis or
+light interception. A **model** provides the equations for that process.
+Two models for the same process can use different equations or assumptions.
+
+An **object** represents something in your simulation: a leaf, a plant, a
+canopy, or a soil layer, for example. Each object has a **status**, which
+stores its current values, such as leaf area or water content.
+
+You decide how much detail to represent. A canopy can be one object, or it
+can contain several plants with individual leaves. PlantSimEngine does not
+require a particular plant structure.
+
+## An application says where and how to use a model
+
+A **model application** combines a model with instructions about where to run
+it, how often to run it, and where to get its inputs. For example, you could
+apply one photosynthesis model to every leaf and run it once an hour.
+
+You write the photosynthesis equation once. PlantSimEngine then runs it for
+each selected leaf, using that leaf's own values and local conditions. You
+can also apply different models to different groups of leaves or plants.
+
+In the [first simulation](one_object.md), all models describe the same canopy,
+so `CompositeModel` creates these applications for you. Later guides use
+`ModelSpec` to choose objects and connections explicitly.
+
+## Inputs come from object values or the environment
+
+A model needs **inputs** to calculate its **outputs**. For example, a
+light-interception model reads LAI and calculates absorbed light. Another
+model can then use that absorbed light as an input.
+
+Weather and local growing conditions are supplied through an **environment**.
+This can be a weather table shared by the whole simulation, or a source of
+local conditions that differ between leaves or soil layers.
+
+Before the simulation starts, PlantSimEngine checks where each input will
+come from and puts the calculations in order. If a biomass model needs the
+light model's result, the light model runs first. You can inspect these
+connections in the [graph viewer](../../guides/graph_visualizer_editor.md).
+
+## A simulation keeps track of progress and results
+
+Calling `run!` starts a **simulation**. It keeps track of the current time
+step and the current values for every object. It also saves the history of
+the outputs you requested.
+
+Use `final_state` to read the latest values, `collect_outputs` to make a table
+of saved results, and `step!` or `continue!` to advance the same simulation.
+The [results guide](../../guides/data/outputs_plotting.md) shows how to select
+and plot those outputs.
diff --git a/docs/src/journeys/users/mutable_environments.md b/docs/src/journeys/users/mutable_environments.md
index 6840a6214..7fce0d452 100644
--- a/docs/src/journeys/users/mutable_environments.md
+++ b/docs/src/journeys/users/mutable_environments.md
@@ -1,14 +1,21 @@
# Modify The Environment
-## New concept: trial state versus accepted state
+## Try a value before keeping it
-The previous environment journey sampled read-only global and spatial values.
-Now a controller evaluates one typed trial state, accepts a different state,
-and commits it explicitly.
+Some models need to try several temperatures before choosing a solution.
+For example, a model might adjust canopy air temperature until its heat
+balance is close enough to zero. A **controller** is a model that manages
+these repeated calculations.
-Start with one cell. `ToyEnvironmentReaderModel` declares `T` as an environment
-input. The controller declares `T` as an environment output: this is permission
-to commit that variable, not an ordinary object-status output.
+This teaching example shows how to try one temperature, then keep a different
+one. It does not solve a heat balance: the trial and final temperatures are
+chosen in advance. **Committing** the final value means writing it back to
+the environment so that later calculations can use it.
+
+Start with one spatial cell. `ToyEnvironmentReaderModel` reads temperature
+`T` from the environment. The controller declares `T` with
+`environment_outputs_` to say that it may change this environment variable.
+This declaration does not add `T` to the leaf's stored status.
```@example journey_mutable_environment
using PlantSimEngine, DataFrames
@@ -24,9 +31,10 @@ using PlantSimEngine.Examples
)
```
-The controller's kernel uses the current typed trial-state path. A trial call
-changes the callee status for inspection but does not publish output history or
-commit backend state:
+The first function asks the reader to calculate a result using a trial
+environment. You can inspect that result in the reader's status.
+`publish=false` prevents it from being added to the output history, and this
+call does not change the stored environment:
```@example journey_mutable_environment
function run_trial!(context, trial_environment)
@@ -39,7 +47,9 @@ function run_trial!(context, trial_environment)
end
```
-Accepted state is explicit and separate:
+After choosing a final value, save it in the environment with
+`commit_environment!`. Then run the reader with `publish=true` to record
+its accepted result:
```@example journey_mutable_environment
function commit_and_publish!(context, accepted_environment)
@@ -53,9 +63,10 @@ function commit_and_publish!(context, accepted_environment)
end
```
-`ToyEnvironmentControllerModel` applies those two operations in its kernel.
-Configure the reader as its one hard-call target and give only the controller a
-commit sink:
+`ToyEnvironmentControllerModel` performs these two operations in its `run!`
+function. The `calls` setting below lets it run the reader. Only the controller
+has `sink=:cells`, which tells the environment where to save the accepted
+temperature:
```@example journey_mutable_environment
environment = ToySpatialEnvironment(
@@ -104,8 +115,9 @@ state = final_state(simulation)
)
```
-The trial was `30`, but the accepted and committed temperature is `22`.
-Only the accepted reader call published:
+The reader tried 30 °C, then used the accepted value of 22 °C. The environment
+now stores 22 °C. The table below shows that the reader recorded only its
+accepted result:
```@example journey_mutable_environment
select(
@@ -116,10 +128,11 @@ select(
)
```
-## Preserve distinct handles under `Many`
+## Give each leaf its own local conditions
-Extend the same backend to two spatial cells. One `Many` application samples
-both, while each target retains its own compiled handle:
+Now give one leaf a sunny cell and the other a shaded cell. `Many(scale=:Leaf)`
+runs the same reader on both leaves, but each leaf reads its own cell's
+temperature:
```@example journey_mutable_environment
spatial_environment = ToySpatialEnvironment(
@@ -156,6 +169,9 @@ spatial_states = final_state(spatial_simulation, Many(scale=:Leaf))
Dict(id => state.temperature_seen for (id, state) in spatial_states)
```
+The sun leaf reads 26 °C and the shade leaf reads 18 °C. A **handle** stores
+the cell used by each leaf. You can check that the two handles are different:
+
```@example journey_mutable_environment
select(
DataFrame(Diagnostics.explain_environment_bindings(spatial_model)),
@@ -164,18 +180,7 @@ select(
)
```
-This ordinary user page does not require the backend implementation protocol.
-Framework builders can follow [Environment Backend Extensions](@ref); the
-complete MAESPA-style synthesis later combines mutable microclimate, several
-plants, and iterative leaf calls.
-
-## Page recap
-
-- **You added:** one typed trial, one explicit accepted commit, one accepted
- publication, and then two spatial cells.
-- **PlantSimEngine inferred:** the reader call target, publication boundary,
- commit permission check, and distinct `Many` handles.
-- **You keep explicit:** trial and acceptance logic, `publish`, the committed
- variables, controller sink, and backend state type.
-- **New API names:** `environment_outputs_`, `run_call!`,
- `commit_environment!`, `publish`, and `calls`.
+For a larger example that adjusts canopy air conditions and repeats leaf
+calculations across several plants, see [MAESPA-Style Synthesis](@ref).
+To connect your own source of environmental data, see
+[Environment Backend Extensions](@ref).
diff --git a/docs/src/journeys/users/one_object.md b/docs/src/journeys/users/one_object.md
index 350cee5e5..9ead09721 100644
--- a/docs/src/journeys/users/one_object.md
+++ b/docs/src/journeys/users/one_object.md
@@ -1,25 +1,52 @@
# Couple Models On One Object
-## New concept: automatic same-object coupling over time
+In this example, we combine three models to calculate how a canopy develops
+and absorbs light over 30 days. We represent the whole canopy as one
+**object**: one part of the simulated system, with its own values. We do not
+describe individual leaves here.
-This first executable simulation couples three existing models on one object:
+The three models pass values to one another:
-1. `ToyDegreeDaysCumulModel` reads temperature and accumulates thermal time.
-2. `ToyLAIModel` reads cumulative thermal time and computes LAI.
-3. `Beer` reads LAI and radiation and computes absorbed PAR.
+1. `ToyDegreeDaysCumulModel` uses temperature to calculate cumulative thermal
+ time, a measure of accumulated warmth.
+2. `ToyLAIModel` uses that thermal time to calculate leaf area index (LAI).
+3. `Beer` uses LAI and incoming radiation to calculate the light absorbed by the canopy.
-The weather file is supplied forcing data for now. Environments get their own
-journey later.
+These are teaching models with illustrative parameters. They show how to
+connect calculations; their results are not predictions for a particular crop.
+
+Start with the [tutorial installation](../../prerequisites/installing_plantsimengine.md)
+if these packages are not yet available in your Julia project.
+
+## Prepare the weather data
+
+We use a weather file included with PlantSimEngine. Each row describes one day.
+The file records daily radiation totals in MJ m⁻² d⁻¹. `Beer` needs the average
+radiation during that day in W m⁻², so the code below converts those columns.
+`SW` means shortwave radiation, `PAR` is the light used for photosynthesis,
+and `NIR` means near-infrared radiation. The weather data are called the
+**environment** in this simulation.
```@example journey_one_object
using PlantSimEngine, PlantMeteo, Dates, DataFrames
using PlantSimEngine.Examples
weather = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
+ joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"),
+ :Ri_SW_f => (x -> x .* 1e6 ./ 86_400) => :Ri_SW_f,
+ :Ri_PAR_f => (x -> x .* 1e6 ./ 86_400) => :Ri_PAR_f,
+ :Ri_NIR_f => (x -> x .* 1e6 ./ 86_400) => :Ri_NIR_f;
duration=Day,
)
+nothing # hide
+
+```
+
+## Connect the models
+
+Put the three models together in a `CompositeModel` and give it the weather data:
+```@example journey_one_object
model = CompositeModel(
ToyDegreeDaysCumulModel(),
ToyLAIModel(),
@@ -28,39 +55,37 @@ model = CompositeModel(
)
```
-No `ModelSpec` or selector is needed when all models run on the one object made
-by the concise constructor.
+PlantSimEngine connects these models automatically. The thermal-time model
+provides `TT_cu` to the LAI model, which provides `LAI` to `Beer`. Each input
+has the same name as an output from exactly one other model on this canopy.
+PlantSimEngine also runs the models in that order, so each calculation can
+use the result it needs.
-!!! tip "Choose a status type for the whole scenario"
- Add `type_promotion=Dict(Float64 => Float32)` to the constructor to
- materialize every matching status scalar, model input default, and model
- output default as `Float32`. Ordinary numeric arrays are converted element
- by element. Model parameters such as `Beer(0.6)` and values supplied by
- `weather` keep their own types.
+## Run 30 days and look at the results
- Use `status_transform=(variable, value) -> ...` when only selected
- variables need another representation. The precise transform runs before
- the general type mapping. See [Numerical Reliability](@ref) for complete
- `Float32` and uncertainty-propagation examples.
+`run!` starts the simulation. Here, `steps=30` runs the first 30 weather rows,
+and `outputs=:all` saves the results from every model at each step.
+`collect_outputs(simulation; sink=DataFrame)` gathers those saved results
+into a DataFrame, the table type provided by DataFrames.jl. `DataFrame` is
+already the default, so you can also write `collect_outputs(simulation)`.
-Run thirty daily steps and retain the model outputs:
+These first 30 days fall in winter, so thermal time increases slowly and LAI
+stays small. The [plotting guide](../../guides/data/outputs_plotting.md) shows
+how to plot results and compare two canopies.
```@example journey_one_object
simulation = run!(model; steps=30, outputs=:all)
-results = collect_outputs(simulation)
-
-thermal_time = results[results.variable .== :TT_cu, :value]
-lai = results[results.variable .== :LAI, :value]
-evolution = DataFrame(
- step=1:length(thermal_time),
- TT_cu=thermal_time,
- LAI=lai,
-)
-vcat(first(evolution, 3), last(evolution, 3))
+results = collect_outputs(simulation; sink=DataFrame)
+first(select(results, :timestep, :variable, :value), 8)
```
-The table is retained history. The latest values are also available directly,
-whether or not history was requested:
+Each row records one variable on one day. The last line selects three
+columns and shows the first eight rows: four variables for each of the first
+two days. `TT` is daily thermal time and `TT_cu` is cumulative thermal time,
+both in °C d. LAI is leaf area per ground area, in m² m⁻².
+
+`final_state` gives the latest values, here at the end of day 30. It is also
+available when you run a simulation without saving its history:
```@example journey_one_object
state_at_day_30 = final_state(simulation)
@@ -69,26 +94,17 @@ state_at_day_30 = final_state(simulation)
TT_cu=state_at_day_30.TT_cu,
LAI=state_at_day_30.LAI,
aPPFD=state_at_day_30.aPPFD,
- retained_streams=length(outputs(simulation)),
)
```
-PlantSimEngine inferred both status connections because each has one
-unambiguous producer on the same object. This focused diagnostic shows the
-resolved sources and the live reference carriers:
+`aPPFD` measures absorbed photosynthetically active radiation (PAR), the light
+available for photosynthesis. Its unit here is μmol of photons per m² of
+ground per second, averaged over the day. The area refers to the ground
+covered by the canopy, rather than the area of its leaves.
-```@example journey_one_object
-select(
- DataFrame(Diagnostics.explain_bindings(model)),
- :application_id,
- :input,
- :source_application_ids,
- :carrier_kind,
-)
-```
+## Continue for another day
-A `Simulation` owns a continuing timeline. Advancing it does not rebuild a
-separate result object:
+`step!` runs the next day and adds its results to the same simulation:
```@example journey_one_object
step!(simulation)
@@ -96,16 +112,6 @@ state_at_day_31 = final_state(simulation)
(current_step=current_step(simulation), TT_cu=state_at_day_31.TT_cu)
```
-## Page recap
-
-- **You added:** three models, supplied weather, a 30-step run, and retained
- outputs.
-- **PlantSimEngine inferred:** the one object, three applications, their
- execution order, and the `TT_cu` and `LAI` connections.
-- **You keep explicit:** model parameters, forcing data, number of steps, and
- whether output history is retained.
-- **New API names:** `CompositeModel`, `run!`, `Simulation`, `final_state`,
- `collect_outputs`, `outputs`, `current_step`, `step!`, and
- `Diagnostics.explain_bindings`.
-- **Optional status policies:** `type_promotion` for a general type mapping and
- `status_transform` for a variable-specific conversion.
+You have now extended the same history to day 31. Continue with
+[several independent objects](several_objects.md), or
+[plot the results](../../guides/data/outputs_plotting.md).
diff --git a/docs/src/journeys/users/one_plant.md b/docs/src/journeys/users/one_plant.md
index cbb13eae8..3bf3c8c11 100644
--- a/docs/src/journeys/users/one_plant.md
+++ b/docs/src/journeys/users/one_plant.md
@@ -1,24 +1,35 @@
# Build One Multiscale Plant
-## New concept: topology and cross-object values
+Share an absorbed-light supply between two leaves, then compute how their
+areas contribute to the plant total. This teaching example represents the
+plant and its two leaves as three objects. Each leaf names the plant as its
+`parent`, which records that the leaf belongs to that plant. The leaf areas
+and radiation values are illustrative.
-The previous journey used independent objects at one scale. A multiscale plant
-adds parent/child topology: one plant object owns two leaf objects.
+The structure is:
-The scope picture for this page is:
+> `:plant`
+> ├─ `:leaf_1`
+> └─ `:leaf_2`
-> `:plant` — `Subtree()` from here contains `:plant`, `:leaf_1`, and `:leaf_2`
-> ├─ `:leaf_1` — `Self()` is `:leaf_1`; `SelfPlant()` resolves to `:plant`
-> └─ `:leaf_2` — `Self()` is `:leaf_2`; `SelfPlant()` resolves to `:plant`
+To choose objects for a calculation, use a **selector** such as
+`Many(scale=:Leaf)`. The `within` option limits where to look. For a model
+running on `:plant`, `Subtree()` includes that plant and everything below it,
+so `Many(scale=:Leaf, within=Subtree())` selects its two leaves. For a model
+running on a leaf, `Self()` means that leaf, and `SelfPlant()` means its plant.
-Selectors still filter that scope. For example,
-`Many(scale=:Leaf, within=Subtree())` selects the two leaves when evaluated for
-the plant application.
+## First pass: share light between leaves
-## First pass: one scalar value from plant to leaves
+Start by supplying the leaf areas and their total in `Status`, where each
+object stores its values. Each leaf model will read the plant's absorbed
+light and total leaf area to calculate its own share.
-Start with leaf surfaces and total plant surface supplied as status. The only
-new value connection sends the plant-level absorbed light to each leaf.
+We use one **common reference ground area** for the whole plant. Its supplied
+`aPPFD` is 120 μmol m⁻² of reference ground s⁻¹. Each leaf receives a share in
+proportion to its area: `120 × 1/3 = 40` and `120 × 2/3 = 80`, on that same
+ground-area basis. These contributions can be added to recover 120. They are
+not photon flux densities per unit leaf area. This deliberately simple share
+does not calculate shading or 3D light interception.
```@example journey_one_plant
using PlantSimEngine, DataFrames
@@ -75,8 +86,17 @@ scalar_states = final_state(scalar_simulation, Many(scale=:Leaf))
Dict(id => state.aPPFD for (id, state) in scalar_states)
```
-The leaf model reads its own `surface` directly from each leaf status.
-`SelfPlant()` makes the other two scalar sources plant-local:
+The areas stored in `surface` are in m² of leaves. A leaf photosynthesis model
+may instead need light per m² of leaf. To make that conversion, multiply a
+leaf's contribution by the plant's reference ground area, then divide by
+that leaf's area. Write this conversion as a model so the units are clear
+when connecting the two calculations; see [Coupling models](../../guides/coupling.md).
+
+Each leaf model reads `surface` from that leaf and uses `SelfPlant()` to find
+the plant's `aPPFD` and total `surface`. In the table below, `consumer_id`
+identifies the object reading a value, and `source_ids` identifies where the
+value comes from. `carrier_kind` describes how PlantSimEngine shares it;
+`ref` means the model reads the source's current value directly.
```@example journey_one_plant
select(
@@ -88,15 +108,20 @@ select(
)
```
-## Second pass: compute and aggregate leaf surfaces
+## Second pass: calculate leaf areas and their total
Now replace the supplied surfaces with two existing models:
- `ToyLeafSurfaceModel` computes each leaf surface from its carbon biomass;
- `ToyPlantLeafSurfaceModel` sums those leaf surfaces on the plant.
-This is the first vector-like cross-object input. It comes after the scalar
-connection above, and differs only in the new `:leaf_surfaces` binding.
+Here the leaf carbon biomasses are 50 and 100 g C, and the specific leaf area
+is 0.02 m² g C⁻¹. Their calculated areas are therefore 1 and 2 m², preserving
+the light shares from the first pass.
+
+The plant model now needs a collection of values: one area from each leaf.
+The `Many(...)` selector for `:leaf_surfaces` provides that collection, which
+`ToyPlantLeafSurfaceModel` adds together.
```@example journey_one_plant
computed_objects = (
@@ -174,8 +199,14 @@ leaf_states = final_state(computed_simulation, Many(scale=:Leaf))
)
```
-The plant aggregation uses a live `RefVector`; the scalar connections remain
-single references:
+The resulting plant surface should be 3 m² and the light contributions should
+still be 40 and 80 μmol m⁻² of reference ground s⁻¹. You can now
+[use different models on two plants in the same simulation](several_plants.md).
+
+The optional table below lets you check the connections. For
+`:leaf_surfaces`, the plant should read from both leaves. Its `RefVector`
+holds references to their current areas, so the plant sees the new values
+after the leaf models update them. Inputs with just one source use `ref`.
```@example journey_one_plant
select(
@@ -187,14 +218,3 @@ select(
:carrier_kind,
)
```
-
-## Page recap
-
-- **You added:** parent/child topology, plant-to-leaf scalar connections, and
- one plant-local vector aggregation.
-- **PlantSimEngine inferred:** same-leaf `surface` coupling, application order,
- and live scalar/vector reference carriers.
-- **You keep explicit:** object parentage, the scope of cross-object searches,
- and the source application when selecting a produced value.
-- **New API names:** `parent`, `Self`, `SelfPlant`, `Subtree`, and the
- `application` and `var` selector fields.
diff --git a/docs/src/journeys/users/several_objects.md b/docs/src/journeys/users/several_objects.md
index 19fba3432..980442c0c 100644
--- a/docs/src/journeys/users/several_objects.md
+++ b/docs/src/journeys/users/several_objects.md
@@ -1,79 +1,76 @@
# Run The Coupling On Several Objects
-## New concept: stable object identity and `Many`
-
-The previous page ran one model chain on one automatically created object. The
-smallest extension is to create two same-scale objects and target both with one
-reusable `Many` selector.
+Run the same teaching models on two independent canopies with different
+initial development stages. Each canopy is one object; `Many(scale=:Canopy)`
+selects both. This lets you compare the two canopies without writing a loop
+inside each model. We start the canopies at different cumulative thermal
+times so their leaf area indices differ visibly over this short, five-day run.
```@example journey_several_objects
using PlantSimEngine, PlantMeteo, Dates, DataFrames
using PlantSimEngine.Examples
weather = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
+ joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"),
+ :Ri_SW_f => (x -> x .* 1e6 ./ 86_400) => :Ri_SW_f,
+ :Ri_PAR_f => (x -> x .* 1e6 ./ 86_400) => :Ri_PAR_f,
+ :Ri_NIR_f => (x -> x .* 1e6 ./ 86_400) => :Ri_NIR_f;
duration=Day,
)
-plants = (
+canopies = (
Object(
- :plant_a;
- scale=:Plant,
- kind=:plant,
- status=Status(TT_cu=0.0),
+ :canopy_a;
+ scale=:Canopy,
+ kind=:canopy,
+ status=Status(TT_cu=600.0),
),
Object(
- :plant_b;
- scale=:Plant,
- kind=:plant,
- status=Status(TT_cu=200.0),
+ :canopy_b;
+ scale=:Canopy,
+ kind=:canopy,
+ status=Status(TT_cu=900.0),
),
)
-plant_targets = Many(scale=:Plant)
+canopy_targets = Many(scale=:Canopy)
model = CompositeModel(
- plants...;
+ canopies...;
applications=(
ModelSpec(
ToyDegreeDaysCumulModel();
name=:degree_days,
- on=plant_targets,
+ on=canopy_targets,
),
- ModelSpec(ToyLAIModel(); name=:lai, on=plant_targets),
- ModelSpec(Beer(0.6); name=:light, on=plant_targets),
+ ModelSpec(ToyLAIModel(); name=:lai, on=canopy_targets),
+ ModelSpec(Beer(0.6); name=:light, on=canopy_targets),
),
environment=weather,
)
```
-`:plant_a` and `:plant_b` are stable object identities. Their initial
-cumulative thermal times differ, but the same three model kernels execute for
-both. The model implementations contain no loop over plants.
-
-The application diagnostic confirms that each application compiled to both
-objects:
-
-```@example journey_several_objects
-select(
- DataFrame(Diagnostics.explain_applications(model)),
- :application_id,
- :target_ids,
-)
-```
+The names `:canopy_a` and `:canopy_b` identify the canopies throughout the
+simulation. Both use the same three models, but each keeps its own values.
+PlantSimEngine runs the models for each canopy, so the model code does not
+need a loop over canopies.
-Run five steps and inspect each independent final status:
+Run five daily steps and read the final values for each canopy:
```@example journey_several_objects
simulation = run!(model; steps=5, outputs=:all)
-states = final_state(simulation, Many(scale=:Plant))
+states = final_state(simulation, Many(scale=:Canopy))
Dict(
id => (TT_cu=state.TT_cu, LAI=state.LAI, aPPFD=state.aPPFD)
for (id, state) in states
)
```
-Retained streams are keyed by application, object, and variable, so the two
-objects do not overwrite one another:
+LAI is in m² of leaves per m² of ground; `aPPFD` is in μmol of absorbed PAR
+per m² of ground per second. The CSV radiation totals were converted to mean
+fluxes just as in the one-object example.
+
+The saved results include the model application name, canopy name, and
+variable name. This keeps the two LAI time series separate:
```@example journey_several_objects
rows = collect_outputs(simulation)
@@ -86,17 +83,18 @@ lai_rows = rows[rows.variable .== :LAI, [
first(lai_rows, 6)
```
-This remains a same-scale simulation. Parent/child topology and cross-object
-value selection are introduced on the next journey, after independent object
-execution is established.
+You can also check which objects each model will run on. The table below
+should list both canopies for each application:
-## Page recap
+```@example journey_several_objects
+select(
+ DataFrame(Diagnostics.explain_applications(model)),
+ :application_id,
+ :target_ids,
+)
+```
-- **You added:** two explicit `Object`s, stable ids, one shared `Many` selector,
- and named `ModelSpec` applications.
-- **PlantSimEngine inferred:** two targets per application plus independent
- same-object `TT_cu` and `LAI` connections for each plant.
-- **You keep explicit:** which objects exist, their initial status, application
- names, and the selector describing the target set.
-- **New API names:** `Object`, `Status`, `ModelSpec`, `Many`, and
- `Diagnostics.explain_applications`.
+The two canopies run independently here. In [one multiscale plant](one_plant.md),
+you will connect a plant to its leaves and pass values between them. To
+compare the canopy time series visually, follow
+[Collecting And Plotting Outputs](../../guides/data/outputs_plotting.md).
diff --git a/docs/src/journeys/users/several_plants.md b/docs/src/journeys/users/several_plants.md
index a468532c1..6b1e5d0e8 100644
--- a/docs/src/journeys/users/several_plants.md
+++ b/docs/src/journeys/users/several_plants.md
@@ -1,84 +1,89 @@
# Instantiate Several Plants
-## New concept: templates, instances, and overrides
+Two plants can use different equations for the same process in one
+simulation. Here both plants receive the same amount of carbon, but use
+different rules to divide it between leaves, wood, and reserves. This lets
+you compare the rules while keeping the inputs the same.
-The previous journey configured one plant explicitly. Its three applications
-can become a `CompositeModelTemplate`, then be mounted on several independent
-object topologies without duplicating that model configuration.
+We represent each whole plant as one object. For an example with individual
+leaves, see [one multiscale plant](one_plant.md).
+
+## Choose two allocation rules
+
+Both models describe **carbon allocation**: deciding where available carbon
+goes. They read the same inputs and produce the same outputs, so either can
+fill the `:allocation` application in our plant template. Their equations
+differ:
+
+- `FixedFractionAllocation(0.5, 0.3)` assigns up to 50% of the available
+ carbon to leaves and up to 30% to wood. Neither receives more than its
+ demand. Carbon left over goes into reserves.
+- `DemandAllocation()` divides carbon in proportion to leaf and wood demand.
+ When there is enough carbon, both demands are met. Any excess goes into
+ reserves.
+
+You can see the difference in the equations. Let `C` be available carbon,
+`L` leaf demand, and `W` wood demand. `min(a, b)` means the smaller of the
+two amounts:
+
+| Carbon sent to | Fixed fractions | Proportional to demand |
+|---|---|---|
+| Leaves | `min(0.5 * C, L)` | `min(C, L + W) * L / (L + W)` |
+| Wood | `min(0.3 * C, W)` | `min(C, L + W) * W / (L + W)` |
+| Reserves | What remains after leaf and wood allocation | What remains after leaf and wood allocation |
+
+If both demands are zero, both models put all available carbon into reserves.
+
+These are teaching rules, not calibrated models of particular species.
+All amounts are grams of elemental carbon (g C) per plant. The example does
+not convert carbon into dry biomass or subtract respiration losses, and
+neither rule withdraws carbon from reserves.
+The carbon offer is the amount available after any respiration costs. Offers
+and demands must be finite and nonnegative.
+
+Load the models from the example source included with PlantSimEngine:
```@example journey_several_plants
-using PlantSimEngine, DataFrames
-using PlantSimEngine.Examples
+using PlantSimEngine, Dates, DataFrames
+
+include(joinpath(pkgdir(PlantSimEngine), "examples", "two_plant_allocation.jl"))
+using .TwoPlantAllocationExample
+```
+## Make a template and two plants
+
+A `CompositeModelTemplate` stores models and their configuration for reuse.
+Our template uses the fixed-fraction rule by default. The application name
+`:allocation` identifies the calculation we will replace on the second plant.
+
+```@example journey_several_plants
plant_template = CompositeModelTemplate((
ModelSpec(
- ToyLeafSurfaceModel(0.02);
- name=:leaf_surface,
- on=Many(scale=:Leaf),
- ),
- ModelSpec(
- ToyPlantLeafSurfaceModel();
- name=:plant_surface,
+ FixedFractionAllocation(0.5, 0.3);
+ name=:allocation,
on=One(scale=:Plant),
- inputs=(
- :leaf_surfaces => Many(
- scale=:Leaf,
- within=Subtree(),
- application=:leaf_surface,
- var=:surface,
- ),
- ),
- ),
- ModelSpec(
- ToyLightPartitioningModel();
- name=:leaf_light,
- on=Many(scale=:Leaf),
- inputs=(
- :aPPFD_larger_scale => One(
- scale=:Plant,
- within=SelfPlant(),
- var=:aPPFD,
- ),
- :total_surface => One(
- scale=:Plant,
- within=SelfPlant(),
- application=:plant_surface,
- var=:surface,
- ),
- ),
),
))
+nothing # hide
```
-An `ObjectInstance` supplies the concrete root and organs. These two instances
-reuse the same template while keeping different initial plant radiation and
-leaf biomasses:
+Each `ObjectInstance` supplies a plant and its initial values. Here `root`
+means the object at the top of the plant's structure, not a botanical root.
+Both plants have 10 g C available today, a leaf demand of 8 g C, and a wood
+demand of 2 g C.
+
+Plant A keeps the template's model. Plant B uses `overrides` to replace that
+model with `DemandAllocation()`:
```@example journey_several_plants
plant_a = ObjectInstance(
:plant_a,
plant_template;
root=Object(
- :plant_a_root;
+ :plant_a;
scale=:Plant,
kind=:plant,
- status=Status(aPPFD=120.0),
- ),
- objects=(
- Object(
- :plant_a_leaf_1;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_a_root,
- status=Status(carbon_biomass=50.0),
- ),
- Object(
- :plant_a_leaf_2;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_a_root,
- status=Status(carbon_biomass=100.0),
- ),
+ status=Status(carbon_offer=10.0, leaf_demand=8.0, wood_demand=2.0),
),
)
@@ -86,119 +91,69 @@ plant_b = ObjectInstance(
:plant_b,
plant_template;
root=Object(
- :plant_b_root;
+ :plant_b;
scale=:Plant,
kind=:plant,
- status=Status(aPPFD=200.0),
- ),
- objects=(
- Object(
- :plant_b_leaf_1;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_b_root,
- status=Status(carbon_biomass=50.0),
- ),
- Object(
- :plant_b_leaf_2;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_b_root,
- status=Status(carbon_biomass=50.0),
- ),
+ status=Status(carbon_offer=10.0, leaf_demand=8.0, wood_demand=2.0),
),
+ overrides=(allocation=DemandAllocation(),),
)
-
-model = CompositeModel(plant_a, plant_b)
-simulation = run!(model; outputs=:all)
-plant_states = final_state(simulation, Many(scale=:Plant))
-Dict(id => (surface=state.surface, aPPFD=state.aPPFD) for (id, state) in plant_states)
+nothing # hide
```
-Plant A aggregates surfaces `1 + 2 = 3`; plant B aggregates `1 + 1 = 2`.
-Those totals prove that `Subtree()` did not mix leaves between instances.
-Likewise, each pair of leaf-level light outputs sums to its own plant's
-supplied light:
+The process is the same on both plants: `:carbon_allocation`. The model used
+for that process is different. Each plant keeps its own inputs and carbon
+pools; replacing Plant B's model does not change Plant A's model.
-```@example journey_several_plants
-leaf_states = final_state(simulation, Many(scale=:Leaf))
-(
- plant_a_light=sum(
- leaf_states[id].aPPFD
- for id in (:plant_a_leaf_1, :plant_a_leaf_2)
- ),
- plant_b_light=sum(
- leaf_states[id].aPPFD
- for id in (:plant_b_leaf_1, :plant_b_leaf_2)
- ),
-)
-```
+## Run both plants together
-The instance diagnostic shows the mounted object and application ids. Template
-application names are prefixed automatically, so the two mounted graphs remain
-unambiguous:
+Put both plants in one `CompositeModel`. One step lasts one day here:
```@example journey_several_plants
-select(
- DataFrame(Diagnostics.explain_instances(model)),
- :name,
- :root_id,
- :object_ids,
- :application_ids,
+model = CompositeModel(plant_a, plant_b; environment=(duration=Day(1),))
+simulation = run!(model; steps=1, outputs=:all)
+results = collect_outputs(simulation; sink=DataFrame)
+
+allocation_rows = filter(
+ :variable => v -> v in (:leaf_growth, :wood_growth, :reserve_change),
+ results,
)
+comparison = unstack(allocation_rows, :object_id, :variable, :value)
+select(comparison, :object_id, :leaf_growth, :wood_growth, :reserve_change)
```
-## Override one instance
+`collect_outputs` returns a table with one row per variable. We select the
+three daily allocations, then use `unstack` to give each variable its own
+column. Each row of the displayed table now describes one plant.
-Only after the two unchanged instances work, override one application for a
-new instance. This plant uses a larger specific leaf area while retaining the
-same logical `:leaf_surface` application and all other template wiring:
+Plant A sends **5 g C to leaves, 2 to wood, and 3 to reserves**. Its wood
+fraction would give 3 g C, but wood only demands 2. The unused carbon stays
+in reserves rather than being reassigned to leaves.
-```@example journey_several_plants
-plant_c = ObjectInstance(
- :plant_c,
- plant_template;
- root=Object(
- :plant_c_root;
- scale=:Plant,
- kind=:plant,
- status=Status(aPPFD=120.0),
- ),
- objects=(
- Object(
- :plant_c_leaf_1;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_c_root,
- status=Status(carbon_biomass=50.0),
- ),
- Object(
- :plant_c_leaf_2;
- scale=:Leaf,
- kind=:leaf,
- parent=:plant_c_root,
- status=Status(carbon_biomass=100.0),
- ),
- ),
- overrides=(leaf_surface=ToyLeafSurfaceModel(0.04),),
-)
+Plant B sends **8 g C to leaves, 2 to wood, and 0 to reserves**. The available
+10 g C is enough to meet both demands. In both cases, the three amounts add
+up to the 10 g C supplied.
-override_simulation = run!(CompositeModel(plant_c))
-override_state = final_state(override_simulation, One(scale=:Plant))
-override_state.surface
-```
-
-There is no `SceneScope()` in this example because nothing is deliberately
-shared between plants. Introduce scene-wide scope only when adding a real
-shared source, such as a soil object or scene-level forcing controller.
+The models also keep cumulative `leaf_carbon`, `wood_carbon`, and
+`reserve_carbon` values. These pools start at zero here, so after one day
+they equal the amounts just allocated. They record allocated carbon, not
+predicted organ biomass.
-## Page recap
+```@example journey_several_plants
+fixed = final_state(simulation, :plant_a) # hide
+demand = final_state(simulation, :plant_b) # hide
+@assert (fixed.leaf_growth, fixed.wood_growth, fixed.reserve_change) == (5.0, 2.0, 3.0) # hide
+@assert (demand.leaf_growth, demand.wood_growth, demand.reserve_change) == (8.0, 2.0, 0.0) # hide
+@assert fixed.leaf_carbon + fixed.wood_carbon + fixed.reserve_carbon == 10.0 # hide
+@assert demand.leaf_carbon + demand.wood_carbon + demand.reserve_carbon == 10.0 # hide
+nothing # hide
+```
-- **You added:** one reusable `CompositeModelTemplate`, two independent
- `ObjectInstance`s, and then one application override.
-- **PlantSimEngine inferred:** instance-local selector scopes, prefixed mounted
- application ids, and the same compiled coupling graph for each plant.
-- **You keep explicit:** each instance's objects and initial values, plus the
- exact application replaced by an override.
-- **New API names:** `CompositeModelTemplate`, `ObjectInstance`, `overrides`,
- and `Diagnostics.explain_instances`.
+To compare other conditions, change `carbon_offer`, `leaf_demand`, or
+`wood_demand` when constructing the plants. These supplied values stay fixed
+in this example: each new day supplies another 10 g C to each plant and
+renews the demands. The carbon pools accumulate over those days. A longer
+simulation could obtain changing daily values from
+photosynthesis and organ-demand models. See
+[Collect and plot results](../../guides/data/outputs_plotting.md) for comparing
+the resulting time series.
diff --git a/docs/src/journeys/users/structure_changes.md b/docs/src/journeys/users/structure_changes.md
index b09749df7..ff182070a 100644
--- a/docs/src/journeys/users/structure_changes.md
+++ b/docs/src/journeys/users/structure_changes.md
@@ -1,11 +1,10 @@
# Modify Plant Structure
-## New concept: lifecycle changes refresh compiled targets
-
Start with one plant, one branch, and two leaves. Each leaf computes carbon
-demand, then treats that fully met demand as accepted carbon allocation for
-`ToyCBiomassModel`. This small chain gives us a conserved quantity to check
-while topology changes.
+demand. We assume enough carbon is available to meet that demand and pass the
+full amount to `ToyCBiomassModel`, which calculates growth and respiration.
+We will add a leaf, move it to the branch, and remove another leaf. After
+each change, we can check that the carbon balance still holds.
```@example journey_structure
using PlantSimEngine, DataFrames
@@ -61,9 +60,10 @@ initial_targets = only(
## Add one leaf
-Registering an object mutates the live model and marks affected compiled state
-dirty. Because this call happens between simulation steps, the new leaf is
-compiled before the next step:
+Use `register_object!` to add a leaf with its initial thermal time. The
+simulation then needs to update which leaves its models run on. Here we add
+the leaf between steps, so PlantSimEngine makes that update before running
+the next step with `continue!`:
```@example journey_structure
register_object!(
@@ -101,16 +101,18 @@ targets_after_refresh = only(
)
```
-When a lifecycle operation occurs *inside* a model kernel, PlantSimEngine
-refreshes after that application. A newly registered object may therefore run
-applications that remain later in the same timestep. It runs an application
-that already completed only through an explicit `Initializer` binding and
-`run_initializer!` call from its creator.
+You can also create organs inside a model's `run!` function, for example in
+a growth model. In that case, PlantSimEngine updates the simulation after
+that model finishes. The new organ can take part in calculations scheduled
+later in the same timestep. Calculations that already ran are not repeated
+automatically: to run one of them on the new organ, the growth model must
+declare an `Initializer` and call `run_initializer!`. See
+[Manual Calls Across Objects](../../guides/multiscale/manual_calls.md).
## Reparent, then remove
-Creation now works, so make two further changes in order. First move the new
-leaf under the branch and advance:
+First change the new leaf's parent from the plant to the branch, then advance
+the simulation:
```@example journey_structure
reparent_object!(model, :leaf_3, :branch)
@@ -145,8 +147,9 @@ continue!(simulation)
## Check conservation and history
-For every retained leaf sample, accepted carbon allocation equals demand. The
-biomass model partitions it into biomass increment plus growth respiration:
+In this example, each leaf receives all the carbon it demands. For every
+saved timestep, check that this carbon equals the increase in biomass plus
+the carbon used in growth respiration:
```@example journey_structure
rows = collect_outputs(simulation; sink=nothing)
@@ -176,8 +179,7 @@ all(
)
```
-Removed-object history remains queryable even though the object is no longer
-in the registry:
+You can still read a removed leaf's earlier results:
```@example journey_structure
history_counts = Dict(
@@ -191,20 +193,10 @@ history_counts = Dict(
)
```
-`:leaf_2` keeps the three samples published before removal; `:leaf_3` begins at
-step 2, after registration, and also has three samples.
+`:leaf_2` keeps the three samples saved before removal. Results for `:leaf_3`
+begin at step 2, after it was added, and also contain three samples.
`ToyCAllocationModel` is useful when supply is limiting and a plant controller
-must divide carbon among many organ demands. This journey deliberately assumes
-all demand is accepted so lifecycle timing and conservation stay visible
-without introducing a controller or hard calls.
-
-## Page recap
-
-- **You added:** one leaf, then one reparenting operation, then one removal.
-- **PlantSimEngine inferred:** the affected application targets, status views,
- reference binding, execution batch extension, and retained stream keys.
-- **You keep explicit:** initialized status for a new object, its parent,
- conservation assumptions, and when removal or reparenting occurs.
-- **New API names:** `register_object!`, `reparent_object!`, `remove_object!`,
- and `Diagnostics.explain_applications(simulation)`.
+must divide carbon among organs. Here we assume there is enough carbon to
+meet every demand, so you can focus on when organs are added or removed and
+how to check their results.
diff --git a/docs/src/migration_composite_model.md b/docs/src/migration_composite_model.md
index 395952952..8196a2d87 100644
--- a/docs/src/migration_composite_model.md
+++ b/docs/src/migration_composite_model.md
@@ -1,11 +1,16 @@
# 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](journeys/users/one_object.md).
+
## Refining early CompositeModel/Object code
-The stabilized public surface makes several early CompositeModel/Object behaviors
-explicit:
+Some names and defaults changed during development of the CompositeModel/Object
+API. Update early examples as follows:
-| Early spelling or behavior | Stabilized API |
+| Early spelling or behavior | Current API |
| --- | --- |
| `Self()` searched self and descendants | `Self()` selects only the current object; use `Subtree()` for self plus descendants |
| omitted `tracked_outputs` retained everything | use explicit `outputs=:all`; the safe default is `outputs=:none` |
@@ -18,7 +23,7 @@ explicit:
`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 target by canonical `application=...` or application ID. Model-authored
+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.
@@ -35,8 +40,8 @@ step!(simulation)
@assert current_step(simulation) == 49
```
-The composite-model/object API replaces the historical multiscale mapping system with
-one object-address graph.
+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:
@@ -48,8 +53,8 @@ Updates
Environment
```
-Process-model implementations do not need to know about composite models, plants, objects, or
-timesteps. They keep the existing kernel contract:
+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)
@@ -64,8 +69,8 @@ equivalents.
## Explicit Input Initialization
-Input literals no longer double as ambiguous placeholder values. Declare
-whether each model input is required or genuinely has a fallback:
+For each input, say whether a value must be supplied or whether the model
+can use a default:
```julia
# Old, ambiguous
@@ -113,8 +118,8 @@ model = CompositeModel(
)
```
-`Object` labels describe runtime entities. They do not prescribe plant
-topology. A plant may use any hierarchy of plants, axes, internodes, segments,
+`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
@@ -169,10 +174,9 @@ model = CompositeModel(
`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. A deliberate
-import boundary may provide `status=node -> import_status(node)` explicitly;
-maintained workflows should initialize scientific state through model objects or
-model applications instead.
+`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
@@ -225,8 +229,9 @@ ModelSpec(
)
```
-Same-rate bindings use shared references or reference vectors when possible.
-Cross-rate bindings use typed temporal streams.
+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
@@ -242,8 +247,8 @@ ModelSpec(SceneWaterBalance(); name=:scene_water, on=One(scale=:Scene), inputs=(
),))
```
-The compiler chooses the carrier. Scenario authors declare the source objects,
-source variable, and temporal policy rather than a route implementation.
+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
@@ -282,10 +287,10 @@ function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment,
end
```
-`run_call!` defaults to `publish=false`. Trial calls mutate target status but
-do not publish temporal samples or commit mutable environment updates.
-do not append temporal samples or write environment outputs. The accepted
-state must use `publish=true`.
+`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
@@ -450,24 +455,25 @@ model object together and, by default, reuses the status initialization policy f
`CompositeModel(mtg; status=...)`. Use `register_object!` when adapting another topology
backend or when a complete `Object` already exists.
-Structural changes refresh application targets, input carriers, call targets,
-writer validation, and schedules after the application that made the change.
-New objects can therefore run applications that remain later in the current
-timestep, but never applications that already ran. Geometry-only changes
-refresh environment bindings without rebuilding unrelated structural bindings.
+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](guides/multiscale/manual_calls.md).
+Changing geometry alone updates the affected environment connections.
-The refreshed runtime also rebuilds homogeneous execution batches. Use
-`Diagnostics.explain_execution_plan(scene_or_simulation)` to inspect the concrete
-model/status/carrier types and the objects grouped into each specialized inner
-loop. Exceptional per-object model overrides appear as separate ordered
-batches.
+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 one-object state, `outputs(sim)` for retained typed streams,
-`Diagnostics.explain_outputs(sim)` for structured diagnostics, and
-`collect_outputs(sim)` for tabular rows.
+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(
@@ -483,8 +489,7 @@ sim = run!(model; steps=48, outputs=request)
daily = collect_outputs(sim, :leaf_transpiration_daily)
```
-CompositeModel output requests are materialized from retained temporal streams after
-the run. They use the same temporal policies as multirate inputs and export
+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
@@ -492,9 +497,9 @@ 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. Dependency-only streams retain a
-bounded policy-specific horizon, while requested streams keep complete
-histories for post-run export. Export is not yet a fully online path.
+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
@@ -512,9 +517,9 @@ Diagnostics.explain_schedule(model)
Diagnostics.explain_writers(model)
```
-These functions return structured rows with concrete object ids, application
-ids, processes, variables, temporal policies, carrier semantics, and resolved
-targets. They are intended for both users and coding agents.
+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
diff --git a/docs/src/model_execution.md b/docs/src/model_execution.md
index 6e5a5c907..b55373442 100644
--- a/docs/src/model_execution.md
+++ b/docs/src/model_execution.md
@@ -1,10 +1,11 @@
# Model Execution
-This page describes how the native composite-model/object runtime executes model
-applications. Use this path for new multi-object, multi-plant, soil,
-microclimate, and multirate simulations.
+This reference explains how PlantSimEngine prepares and runs a simulation.
+Start with [your first simulation](journeys/users/one_object.md) if you are
+new to the package.
-The public configuration surface has one application constructor:
+A **model application** says where to run a model and how to supply its
+inputs. Use `ModelSpec` to describe one:
```julia
ModelSpec(
@@ -20,30 +21,33 @@ ModelSpec(
)
```
-Scenarios start from `CompositeModel` and model applications.
+Put these applications and the objects they describe into a `CompositeModel`.
## Model Kernels And Applications
-A model kernel is still an ordinary PlantSimEngine model:
+A **kernel** is the `run!` function that evaluates a model's equations.
+The model also declares what those equations need and produce:
- `inputs_(model)` declares each status input as `Required(T)` or
`Default(value)`;
- `outputs_(model)` declares variables the model computes and their initial
output-state values;
- `environment_inputs_(model)` declares environment variables it reads;
+- `environment_outputs_(model)` declares which environmental variables it
+ may update, such as the temperature controlled by a canopy model;
- `commit_environment!(context, state)` commits accepted mutable environment
state when the model intentionally controls microclimate;
- `dep(model)` may declare model-author defaults;
- `run!(model, status, environment, constants, context)` contains the model
equations.
-`Required(T)` has no initialization value: object state or a producer
-application must satisfy it. `Default(value)` is installed only when the target
-does not already have the input. Plain input literals are rejected because
-they are ambiguous.
+`Required(T)` means you must supply the input on the object, or connect it
+to another model's output. `Default(value)` supplies a starting value only
+when that input is absent. A plain number is not a valid input declaration:
+it would not say whether the model requires a value or can use a default.
-The composite-model/object layer does not change that kernel contract. It adds a
-scenario-specific application around the kernel:
+The equations stay in the model. Use `ModelSpec` to configure their use in
+this particular simulation:
```julia
ModelSpec(
@@ -59,29 +63,29 @@ ModelSpec(
`ModelSpec` decides where the model runs, where its inputs come from, which
models it may call manually, which timestep it uses, and which environment
-provider is bound to it. The model implementation stays reusable.
+source supplies its growing conditions. You can reuse the same model in
+other simulations with different choices.
## Compilation Before Runtime
-Before the timestep loop, PlantSimEngine compiles the model into concrete
-runtime carriers:
-
-1. `ModelSpec(...; on=...)` selectors are resolved to stable object ids.
-2. `ModelSpec(...; inputs=...)` selectors are resolved to source object/application ids.
-3. Same-rate inputs are wired as shared `Ref`s, `RefVector`s, or
- heterogeneous object-reference vectors.
-4. Temporal inputs are compiled as stream lookups with a policy such as
- `HoldLast`, `Interpolate`, `Integrate`, or `Aggregate`.
-5. `ModelSpec(...; calls=...)` declarations are compiled to callable target lists.
-6. `Environment(...)` is bound to backend cells, layers, voxels, or global
- weather providers.
-7. The root application order is topologically sorted from value inputs and
- `Updates(...)` ordering.
-8. Root execution batches are grouped by concrete model/status/environment
- types where possible.
-
-Selectors are not resolved in the hot loop. Runtime execution uses the
-compiled indexes and carriers.
+Before the first time step, PlantSimEngine prepares the simulation. This
+preparation is called **compilation**. It:
+
+1. finds the objects selected by each application's `on` rule;
+2. finds where each input value will come from;
+3. connects values updated at the same rate through shared references, which
+ let one model read another object's current value without copying it;
+4. prepares any output history and rules needed to exchange values between
+ models that run at different time steps;
+5. lists the models that each controller can call through `calls`;
+6. finds the weather source, cell, or layer that supplies each object;
+7. puts applications in order so their inputs are available when needed,
+ including any order specified by `Updates`;
+8. groups objects with matching model, status, and environment types so Julia
+ can run them efficiently.
+
+The time loop reuses this work instead of searching for every connection
+again at each step.
Useful inspection helpers:
@@ -95,9 +99,9 @@ Diagnostics.explain_execution_plan(model)
Diagnostics.explain_writers(model)
```
-These explanations are intended for both users and agents. They report the
-compiled object ids, applications, carriers, clocks, environment bindings, and
-manual-call targets that the runtime will use.
+These reports show which objects and models were selected, where their
+inputs come from, when they run, and which growing conditions they receive.
+Both people and coding agents can inspect the reports.
### Readable source views
@@ -120,17 +124,16 @@ source = Authoring.compiled_model_source(model)
Authoring.write_compiled_model_source("compiled_model.jl", model)
```
-The generated source spells out application order, selected targets, input
-provenance, hard calls, and the kernel bodies invoked through normal
-PlantSimEngine status, environment, output, and lifecycle machinery. It is
-optimized for explanation and review, not as an alternative scheduler. It
-represents the resolved plan; `scenario_source` reconstructs the scenario that
-an author can edit and compile again.
+The generated code shows which models run on which objects, where their
+inputs come from, and which equations and manual calls are used. It runs
+through the normal PlantSimEngine machinery. Use this view to inspect
+execution; use `scenario_source` when you want to edit the simulation setup.
## Soft Dependencies With Inputs
-Soft dependencies are value dependencies. A consumer model reads a variable
-produced by another model through `ModelSpec(...; inputs=...)`.
+A **soft dependency** means that one model needs a value calculated by
+another. The model supplying the value is called the **producer**; the model
+reading it is the **consumer**. Connect them with `ModelSpec(...; inputs=...)`:
```julia
ModelSpec(SceneLAI(ground_area); name=:scene_lai, on=One(scale=:Scene), inputs=(:leaf_areas => Many(
@@ -142,24 +145,28 @@ ModelSpec(SceneLAI(ground_area); name=:scene_lai, on=One(scale=:Scene), inputs=(
),))
```
-For same-rate inputs, the runtime installs a reference carrier into the
-consumer status during compilation. A model-scale model reading all leaf areas
-therefore sees a `RefVector`-like object: reading pulls current values from
-source leaf statuses, and writing through the carrier mutates source refs when
-the carrier supports it.
+When the models run at the same rate, the input refers to the source
+object's current value. A plant model reading several leaf areas receives
+a reference vector such as `RefVector`: a list that reads the current value
+from each leaf. This shared storage is called a **reference carrier** in
+the diagnostic reports. Where it supports writing, changing an entry also
+changes the source object's value.
-If an input is not explicitly declared with `ModelSpec(...; inputs=...)`, the compiler can
-infer simple same-object bindings when exactly one producer on the same object
-outputs the same variable. Ambiguous producers are errors and should be
-disambiguated with `application=...` and, when names differ, `var=...`.
+You can omit an input connection when exactly one other model on the same
+object provides an output with the same name. PlantSimEngine connects it
+automatically. If several models provide that output, choose one with
+`application=...`. Use `var=...` when the source variable has a different name.
-Use `PreviousTimeStep(:x) => selector` when a feedback dependency should read
-the previous sample instead of creating a same-timestep scheduling edge.
+Use `PreviousTimeStep(:x) => selector` when a feedback calculation should
+read the previous step's value of `x`. The receiving model then does not
+need to wait for the current step's calculation of `x`.
## Hard Calls With Calls
-Hard dependencies are manual calls. Use `ModelSpec(...; calls=...)` when a parent model must
-control the call stack, for example during an iterative energy-balance solve.
+A **hard dependency** means that one model decides when to run another.
+Declare it with `ModelSpec(...; calls=...)`. For example, an energy-balance
+model may need to run photosynthesis repeatedly while adjusting leaf
+temperature.
```julia
ModelSpec(SceneEnergyBalance(); name=:scene_energy, on=One(scale=:Scene), calls=(:leaf_energy => Many(
@@ -202,20 +209,22 @@ function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment,
end
```
-`run_call!` defaults to `publish=false`. Trial calls mutate target statuses but
-do not publish temporal samples or commit mutable environment updates. Use
-`environment=trial_state` when hard-called descendants should sample temporary
-state through their compiled environment handles. Call `commit_environment!` and
-`run_call!(...; publish=true)` once for the accepted state.
+`run_call!` uses `publish=false` by default. Each trial updates the called
+objects' current values, but does not add results to their time histories or
+save changes to the shared environment. Pass `environment=trial_state` to
+try temporary growing conditions; each called model still gets the conditions
+for its own location. Once the result is accepted, save the environment
+with `commit_environment!` and run once with `publish=true` to record it.
-Applications selected only by `ModelSpec(...; calls=...)` are marked manual-call-only in
-`Diagnostics.explain_schedule(model)` and are skipped by the root `run!(model)` loop.
+Applications used only through `ModelSpec(...; calls=...)` run when their
+controller calls them. They do not also run independently from the normal
+schedule. `Diagnostics.explain_schedule(model)` marks them as manual-call-only.
## Duplicate Writers With Updates
-By default, one application owns each `(object, output variable)` canonical
-writer. If a scenario intentionally lets several models update the same
-variable, later writers must declare that order explicitly:
+Normally, only one model application may calculate a given output variable
+on an object. If you want several models to update that variable, state
+which should run first:
```julia
ModelSpec(CarbonAllocation(); name=:carbon_allocation, on=Many(scale=:Leaf))
@@ -226,12 +235,14 @@ ModelSpec(LeafPruning(); name=:leaf_pruning, on=Many(scale=:Leaf), updates=Updat
This keeps ordinary duplicate outputs as errors while allowing cases such as
allocation followed by pruning. `Diagnostics.explain_writers(model)` reports writer
groups and the `Updates(...)` declarations that validate them.
-The `after` value is the canonical application identifier shown by
-`Diagnostics.explain_applications(model)`, not the process name.
+For `after`, use the application ID shown by
+`Diagnostics.explain_applications(model)`. A process name does not identify
+a particular application when several models use that process.
## Multirate Execution
-Use `ModelSpec(...; every=...)` with `Dates.Period` values for model application clocks:
+**Multirate** means that models run at different time steps. Set how often
+a model runs with `ModelSpec(...; every=...)` and a duration such as `Hour(1)`:
The duration must be a positive integer multiple of the simulation base step.
Choose a finer common base step when needed; the scheduler does not insert
@@ -274,8 +285,10 @@ Supported policies are:
duration weighting;
- `Aggregate()`: reduce values over a window, defaulting to `MeanReducer()`.
-Policies resample numeric values but do not transform `VariableContract`
-metadata. A contracted rate-to-amount conversion needs an explicit adapter;
+These rules combine numeric values but do not change the units or physical
+meaning recorded in `VariableContract`. If you declare a rate on one side
+and an amount on the other, use a conversion model with the appropriate
+units and meaning declared for each side;
see [Coupling models](@ref).
`Integrate(...)` and `Aggregate(...)` accept reducer objects or callables that
@@ -289,9 +302,9 @@ producer execution and weighted by the portion of that interval overlapping
the consumer window. This includes the last value published before the window
when it remains active inside the window.
-Temporal windows are duration-based rolling windows. Calendar-aligned civil
-days and "previous complete period" selection are not part of the public API;
-there is no `CalendarWindow` compatibility type.
+Time windows cover a duration relative to the current simulation time.
+They do not automatically align with calendar days or select the previous
+complete day, week, or month.
## Environment Sampling
@@ -301,9 +314,10 @@ there is no `CalendarWindow` compatibility type.
ModelSpec(CO2Probe(); name=:co2_probe, on=Many(scale=:Leaf), environment=Environment(provider=:canopy, sources=(CO2=:Ca,)))
```
-The compiler binds each application/object pair to the selected backend before
-runtime. Constant weather, global tabular meteorology, grid, layer, voxel, or
-octree-style microclimate backends all use the same contract:
+Before running, PlantSimEngine finds the environment source for each model
+and object. An environment **backend** is the code that supplies those
+conditions, from a weather table or a spatial representation such as soil
+layers or canopy cells. All backends use the same model-facing functions:
- `environment_inputs_(model)` says what the model reads;
- `environment_outputs_(model)` says what the model may commit;
@@ -315,7 +329,9 @@ octree-style microclimate backends all use the same contract:
- geometry and position are used by spatial backends when available;
- object-to-environment links are cached and refreshed when objects move.
-Backend authors implement an opaque-handle protocol:
+A backend first locates the data needed by an object and saves that location
+in a **handle**. The model does not need to interpret this handle; the backend
+uses it to retrieve values efficiently. Backend authors implement:
```julia
handle = EnvironmentAPI.bind_environment(backend, object, context, config)
@@ -325,12 +341,13 @@ EnvironmentAPI.sample(backend, handle, trial_state, variable, time) # transient
commit_environment!(backend, handle, accepted_state, time)
```
-`EnvironmentAPI.EnvironmentContext` identifies the application, object, scale, and process
-while the handle is compiled. Runtime status and geometry are not passed to
-sampling: a spatial backend resolves them once in `EnvironmentAPI.bind_environment` and stores
-the resulting provider, layer, voxel, or other routing data in its concrete
-handle. A controller that reads from one provider and commits to another should
-encode both routes in the handle, for example
+`EnvironmentAPI.EnvironmentContext` identifies the model application, object,
+scale, and process when the backend prepares the handle. A spatial backend
+uses `EnvironmentAPI.bind_environment` to locate the object's layer, cell, or
+other data source and stores that location in the handle. Later requests for
+environment values use this handle; they do not receive the object's full
+status and geometry again. If a controller reads from one source and saves
+updates to another, the handle must record both, for example
`Environment(provider=:forcing, sink=:canopy)`.
Model-level `environment_hint(...)` can provide default source bindings and
@@ -346,8 +363,8 @@ Run a model with:
sim = run!(model; steps=30)
```
-The returned `Simulation` contains the mutated model, compiled bindings,
-environment bindings, execution plan, and retained temporal output streams.
+The returned `Simulation` keeps the current model values, the prepared
+connections and schedule, and any saved output histories.
By default, model runs retain no user output streams. Pass `outputs=:all` to
retain every published stream, or pass `OutputRequest` values to retain only
@@ -417,36 +434,39 @@ move_object!(model, :leaf_4, new_geometry)
update_geometry!(model, :leaf_5, new_geometry)
```
-Use `add_organ!` for an MTG-backed model. It creates the MTG node, initializes
-and attaches its `Status`, registers the model object, and invalidates the
-affected bindings. By default, status initialization reuses the model's MTG
-policy. A framework that already supplies the complete creation attributes and
-initial status may set `use_status_adapter=false`; doing so is an explicit
-assertion that the configured status accessor contributes no additional fields
-or side effects for that node. `register_object!` is the low-level operation
-for callers that already own a complete `Object`.
-
-Structural changes invalidate compiled object/model bindings. Movement and
-geometry changes invalidate environment bindings without rebuilding structural
-input carriers. Scenario-level application, dependency, selector, cadence,
-environment-sampling, and output-retention plans remain immutable; only the
-affected object targets and buffers are refreshed.
-
-Do not mutate `Object` topology, labels, or geometry fields directly. Direct
-field mutation bypasses registry indexes and cache invalidation and is
-unsupported. Use the lifecycle functions above. They validate prerequisites
-before mutating; in particular, `reparent_object!` rejects self-parenting and
-descendant cycles without changing existing links. `ObjectInstance` roots are
-immutable lifecycle anchors: removing or reparenting a root, or an ancestor
-whose subtree contains one, is rejected atomically. Ordinary descendants may
-still be added, removed, or reparented.
-
-Inside a lifecycle-capable model kernel, use `runtime_model(context)` to obtain
-the live model. Objects created during a kernel call do not recursively execute
-inside that call. At the safe barrier after the mutating application,
-PlantSimEngine refreshes affected structural targets, value carriers, hard-call
-targets, writer validation, temporal storage, execution groups, output-request
-matches, and environment handles. A new object can therefore run an application
+Use `add_organ!` when the plant structure comes from an MTG. It creates the
+node, prepares its starting values, adds the object to the simulation, and
+marks its connections for updating. By default, it reuses the function you
+provided to initialize values from MTG nodes.
+
+Set `use_status_adapter=false` only if you supply all the new organ's
+attributes and values yourself, and that initialization function has no
+additional work to do. Use `register_object!` when you already have a fully
+prepared `Object` to add.
+
+Adding or removing objects can change which models run and where their
+inputs come from, so PlantSimEngine updates the affected connections. Moving
+an object or changing its shape updates its environment connection without
+rebuilding unrelated model connections. The rules you supplied in the
+simulation setup stay the same.
+
+Use the functions above to change objects; assigning directly to their
+structure, label, or geometry fields would leave PlantSimEngine's stored
+connections out of date. The functions check each change before applying it.
+For example, `reparent_object!` prevents an object from becoming its own
+parent or a descendant of itself.
+
+The root of an `ObjectInstance` must stay in place. You cannot remove or
+reparent that root, or an ancestor whose descendants contain it. Rejected
+operations leave the existing structure unchanged. You can still add,
+remove, or reparent ordinary descendants of the instance root.
+
+Inside a model's `run!` function, use `runtime_model(context)` to access the
+simulation model when creating or changing objects. Creating an object does
+not immediately run its models. After the application that made the change
+finishes, PlantSimEngine updates the affected model selections, input and
+environment connections, manual calls, output histories, and execution order.
+It also checks that output variables still have valid sources. A new object can therefore run an application
that remains later in the same timestep. It does not retroactively run an
application that already completed unless its creator declares that application
as an [`Initializer`](@ref) and explicitly calls [`run_initializer!`](@ref) on
diff --git a/docs/src/model_traits.md b/docs/src/model_traits.md
index 3dee94758..a6adddaa5 100644
--- a/docs/src/model_traits.md
+++ b/docs/src/model_traits.md
@@ -1,8 +1,12 @@
# Model Traits
-Model traits describe intrinsic model behavior. Scenario-specific coupling
-belongs in `ModelSpec` through `on`, `inputs`, `calls`, `every`,
-`Environment`, `output_routing`, and `Updates`.
+A **trait** is a function that tells PlantSimEngine something about a model:
+which values it needs, which values it calculates, or how often it should run.
+This page lists the declarations a model author can provide. For a complete
+example, start with [writing a first model](journeys/modelers/basic_model.md).
+
+Use `ModelSpec` for choices that belong to a particular simulation, such as
+which leaves use the model and where their inputs come from.
## Variables
@@ -16,22 +20,22 @@ PlantSimEngine.inputs_(::MyModel) = (
PlantSimEngine.outputs_(::MyModel) = (assimilation=0.0,)
```
-`Required(T)` means the value must be present on the target object's `Status`
-or bound from another application. `T` is an expected type, not a placeholder
-value. It may be abstract or parametric, so use the scientific type contract
-instead of forcing `Float64`.
+`Required(T)` means the object must already have this value, or another
+model must provide it. `T` describes the kind of value expected, such as
+`Float64` for a floating-point number. It does not supply a starting value.
+Choose a type that fits the calculation; it need not always be `Float64`.
-`Default(value)` means the model can run without user initialization or a
-producer for that input. PlantSimEngine installs a private copy of `value` on
-each target object when the value is absent. Mutable defaults are therefore
-not shared between objects.
+`Default(value)` supplies a starting value when neither the user nor another
+model provides one. Each object gets its own copy. For example, changing a
+default array on one leaf does not change the array on another leaf.
-Output literals remain initial output-state values. In the example,
-`assimilation` starts at `0.0` before the first accepted model call.
+The values in `outputs_` are the outputs' starting values. In this example,
+`assimilation` starts at `0.0` before the model first runs.
-These declarations are used for status initialization, dependency inference,
-validation, and type construction. Plain input literals are rejected because
-they do not say whether the value is required or genuinely optional.
+PlantSimEngine uses these declarations to prepare each object's values,
+connect models, and find missing inputs. Writing a plain number in `inputs_`
+is rejected: it would not say whether the model needs a supplied value or
+can use a default.
Use `init_variables(model)` to inspect only values PlantSimEngine can
initialize by itself: `Default` input values and output initial values.
@@ -39,13 +43,15 @@ Required inputs are intentionally omitted.
Before running a scenario, `Diagnostics.explain_initialization(model)` classifies inputs as
`:required`, `:defaulted`, `:supplied`, or `:producer_bound`. A
-`:required` row must be resolved before compilation can succeed.
+`:required` row identifies an input you must supply before the simulation
+can be prepared.
## Scientific meaning and dimensions
Names and Julia types are not enough to distinguish, for example, daily PAR
per ground area from daily PAR per plant. Add a `VariableContract` when a
-variable participates in scientific coupling:
+value will pass between models. A **variable contract** records its unit
+and physical meaning:
```julia
const PLANT_DAILY_PAR = VariableContract(
@@ -64,33 +70,35 @@ PlantSimEngine.variable_contracts_(::PlantGrowth) = (
)
```
-The tokens are open symbols, but a connected producer and consumer must declare
-the same complete contract. Once either side declares one, a missing contract
-on the other side is also a compilation error. Renaming a variable with
-`var=...` does not convert its meaning; put unit, basis, or time conversion in
-an explicit model boundary.
+You choose the labels, such as `:plant` and `:day`. The model supplying the
+value and the model reading it must declare the same complete contract. If
+one declares a contract and the other does not, PlantSimEngine reports an
+error before running. Renaming a variable with `var=...` does not convert
+its units or meaning. Use a separate model for a conversion, such as
+multiplying a quantity per unit area by plant area.
-`VariableContract` is metadata only. Status and environment payloads remain
-ordinary numbers, arrays, unit-bearing values, or automatic-differentiation
-values, so the contract adds no wrapper to a model's numerical hot loop.
+`VariableContract` describes the values without changing how they are
+stored. Your equations still receive numbers or arrays, including compatible
+types that carry units, uncertainty, or derivatives.
Use `variable_contracts(model)` to inspect the validated declarations. Contract
keys must occur in one of the model's declared status or environment traits, or
in the compiled application's distributed `outputs_to` declaration.
-| Role | Declaration | Ownership |
+| Value or operation | Declaration | Where it comes from or goes |
|---|---|---|
-| Status input | `inputs_` | object state or a compiled model producer |
-| Environment input | `environment_inputs_` | the active forcing backend |
-| Constant | the `constants` argument | simulation configuration, not mutable status |
-| Manual model call | `dep` plus `ModelSpec(...; calls=...)` | explicit callee execution; the callee keeps its own variable declarations |
-| Local status output | `outputs_` | the target object's runtime status |
-| Distributed output | `ModelSpec(...; outputs_to=...)` | explicitly selected destination objects |
+| Object input | `inputs_` | A value on the object or an output from another model |
+| Environment input | `environment_inputs_` | Weather data or local growing conditions |
+| Constant | the `constants` argument | Values supplied for the simulation, such as physical constants |
+| Manual model call | `dep` plus `ModelSpec(...; calls=...)` | Another model that this model decides when to run |
+| Output on the current object | `outputs_` | The object on which this model runs |
+| Outputs on other objects | `ModelSpec(...; outputs_to=...)` | Selected objects, such as leaves receiving a scene light calculation |
## Manual Dependencies
-Implement `dep(model)` only when the model directly calls another process from
-inside its own `run!` method:
+Use `dep(model)` to suggest default input connections with `Input`, or models
+to call with `Call`. A `Call` declaration is needed when the model directly
+runs another process from inside its own `run!` function:
```julia
PlantSimEngine.dep(::EnergyBalance) = (
@@ -99,10 +107,11 @@ PlantSimEngine.dep(::EnergyBalance) = (
```
The scenario may override that default selector with
-`ModelSpec(...; calls=...)`. The parent executes all resolved targets with
-`run_call!(context, :photosynthesis)`, which always returns a vector-like
-collection. Use `call_targets` plus `run_call!(target)` when the parent needs
-selective trials and accepted publication.
+`ModelSpec(...; calls=...)`. The calling model runs all selected photosynthesis models with
+`run_call!(context, :photosynthesis)`, which returns a collection even when
+there is only one model. Use `call_targets` to inspect that collection first
+and `run_call!(target)` to run individual entries. This lets you try values
+and record only the accepted result with `publish=true`.
## Timing
@@ -113,11 +122,17 @@ selective trials and accepted publication.
PlantSimEngine.timespec(::Type{<:DailyGrowth}) = ClockSpec(24.0, 1.0)
```
+Here, `24.0` means every 24 simulation steps and `1.0` sets the first
+execution at step 1: the model runs at steps 1, 25, 49, and so on. This is
+daily execution only when the simulation uses hourly steps.
+
Use `ModelSpec(...; every=Dates.Day(1))` when the cadence should be expressed
-as a physical duration relative to the scenario environment. A
+in hours or days relative to the simulation's weather time step. A
`Dates.Period` is not a `ClockSpec` constructor argument.
-`output_policy(model)` declares the default temporal policy per output:
+`output_policy(model)` says how another model should read an output when
+the two models run at different time steps. For example, it can request a
+sum or a mean over a time window:
```julia
PlantSimEngine.output_policy(::Type{<:MyModel}) = (
@@ -147,23 +162,31 @@ PlantSimEngine.environment_inputs_(::LeafEnergyBalance) = (
)
```
-Mutable microclimate updates should be committed explicitly by controller
-models:
+If a controller changes growing conditions, declare the variables it may
+update with `environment_outputs_`. For example, a canopy controller that
+adjusts temperature declares:
+
+```julia
+PlantSimEngine.environment_outputs_(::CanopyController) = (T=0.0,)
+```
+
+Once its calculation is accepted, the controller saves the new conditions
+with `commit_environment!`. This operation requires the output declaration:
```julia
commit_environment!(context, accepted_environment)
```
-For trial solves, pass a backend-specific state with `environment` so each
-hard-called model keeps its compiled provider or spatial handle:
+To try temporary growing conditions, pass them through `environment`. Each
+called model still receives the conditions for its own location:
```julia
run_call!(context, :leaf_energy; environment=trial_environment, publish=false)
```
-Diagnostic variables such as canopy temperature or vapor-pressure deficit can
-still be regular `outputs_`, but status fields are not the transport mechanism
-for mutable environment state.
+You can also record canopy temperature or vapor-pressure deficit in
+`outputs_`. Changing these outputs alone does not update the environment
+that other models read; use `commit_environment!` for that.
## Precedence
diff --git a/docs/src/planned_features.md b/docs/src/planned_features.md
index af2e89cc4..657f27a60 100644
--- a/docs/src/planned_features.md
+++ b/docs/src/planned_features.md
@@ -1,20 +1,21 @@
# Roadmap
-PlantSimEngine now has one composite-model/object runtime for single-object, multiscale,
-multi-plant, soil, microclimate, and multirate simulations.
+PlantSimEngine uses the same simulation engine for a single object or many
+plants and organs, including models that run at different time steps. This
+page lists work planned to extend and check these capabilities.
Current priorities are:
- migrate downstream model packages to `CompositeModel`, `CompositeModelTemplate`,
`ObjectInstance`, and `ModelSpec`;
- strengthen type-stability and allocation tests for million-object workloads;
-- add broader lifecycle tests for object creation, removal, movement, and
- environment-index refresh;
-- improve diagnostics for ambiguous selectors, writer conflicts, and temporal
- policies;
+- test more combinations of adding, removing, and moving objects, including
+ updates to their local growing conditions;
+- make error reports clearer when object selections match too many objects,
+ models try to set the same output, or time-step connections need attention;
- validate mutable voxel, layer, and octree microclimate backends;
-- expand downstream release gates and performance benchmarks;
-- evaluate parallel execution for independent compiled application batches.
+- test more dependent packages and simulation performance before releases;
+- investigate running independent groups of model calculations in parallel.
## Environment and microclimate work
@@ -43,11 +44,10 @@ commit_environment!(context, accepted_environment)
run_call!(context, :leaf_energy; publish=true)
```
-The transient state is interpreted by each target backend through its opaque
-compiled handle, so one call can sample different cells for different leaves.
-`commit_environment!` commits only the accepted state to a mutable backend.
-Future work is to validate full voxel, layer, and octree implementations on
-this same model-side API.
+Each leaf still receives conditions for its own location, so one call can
+supply different trial values to different leaves. `commit_environment!`
+saves only the accepted growing conditions. Future work will test this
+approach with environments represented by cells, layers, and octrees.
The full issue list is available on
[GitHub](https://github.com/VirtualPlantLab/PlantSimEngine.jl/issues).
diff --git a/docs/src/prerequisites/installing_plantsimengine.md b/docs/src/prerequisites/installing_plantsimengine.md
index 67c135dbf..b4801a847 100644
--- a/docs/src/prerequisites/installing_plantsimengine.md
+++ b/docs/src/prerequisites/installing_plantsimengine.md
@@ -1,23 +1,43 @@
# Installing PlantSimEngine
+!!! compat "Use the package version described by this manual"
+ These development pages use the `CompositeModel` API. Registered releases
+ through 0.14.1 use the previous mapping API. Follow the commands below to
+ use the development version, or use the documentation matching your
+ installed release. For a pull-request preview, replace `"main"` with the
+ branch or commit shown by that pull request so its examples and package
+ sources match.
+
Install Julia from the
[official download page](https://julialang.org/downloads/), create a project
-environment, and add PlantSimEngine:
+environment (a folder that records the packages used for this simulation),
+and install the tutorial packages:
```julia
using Pkg
Pkg.activate("my_simulation")
-Pkg.add("PlantSimEngine")
+Pkg.add(["PlantMeteo", "DataFrames", "CairoMakie"])
+Pkg.add(url="https://github.com/VirtualPlantLab/PlantSimEngine.jl", rev="main")
```
-Most simulations also use PlantMeteo:
+PlantMeteo supplies weather data, DataFrames organizes the results, and
+CairoMakie draws the tutorial figures. Run these commands at Julia's
+interactive prompt, also called the **REPL**;
+`my_simulation` is the project directory created relative to your current
+working directory. In a later session, run `Pkg.activate("my_simulation")`
+from the same location before using the project again.
-```julia
-Pkg.add("PlantMeteo")
-```
+For reproducible work, record the package revision and keep the generated
+`Project.toml` and `Manifest.toml` with your experiment. The development branch
+can change; a commit revision selects one exact version.
## First Simulation
+This small example calculates how much light a canopy absorbs. Its leaf area
+index is 2 m² of leaves per m² of ground, and incoming photosynthetically
+active radiation (PAR) is 500 W m⁻² of ground. The canopy is one simulated
+object; individual leaves are not represented.
+
```@example install
using PlantSimEngine, PlantMeteo, Dates
using PlantSimEngine.Examples
@@ -33,17 +53,23 @@ meteo = Atmosphere(
model = CompositeModel(
Beer(0.5);
status=(LAI=2.0,),
- id=:leaf,
- scale=:Leaf,
+ id=:canopy,
+ scale=:Canopy,
environment=meteo,
)
simulation = run!(model)
-final_state(simulation, One(scale=:Leaf)).aPPFD
+(absorbed_PAR_umol_m2_ground_s=final_state(simulation).aPPFD,)
```
-Example models are provided by the `PlantSimEngine.Examples` submodule. They
-are useful for learning and tests but are not part of the core modeling API.
+`using PlantSimEngine.Examples` loads the example models, including `Beer`.
+These models are included for learning and testing.
+
+The result is the amount of light absorbed by the canopy, expressed in μmol
+of photons per m² of ground per second. The inputs are
+illustrative, not a calibrated crop scenario. Next,
+[couple three models over time](../journeys/users/one_object.md), then
+[collect and plot their outputs](../guides/data/outputs_plotting.md).
For local package development, use `Pkg.develop(path="...")`. Run the package
tests with `Pkg.test("PlantSimEngine")`.
diff --git a/docs/src/prerequisites/julia_basics.md b/docs/src/prerequisites/julia_basics.md
index 707ad6122..8992cbabd 100644
--- a/docs/src/prerequisites/julia_basics.md
+++ b/docs/src/prerequisites/julia_basics.md
@@ -1,53 +1,66 @@
# Getting started with Julia
-PlantSimEngine (as well as its related packages) is written in Julia. The reasons why Julia was chosen are briefly discussed here : [The choice of using Julia](@ref).
-
-Julia is a language that is gaining traction, but it isn't the most widely used in research and data science.
-
-Many elements will be familiar to those with an R, Python or Matlab background, but there are some noteworthy differences, and if you are new to the language, there will be a few hurdles you might have to overcome to be comfortable using the language.
-
-This page is here to list to the parts of Julia that are most relevant regarding usage of PlantSimEngine, and point to resources that can help you grasp those basics.
+You can run the PlantSimEngine tutorials without knowing all of Julia. Start
+by copying a complete example, then change one parameter and compare the
+result. This page explains a few patterns you will see along the way.
## New to programming
-It is not meant as a full-fledged from-scratch Julia tutorial. If you are completely new to programming, you may wish to check some other resources first, such as ones found [here](https://docs.julialang.org/en/v1/manual/getting-started/). The video course [Julia Programming for Nervous Beginners](https://www.youtube.com/playlist?list=PLP8iPy9hna6Qpx0MgGyElJ5qFlaIXYf1R) is tailored for people with no programming experience.
-
-## Installing packages and setting up and environment
-
-For PlantSimEngine, you can check our documentation page on the topic:
-[Installing PlantSimEngine](installing_plantsimengine.md).
-
-## Cheatsheets
-
-You can also find a few cheatsheets [here](https://palmstudio.github.io/Biophysics_database_palm/cheatsheets/) as well as a [short introductory notebook](https://palmstudio.github.io/Biophysics_database_palm/basic_syntax/) along with its [install instructions](https://palmstudio.github.io/Biophysics_database_palm/installation/).
+The [Julia getting-started guide](https://docs.julialang.org/en/v1/manual/getting-started/)
+explains how to start Julia and enter commands. If you prefer a video course,
+[Julia Programming for Nervous Beginners](https://www.youtube.com/playlist?list=PLP8iPy9hna6Qpx0MgGyElJ5qFlaIXYf1R)
+is aimed at people with no programming experience.
-## Troubleshooting
-
-For more Julia learning-related difficulties, you will find quick responses on the Discourse forum: [https://discourse.julialang.org](https://discourse.julialang.org).
-
-### Noteworthy differences with other languages:
-
-If you wish to compare Julia to a specific language, [the noteworthy differences section](https://docs.julialang.org/en/v1/manual/noteworthy-differences/#Noteworthy-differences-from-Python) will provide you with a quick overview of the differences.
+## Installing packages and setting up an environment
-(Array indexing starts at 1, for example)
+Follow [Installing PlantSimEngine](installing_plantsimengine.md) to create a
+project folder and install the tutorial packages. A project environment
+records which packages and versions your simulation uses.
## Essential Julia concepts for PlantSimEngine
-Here's a list of the main aspects of the Julia language required (beyond package management) to understand how to use PlantSimEngine to its potential:
+The first tutorials mainly use these patterns:
+
+| Code | Meaning |
+|---|---|
+| `lai = 2.0` | Store a value under the name `lai` |
+| `Beer(0.6)` | Create a Beer model with an extinction coefficient of 0.6 |
+| `run!(model; steps=30)` | Run a function, with the named option `steps=30` |
+| `(LAI=2.0, TT=12.0)` | Group named values in a **named tuple** |
+| `[1.0, 2.0, 3.0]` | Create an array of three values |
+| `values[1]` | Read the first array entry; Julia indexing starts at 1 |
+| `state.LAI` | Read the value named `LAI` from `state` |
+| `values .* 2` | Multiply every array entry by 2 |
+
+Options such as `steps=30` are called **keyword arguments**. The semicolon
+separates these named options from the other arguments. A dot before an
+operator, as in `.*`, applies the operation to each array entry. Julia calls
+this **broadcasting**.
+
+A function name ending in `!`, such as `run!` or `step!`, usually means that
+the function changes something it was given. Here, running a simulation
+updates its objects' values.
+
+When you start writing models, you will also meet **types** and **methods**.
+A type describes a kind of value; a model type can store its parameters.
+A method is a version of a function for particular types of arguments.
+The [first model tutorial](../journeys/modelers/basic_model.md) introduces
+these ideas with a complete equation and its parameters.
-Standard notions and constructs:
-
-- Standard concepts of a variable, arrays, functions, function arguments
-- The typing system and custom types
-- Dictionaries and NamedTuple objects are used throughout the codebase
-
-The Julia manual goes more in-depth than lighter introductions to some of these topics, so might be more useful as a reference than a starting point. You might find other guides or courses, such as the first section in [https://julia.quantecon.org/intro.html](https://julia.quantecon.org/intro.html), chapters 0-4 and 7 of the [Learn Julia the Hard Way](https://scls.gitbooks.io/ljthw/content/) draft or the interactive [Mathigon course](https://mathigon.org/course/programming-in-julia/introduction).
+## Cheatsheets
-Also of importance:
+The [Julia Data Science basics](https://juliadatascience.io/julia_basics)
+cover common syntax and working with tables. There are also
+[cheatsheets](https://palmstudio.github.io/Biophysics_database_palm/cheatsheets/)
+and a [short introductory notebook](https://palmstudio.github.io/Biophysics_database_palm/basic_syntax/).
-- [Keyword arguments](https://docs.julialang.org/en/v1/manual/functions/#Keyword-Arguments) (kwargs) are present in many API functions
-- [Type promotion](https://docs.julialang.org/en/v1/manual/conversion-and-promotion/#Promotion), [splatting](https://docs.julialang.org/en/v1/base/base/#...), [broadcasting](https://docs.julialang.org/en/v1/manual/functions/#man-vectorized), and [comprehensions](https://docs.julialang.org/en/v1/manual/arrays/#man-comprehensions) are also very useful, but not compulsory to get started
+## Troubleshooting
-Many of these are also briefly presented in [this Julia Data Science](https://juliadatascience.io/julia_basics) guide, which also happens to focus on the DataFrames.jl package.
+Ask Julia language questions on [Julia Discourse](https://discourse.julialang.org).
+For errors from PlantSimEngine, use the
+[common errors guide](../troubleshooting/common_errors.md).
-Understanding more about methods, parametric types and the typing system is usually worthwhile, when working with Julia packages.
+If you know R, Python, or MATLAB, Julia's
+[comparison with other languages](https://docs.julialang.org/en/v1/manual/noteworthy-differences/)
+explains differences you may encounter. You can read about more advanced
+features, such as type promotion and parametric types, when a model needs them.
diff --git a/docs/src/prerequisites/key_concepts.md b/docs/src/prerequisites/key_concepts.md
deleted file mode 100644
index 2f254e75c..000000000
--- a/docs/src/prerequisites/key_concepts.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# Key Concepts
-
-## Processes And Models
-
-A process identifies a biological or physical phenomenon, such as
-photosynthesis, growth, water balance, or energy balance. A model is one
-implementation of a process.
-
-Models subtype `AbstractModel` and declare:
-
-- `inputs_`: values read from object status, each declared as `Required(T)` or
- `Default(value)`;
-- `outputs_`: values written to object status;
-- `environment_inputs_`: values sampled from the environment;
-- `environment_outputs_`: environment variables a controller may commit;
-- `commit_environment!`: accepted meteorological state committed to a mutable
- environment by controller models;
-- `dep`: processes called manually by the model, when required.
-
-The numerical kernel is implemented with:
-
-```julia
-PlantSimEngine.run!(model, status, environment, constants, context)
-```
-
-`Required(T)` describes an input that must be supplied by object state or
-another application. `Default(value)` is a true model fallback that
-PlantSimEngine can initialize automatically. Output literals are initial
-output-state values.
-
-## Composite Models And Objects
-
-A `CompositeModel` contains objects and model applications. An `Object` can represent a
-model, plant, soil volume, axis, internode, leaf, sensor, or any other simulated
-entity. PlantSimEngine does not impose one plant architecture.
-
-Objects can carry:
-
-- a stable identifier;
-- scale, kind, species, and name metadata;
-- parent-child relationships;
-- geometry and position;
-- mutable `Status`;
-- object-local model applications.
-
-`CompositeModelTemplate` packages reusable applications for a species or object type.
-`ObjectInstance` mounts the template in a model. Several instances can share
-models and parameters while declaring targeted overrides for exceptional
-objects.
-
-## Model Applications
-
-`ModelSpec` configures one use of a model:
-
-- `ModelSpec(...; on=...)` selects target objects;
-- `ModelSpec(...; inputs=...)` selects producers for value dependencies;
-- `ModelSpec(...; calls=...)` binds manually controlled model calls;
-- `ModelSpec(...; every=...)` selects the execution cadence;
-- `Environment(...)` configures environment sampling;
-- `Updates(...; after=:application_id)` orders intentional additional writers;
-- `ModelSpec(...; output_routing=...)` controls output publication.
-
-This keeps model implementations generic. Models do not need to know which
-model, object, timestep, or coupling scenario will use them.
-
-## Soft And Manual Dependencies
-
-Ordinary dependencies are inferred by matching model inputs with outputs and
-are compiled into an acyclic execution order. `ModelSpec(...; inputs=...)` is used when the
-source is cross-object, renamed, temporal, or otherwise ambiguous.
-
-Some algorithms need direct call-stack control. For example, a model energy
-balance may repeatedly call leaf energy-balance models until canopy
-microclimate converges. Such dependencies are bound with `ModelSpec(...; calls=...)`; the
-parent invokes them with `run_call!`.
-
-## Status And References
-
-`Status` stores variables in references. Same-rate coupling normally shares
-those references instead of copying values. A many-object input uses a
-reference vector, so aggregation models read current source values directly.
-
-Temporal coupling uses published streams when producer and consumer clocks
-differ. Policies include `HoldLast`, `Interpolate`, `Integrate`, `Aggregate`,
-and `PreviousTimeStep`.
-
-## Environment
-
-The active environment backend may be:
-
-- one constant atmosphere shared by all objects;
-- a time-indexed weather table;
-- a mutable layer, voxel, grid, or octree microclimate.
-
-Object-to-environment support is compiled and cached. Geometry changes mark the
-binding dirty so it can be refreshed without recomputing spatial lookup at
-every timestep.
-
-## Multiscale Plant Structure
-
-PlantSimEngine treats scale and object hierarchy as scenario data. A plant may
-use leaves directly under a plant, or axes, segments, internodes, roots, and
-other intermediate levels. Selectors express relationships such as one source,
-many descendants, the current plant, an ancestor, or all matching objects in
-the model.
-
-MultiScaleTreeGraph objects can be imported with `objects_from_mtg`, but the
-runtime operates on the same composite-model/object representation afterward.
diff --git a/docs/src/step_by_step/advanced_coupling.md b/docs/src/step_by_step/advanced_coupling.md
index 907953a44..e5efe7d6c 100644
--- a/docs/src/step_by_step/advanced_coupling.md
+++ b/docs/src/step_by_step/advanced_coupling.md
@@ -10,44 +10,49 @@ meteo_day = read_weather(
)
```
-Most model coupling is a value dependency: one model writes an output, another
-model reads it as an input. Some models need tighter control. For example, an
-energy-balance model may call photosynthesis and stomatal-conductance models
-several times while it iterates leaf temperature.
+Usually, coupling means that one model calculates a value and another reads
+it. Sometimes a model must also decide when the other calculation runs. For
+example, an energy-balance model may run photosynthesis and
+stomatal-conductance models several times while trying to find the leaf
+temperature that balances heat gains and losses.
-That second case is a manual call dependency. In the composite-model/object API it is
-declared with `ModelSpec(...; calls=...)`.
+That second case is called a **manual call** or **hard dependency**. Declare
+it with `ModelSpec(...; calls=...)`.
## Soft inputs and manual calls
-Use `ModelSpec(...; inputs=...)` or inferred same-object bindings when a model only needs a
-value. Use `ModelSpec(...; calls=...)` when the parent model must directly run another model
-inside its own `run!` method.
+An ordinary input connection is also called a **soft dependency**. Set it
+with `ModelSpec(...; inputs=...)`. PlantSimEngine connects inputs automatically
+on the same object when exactly one model supplies the required variable.
+Use `ModelSpec(...; calls=...)` when one model must run another from inside
+its own `run!` function.
The example process models in `examples/dummy.jl` contain both patterns:
- `Process4Model` computes `var1` and `var2`;
-- `Process1Model` consumes `var1` and `var2` and computes `var3`;
+- `Process1Model` reads `var1` and `var2` and computes `var3`;
- `Process2Model` manually calls process 1, then computes `var4` and `var5`;
- `Process3Model` manually calls process 2, then computes `var6`;
-- `Process5Model`, `Process6Model`, and `Process7Model` use regular soft
- value dependencies.
+- `Process5Model`, `Process6Model`, and `Process7Model` read other models'
+ results through ordinary inputs.
## Declaring manual calls in the scenario
-`ModelSpec(...; calls=...)` is scenario-level wiring. The model kernel remains generic; the
-scenario decides which concrete application is called.
+The simulation setup chooses which models are called. The model's `run!`
+function can then keep the same equations when those choices change.
-Use `application=...` in scenario-level `ModelSpec(...; calls=...)` and `ModelSpec(...; inputs=...)` when you
-know which mounted model application should provide the value or be called. Use
-process identities in model-level contracts such as `dep(model)`, where the
-model author only declares that a compatible process is required and cannot know
-the names chosen by future scenarios.
+A **model application** is a model configured with a name, selected objects,
+and any input or timing settings. In the setup below, use `application=...`
+to choose that name in a call or input connection.
-This split avoids ambiguity when several applications implement the same
-process. For example, two soil-water applications can share the same process but
-represent different layers, parameter sets, objects, or time steps. A scenario
-selector should name the application that has the intended role.
+This matters when you use the same process in several places. For example,
+two soil-water models may represent different layers. Naming the application
+lets you choose the intended layer.
+
+When writing a reusable model, you will not know the names that future
+simulations choose. Its `dep(model)` declaration can instead ask for the
+scientific process it needs. The simulation then connects that request to
+an application.
```@example scene_advanced_coupling
complex_scene = CompositeModel(
@@ -80,15 +85,15 @@ select(
)
```
-Applications selected by `ModelSpec(...; calls=...)` are not scheduled as independent root
-applications under their caller. They run only when the parent calls them.
-This gives the parent full call-stack control.
+The table shows which model each named call will run. Models used only
+through these calls run when their caller asks them to; they do not also
+run independently at each time step.
## Running the coupled model
-The regular soft dependencies are still inferred from `inputs_` and
-`outputs_`. The scheduler combines those soft edges with the call ownership
-rules:
+PlantSimEngine still connects ordinary inputs from the models' `inputs_`
+and `outputs_` declarations. It uses those connections and the manual calls
+to determine which calculations run first. Inspect that order:
```@example scene_advanced_coupling
select(
@@ -115,16 +120,16 @@ complex_status = final_state(complex_sim)
## Writing new hard-coupled models
-For new composite-model/object models, execute all targets directly when they
-share meteorology and publication policy:
+Inside a model, run all models and objects selected by a named call with:
```julia
targets = run_call!(context, :leaf_energy; publish=true)
```
-The result is always vector-like. Retrieve targets without executing them when
-an algorithm needs selective execution or an already sampled environment for
-each target:
+The result is a collection of **targets**, each representing a called model
+on one object. To choose individual targets before running them, use
+`call_targets`. This also lets you pass different environmental values to
+each one:
```julia
targets = call_targets(context, :leaf_energy)
@@ -137,8 +142,10 @@ for (target, leaf_environment) in zip(targets, environments_by_leaf)
end
```
-For a provider-aware trial state shared by the call, keep the execute-all form.
-Each target still samples through its own compiled handle:
+If an environment provider supplies values for different positions, you can
+give it a trial state for the whole call. PlantSimEngine then reads the
+appropriate values for each target's location. The following outline shows
+the order; your model must define how to calculate and accept a trial:
```julia
function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment,
@@ -154,14 +161,16 @@ function PlantSimEngine.run!(model::SceneEnergyBalance, status, environment,
end
```
-`run_call!` defaults to `publish=false`, which is useful for trial iterations.
-Pass non-committing trial state with the `environment` keyword. Use
-`commit_environment!` and `publish=true` for the accepted state so temporal
-streams and mutable environment state are published once.
-
-The MAESPA-style example uses the same mechanism: a model energy-balance model
-calls all selected leaf energy-balance models and the shared soil model while
-it solves canopy microclimate.
-
-Scenario wiring uses `ModelSpec(...; calls=...)`. Model authors should keep kernels generic
-and only require manual calls when the model really needs call-stack control.
+`run_call!` defaults to `publish=false`: trial results are not saved as
+accepted samples for output history or time-based connections. Pass a trial
+environment with the `environment` keyword. Once you accept a solution,
+`commit_environment!` writes its environmental values, and a call with
+`publish=true` saves the accepted result. Trial calculations can still change
+the objects' current values, so your algorithm must handle any changes it
+needs to discard.
+
+The MAESPA-style example uses the same approach: a canopy energy-balance
+model calls the leaf energy-balance models and the shared soil model while
+it solves canopy microclimate. Use manual calls for calculations that need
+this control. An ordinary input is enough when a model simply reads
+another model's result.
diff --git a/docs/src/step_by_step/detailed_first_example.md b/docs/src/step_by_step/detailed_first_example.md
deleted file mode 100644
index 5a762b2dd..000000000
--- a/docs/src/step_by_step/detailed_first_example.md
+++ /dev/null
@@ -1,225 +0,0 @@
-# [Detailed Walkthrough Of A Simple Simulation](@id detailed-walkthrough-of-a-simple-simulation)
-
-This page walks through a small composite-model/object simulation. It is written for
-readers who are still getting comfortable with Julia and PlantSimEngine.
-
-If you only want examples to copy and modify, see [Quick examples](quick_and_dirty_examples.md). For
-multi-object and multi-plant simulations, the same API scales up: add objects,
-select them with `ModelSpec(...; on=...)`, connect values with `ModelSpec(...; inputs=...)`, and use
-`ModelSpec(...; calls=...)` when a parent model must manually run child models.
-
-```@setup detailed_scene
-using PlantSimEngine, PlantMeteo, Dates, DataFrames
-using PlantSimEngine.Examples
-
-meteo_day = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
- duration=Dates.Day,
-)
-```
-
-```@contents
-Pages = ["detailed_first_example.md"]
-Depth = 3
-```
-
-## Setting Up Your Environment
-
-Every script needs a Julia environment with PlantSimEngine installed. Most
-examples also use companion packages such as PlantMeteo for weather data and
-DataFrames for tabular outputs. Installation details are in
-[Installing PlantSimEngine](../prerequisites/installing_plantsimengine.md).
-
-## The Simulation Pieces
-
-### Processes And Models
-
-A process is something you want to simulate, such as light interception,
-photosynthesis, water flux, growth, yield, or energy balance.
-
-A model is one implementation of a process. In this page we use the example
-`Beer` model, which implements a Beer-Lambert light-interception equation.
-Its only parameter is the extinction coefficient `k`.
-
-```@example detailed_scene
-fieldnames(Beer)
-```
-
-The model implementation declares the status variables it reads and writes:
-
-```@example detailed_scene
-inputs(Beer(0.5))
-```
-
-```@example detailed_scene
-outputs(Beer(0.5))
-```
-
-These declarations are the modeler's contract. The composite-model/object layer decides
-where the model runs and where those values come from.
-
-### CompositeModel Objects
-
-A `CompositeModel` contains simulated `Object`s. An object can represent a model, plant,
-axis, leaf, soil layer, sensor, voxel, or any other simulated entity.
-
-For a first example, we use one object representing the whole model. The `Beer`
-model reads `LAI`, so we initialize that variable on the object status.
-
-```@example detailed_scene
-model = CompositeModel(
- Beer(0.5);
- status=(LAI=2.0,),
- environment=meteo_day,
- timestep=Day(1),
-);
-nothing
-```
-
-The concise constructor creates one ordinary model object and one application
-for each supplied model. `status` initializes that object, `timestep` applies a
-common daily cadence, and `environment` supplies weather values such as
-radiation. Use explicit `ModelSpec` and selectors when applications need
-different policies or targets.
-
-## Inspecting The Compiled CompositeModel
-
-Before runtime, PlantSimEngine resolves selectors and builds a compiled model.
-This avoids resolving object selections inside the timestep loop.
-
-```@example detailed_scene
-select(
- DataFrame(Diagnostics.explain_applications(model)),
- :application_id,
- :process,
- :target_ids,
-)
-```
-
-`Beer` has no model-to-model value input in this first model because `LAI` was
-initialized directly on the object status:
-
-```@example detailed_scene
-Diagnostics.explain_bindings(model)
-```
-
-The schedule tells us when each application runs:
-
-```@example detailed_scene
-select(
- DataFrame(Diagnostics.explain_schedule(model)),
- :application_id,
- :dt_seconds,
- :root_scheduled,
- :manual_call_only,
-)
-```
-
-## Running The Simulation
-
-Run the model with [`run!`](@ref):
-
-```@example detailed_scene
-sim = run!(model; steps=3, outputs=:all)
-nothing
-```
-
-Final state is available independently of retained output history:
-
-```@example detailed_scene
-scene_status = final_state(sim)
-(LAI=scene_status.LAI, aPPFD=scene_status.aPPFD)
-```
-
-The returned `Simulation` stores retained output streams:
-
-```@example detailed_scene
-first(collect_outputs(sim; sink=nothing), 3)
-```
-
-For a table, use the default `DataFrame` sink:
-
-```@example detailed_scene
-first(collect_outputs(sim), 3)
-```
-
-## Adding A Model Coupling
-
-Now let a daily LAI model compute `LAI` before the light-interception model
-runs. `ToyLAIModel` reads cumulative thermal time `TT_cu` and writes `LAI`.
-Because `Beer` reads `LAI`, the compiler can infer the same-object binding.
-
-```@example detailed_scene
-coupled_scene = CompositeModel(
- ToyDegreeDaysCumulModel(),
- ToyLAIModel(),
- Beer(0.5);
- status=(TT_cu=0.0,),
- environment=meteo_day,
- timestep=Day(1),
-)
-
-select(
- DataFrame(Diagnostics.explain_bindings(coupled_scene)),
- :application_id,
- :input,
- :source_application_ids,
- :carrier_kind,
- :copy_semantics,
-)
-```
-
-The `LAI` binding uses a live reference carrier, so the light-interception
-model sees the value written by the LAI model without copying it.
-
-Run the coupled model:
-
-```@example detailed_scene
-coupled_sim = run!(coupled_scene; steps=5, outputs=:all)
-first(collect_outputs(coupled_sim), 8)
-```
-
-The final object status contains the latest values from the coupled models:
-
-```@example detailed_scene
-coupled_status = final_state(coupled_sim)
-(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD)
-```
-
-## What Needs Initialization?
-
-Model `inputs_(...)` explicitly distinguishes `Required(T)` from
-`Default(value)`. A required input needs user state or a producer binding; a
-defaulted input needs neither. In a coupled model, an upstream application can
-satisfy a required input.
-
-Use the compiler explanations to distinguish the two cases:
-
-- `:supplied` means the object `Status` already provides the value;
-- `:producer_bound` means another application supplies it;
-- `:defaulted` means `Default(value)` initialized it;
-- `:required` means it still has no source and compilation will fail.
-
-For example, if we remove `TT_cu` from the model status, compilation fails
-because no model in this model computes it before `ToyLAIModel` reads it:
-
-```@example detailed_scene
-bad_scene = CompositeModel(
- ToyLAIModel();
- environment=meteo_day,
-)
-
-try
- Diagnostics.explain_bindings(bad_scene)
-catch err
- first(sprint(showerror, err), 300)
-end
-```
-
-## Next Steps
-
-- [Standard model coupling](@ref) shows more coupling patterns.
-- [CompositeModel/Object Quickstart](../composite_model/quickstart.md) is the shortest
- copy-pasteable path for the new API.
-- [Model execution](../model_execution.md) explains scheduling, temporal inputs, hard calls,
- output retention, and lifecycle refreshes.
diff --git a/docs/src/step_by_step/implement_a_model.md b/docs/src/step_by_step/implement_a_model.md
deleted file mode 100644
index f2a63d5d3..000000000
--- a/docs/src/step_by_step/implement_a_model.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# [Implementing a model](@id model_implementation_page)
-
-The canonical, executable model-authoring tutorial is now
-[Implement a basic model](@ref). Start there for the complete five-argument
-kernel contract, direct testing, same-object coupling, and reuse over several
-objects.
-
-Before declaring a new abstract process type, use
-[New process or new model?](@ref) to determine whether the implementation is a
-new scientific process or another hypothesis for an existing process.
-
-Then continue according to the model's needs:
-
-- [Port an existing model](@ref) explains how to keep a scientific kernel
- readable and generic;
-- [Model repository layout and tests](@ref) places the process, model,
- documentation, and test levels in a package;
-- [Implement Cross-Object Values](@ref) covers `One`, `OptionalOne`,
- `Many`, and `Subtree()`;
-- [Coupling models](@ref) distinguishes value coupling, hard calls, and
- explicit adapters;
-- [Model compatibility and replacement](@ref) distinguishes process identity
- from drop-in substitutability;
-- [Model Traits](@ref) documents environment, timing, output, and scientific
- variable contracts.
-
-!!! compat
- Older versions of this page showed a hard dependency as an abstract model
- type returned directly by `dep(model)`. The current API declares a
- `Call(selector)`, for example
- `(stomata=Call(One(process=:stomatal_conductance)),)`.
diff --git a/docs/src/step_by_step/implement_a_model_additional.md b/docs/src/step_by_step/implement_a_model_additional.md
deleted file mode 100644
index 80822b10f..000000000
--- a/docs/src/step_by_step/implement_a_model_additional.md
+++ /dev/null
@@ -1,113 +0,0 @@
-# Model implementation additional notes
-
-```@contents
-Pages = ["implement_a_model_additional.md"]
-Depth = 3
-```
-
-## Parametric types
-
-In [Implementing a model](@ref model_implementation_page), the Beer model's structure was declared with a parametric type.
-
-```julia
-struct Beer{T} <: AbstractLight_InterceptionModel
- k::T
-end
-```
-
-Why not force the type ? Float64 is more accurate than Float32, after all:
-
-```julia
-struct YourStruct <: AbstractLight_InterceptionModel
- k::Float64
- x::Float64
- y::Float64
- z::Int
-end
-```
-
-Doing so would lose some flexibility in the way users can make use of your models. For example a user could use the `Particles` type from [MonteCarloMeasurements.jl](https://github.com/baggepinnen/MonteCarloMeasurements.jl) for automatic uncertainty propagation, and this is only possible if the model type is parameterizable. Forcing a `Float64` type would render the model incompatible with `Particles`.
-
-## Promoting model constructor arguments
-
-When implementing a new model, you can do a little optional extra work to help future users.
-
-You can add a constructor that uses Julia's `promote` function. This is
-promotion of model parameters while constructing the model; it is distinct
-from the `CompositeModel(...; type_promotion=...)` policy that converts status
-values for a scenario. See [Numerical Reliability](@ref) for that status
-policy.
-
-Constructor promotion would not make any difference for the previous `Beer`
-example because it has only one parameter. Consider instead a `Beer2` model
-with two parameters:
-
-```julia
-struct Beer2{T} <: AbstractLight_InterceptionModel
- k::T
- x::T
-end
-```
-
-To add type promotion to `Beer2` we would do:
-
-```julia
-function Beer2(k,x)
- Beer2(promote(k,x)...)
-end
-```
-
-!!! note
- `promote` returns a tuple, which is splatted into the constructor above.
- See the [Julia documentation](https://docs.julialang.org/en/v1/manual/conversion-and-promotion/#Promotion)
- for a more in-depth explanation.
-
-This would allow users to instantiate the model parameters using different types of inputs. For example users may write the following:
-
-```julia
-Beer2(0.6,2)
-```
-
-`Beer2` is a parametric type, with all fields sharing the same type `T`. This is the `T` in `Beer2{T}` and then in `k::T` and `x::T`. And this forces the user to give all parameters with the same type.
-
-In the example above, `0.6` for `k` is a `Float64`, while `2` for `x` is an
-`Int`. Constructor promotion converts both arguments to a common type when
-possible. In this case it converts `2` to `2.0`.
-
-## Other helper functions and constructors
-
-### Default parameter values
-
-You can simplify model usage by helping your user with default values for some parameters (if applicable). For example, in the `Beer` model a user will almost never change the value of `k`. So we can provide a default value like so:
-
-```@example usepkg
-Beer() = Beer(0.6)
-```
-
-Now the user can call `Beer` with no arguments, and `k` will default to `0.6`.
-
-### Parameter values as kwargs
-
-Another useful thing is the ability to instantiate your model type with keyword arguments, *i.e.* naming the arguments. You can do it by adding the following method:
-
-```@example usepkg
-Beer(;k) = Beer(k)
-```
-
-The `;` syntax indicates that subsequent arguments are provided as keyword arguments, so now we can call `Beer` like this:
-
-```julia
-Beer(k = 0.7)
-```
-
-This helps readability when there are a lot of parameters and some have default values.
-
-### eltype
-
-The last optional utility function to implement is a method for the `eltype` function:
-
-```julia
-Base.eltype(x::Beer{T}) where {T} = T
-```
-
-This one helps Julia know the type of the elements in the structure, and make it faster.
diff --git a/docs/src/step_by_step/implement_a_process.md b/docs/src/step_by_step/implement_a_process.md
index 71b65c416..8ed6b645e 100644
--- a/docs/src/step_by_step/implement_a_process.md
+++ b/docs/src/step_by_step/implement_a_process.md
@@ -1,63 +1,66 @@
# New process or new model?
-A process names a biological or physical question. A concrete model is one
-hypothesis, formulation, or scale at which that process is computed. Make this
-choice before writing the model type: it determines whether users can discover
-your implementation beside existing alternatives.
+A **process** names a biological or physical question. A **model** is one
+hypothesis or formulation used to answer it. For example, two photosynthesis
+equations can belong to the same process even if they need different inputs.
-## Prefer an existing process
+Before creating a model, check whether the process already exists. That lets
+users find your implementation beside the alternatives they may want to compare.
-Start by loading the packages that may own the process and inspect what they
-declare:
+## Find the existing family
+
+Load the package that provides the models. This example uses the teaching models
+distributed with PlantSimEngine:
```@example choose_process
using PlantSimEngine
+using PlantSimEngine.Examples
-Authoring.available_processes()
+growth_models = Authoring.available_models(AbstractGrowthModel)
+growth_models
```
-`Authoring.available_processes()` can only see loaded Julia modules. Search the source of
-the target package as well before deciding that a process is missing. The
-[Loaded model catalog](@ref) shows the same discovery result grouped into a
-generated table with provenance and completeness.
-
-Reuse an existing abstract process type when the new implementation answers
-the same scientific question. Examples include two photosynthesis
-formulations, a simple and a water-stress-aware radiation-use-efficiency model,
-or the same process represented at different scales. Give each hypothesis its
-own concrete model instead of adding a `method=:a_or_b` switch to one large
-kernel.
-
-Models in the same process form a scientific family, but they are not
-necessarily interchangeable. They may require different inputs, produce
-different outputs, use different clocks, or declare different
-`VariableContract`s. See [Model compatibility and replacement](@ref) before
-using one as an `Override` or replacing it without revisiting scenario
-bindings.
-
-## Declare a process only for a new meaning
+For a scientific application, load its model package first and inspect its
+documentation. `Authoring.available_processes()` lists the process types
+available in the packages you have loaded. `Authoring.available_models(process_type)`
+then lists the model types that implement that process.
-Create a process when no existing process has the same biological or physical
-meaning:
+Choose a model, give it a parameter value, and inspect its inputs and outputs:
```@example choose_process
-PlantSimEngine.@process "docs_root_exudation" verbose=false
-
-abstract = AbstractDocs_Root_ExudationModel
-abstract <: AbstractModel
+candidate = ToyRUEGrowthModel(0.2)
+(
+ process=process(candidate),
+ inputs=inputs(candidate),
+ outputs=outputs(candidate),
+)
```
-The generated abstract type is formed by prefixing `Abstract`, preserving word
-boundaries from the process name, and appending `Model`. For example:
+[Loaded model catalog](@ref) explains discovery and inspection in more detail.
+These functions only find packages loaded into Julia. Also check the package
+you plan to use before concluding that a process is missing.
-| Declaration | Generated abstract type |
+## Decide what your new equation changes
+
+| Your change | What to create |
|---|---|
-| `@process "growth"` | `AbstractGrowthModel` |
-| `@process "light_interception"` | `AbstractLight_InterceptionModel` |
+| Another equation, assumption, or level of detail for the same question | A model under the existing process |
+| A distinct biological or physical question | A new process and its model |
+| A conversion such as radiation per square metre to radiation per plant | A small conversion model, called an adapter |
+
+Give alternative equations separate model types so users can select and test
+them. To compare parameter values in the same equation, create instances of
+that model with different parameters. Models in one process may need
+different inputs, produce different outputs, or run at different frequencies.
+Use [Model compatibility and replacement](@ref) before substituting one.
-Concrete implementations subtype the generated abstract type:
+## Declare a genuinely new process
+
+This declaration creates a process for a teaching example of root exudation:
```@example choose_process
+PlantSimEngine.@process "docs_root_exudation" verbose=false
+
struct DocsLinearExudation{T} <: AbstractDocs_Root_ExudationModel
fraction::T
end
@@ -65,43 +68,18 @@ end
process(DocsLinearExudation(0.1))
```
-The macro generates process identity and the abstract type; it does not choose
-ports, units, parameters, defaults, equations, or scientific validation for
-you. Continue with [Implement a basic model](@ref), which is the canonical
-model-authoring path.
+`@process` creates the abstract type that concrete models inherit from:
-## A practical decision test
-
-Ask these questions in order:
-
-1. Is the quantity being simulated and its scientific meaning already
- represented by a loaded process?
-2. Would users reasonably compare this implementation with the existing
- implementations as alternative hypotheses?
-3. Is the difference only an equation, assumption, parameterization, scale, or
- resolution of that same question?
-
-If the answers point to the same question, add a model to the existing
-process. Create a new process only when the meaning itself changes. If the new
-model changes units or basis while connecting two existing meanings, implement
-an explicit adapter model instead; see [Coupling models](@ref).
-
-## Without the macro
-
-`@process` is a small convenience. The equivalent manual declaration is:
-
-```@example choose_process
-abstract type AbstractDocsManualRootExudationModel <:
- PlantSimEngine.AbstractModel end
-PlantSimEngine.process_(
- ::Type{AbstractDocsManualRootExudationModel},
-) = :docs_manual_root_exudation
-
-struct DocsManualRootExudation <: AbstractDocsManualRootExudationModel end
+| Declaration | Generated abstract type |
+|---|---|
+| `@process "growth"` | `AbstractGrowthModel` |
+| `@process "light_interception"` | `AbstractLight_InterceptionModel` |
-manual_process = process(DocsManualRootExudation())
-@assert manual_process == :docs_manual_root_exudation
-manual_process
-```
+When another package already declares the process, import its abstract type
+and use it after `<:` in your model definition. This puts your model in the
+same family as the existing alternatives.
-Prefer the macro for ordinary package code so process naming stays consistent.
+The type above is only the beginning. It still needs input and output
+declarations, descriptions of the variables' units and meaning, an equation,
+and tests. Continue with
+[Implement a basic model](@ref) to complete those steps.
diff --git a/docs/src/step_by_step/model_switching.md b/docs/src/step_by_step/model_switching.md
index c593a8031..c27ca7da4 100644
--- a/docs/src/step_by_step/model_switching.md
+++ b/docs/src/step_by_step/model_switching.md
@@ -1,188 +1,141 @@
# Model compatibility and replacement
-```@setup scene_model_switching
-using PlantSimEngine, PlantMeteo, Dates, DataFrames
-using PlantSimEngine.Examples
+Suppose daily carbon gain increases with intercepted light. We want to compare
+a linear response with a saturating response, then add a soil-water limitation.
+All three models answer the same scientific question, but the last needs an
+extra input.
-meteo_day = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
- duration=Dates.Day,
-)
-```
-
-One main objective of PlantSimEngine is to compare and switch model
-implementations for a process without changing the engine or unrelated model
-kernels. Process identity and substitutability are nevertheless different
-claims:
-
-1. **Same process:** two models answer the same scientific question.
-2. **Scenario-compatible replacement:** the new model supplies every value
- required by the current consumers and its own inputs can be bound.
-3. **Drop-in replacement:** process, status and environment ports, scientific
- contracts, dependencies, and relevant traits are compatible without
- changing scenario wiring.
-
-Only the third level is suitable for an `Override`, whose applications share
-one logical interface. Models may belong to the same process while using
-different inputs or producing additional outputs; this is useful scientific
-variation, not an error.
-
-At the model-application layer, replace the model inside a `ModelSpec`, keep
-the same `ModelSpec(...; on=...)` selector, then revalidate every binding and
-consumer affected by the changed interface.
+This example uses teaching models with arbitrary coefficients. They demonstrate
+replacement and input checks; their outputs are not predictions for a crop.
-## A first simulation
-
-This model computes degree-days, LAI, absorbed PAR, and growth on one model
-object:
+## Load one family of alternatives
```@example scene_model_switching
-function plant_model_with_growth(growth_model; growth_name=:growth)
- CompositeModel(
- Object(:scene; scale=:Scene, kind=:scene);
- applications=(
- ModelSpec(ToyDegreeDaysCumulModel(); name=:degree_days, on=One(scale=:Scene), every=Day(1)),
-
- ModelSpec(ToyLAIModel(); name=:lai, on=One(scale=:Scene), every=Day(1)),
-
- ModelSpec(Beer(0.5); name=:light_interception, on=One(scale=:Scene), every=Day(1)),
+using Dates, Test, PlantSimEngine
- ModelSpec(growth_model; name=growth_name, on=One(scale=:Scene), every=Day(1)),
- ),
- environment=meteo_day,
- )
-end
+include(joinpath(
+ pkgdir(PlantSimEngine), "skills", "plantsimengine",
+ "assets", "alternative-model.jl",
+))
+using .AlternativeModelExample
-rue_scene = plant_model_with_growth(ToyRUEGrowthModel(0.2))
-rue_sim = run!(rue_scene; steps=10)
-rue_status = final_state(rue_sim)
-(growth_model=:ToyRUEGrowthModel, biomass=rue_status.biomass)
+linear = LinearCarbonGain(0.2)
+saturating = SaturatingCarbonGain(10.0, 5.0)
+water_limited = WaterLimitedCarbonGain(0.2)
```
-The compiler infers the same-object bindings from the model declarations. The
-growth model reads `aPPFD`, which is produced by the light interception model:
+| Model | Equation | Required inputs |
+|---|---|---|
+| Linear | efficiency × absorbed PAR | Absorbed PAR |
+| Saturating | maximum × absorbed PAR / (half-saturation + absorbed PAR) | Absorbed PAR |
+| Water-limited | efficiency × absorbed PAR × bounded soil-water fraction | Absorbed PAR and soil-water fraction |
-```@example scene_model_switching
-select(
- DataFrame(Diagnostics.explain_bindings(rue_scene)),
- :application_id,
- :input,
- :source_application_ids,
- :origin,
- :carrier_kind,
-)
-```
+Absorbed PAR is a daily total in mol photons per plant; carbon gain is a daily
+total in g carbon per plant. The soil-water fraction is dimensionless. The
+models record these units and meanings in `VariableContract` declarations.
-## Switching the growth model
+## Check before changing a scenario
-`ToyAssimGrowthModel` implements the same `:growth` process and reads the same
-`aPPFD` input, but computes additional outputs such as carbon assimilation and
-respiration. It is compatible with this small scenario because no downstream
-model requires an output that disappeared. Its larger output interface means
-that same process alone did not prove a strict drop-in replacement:
+Compare the models with their chosen parameters:
```@example scene_model_switching
-assim_scene = plant_model_with_growth(ToyAssimGrowthModel())
-assim_sim = run!(assim_scene; steps=10)
-assim_status = final_state(assim_sim)
+compatible = Authoring.compare_models(linear, saturating)
+needs_water = Authoring.compare_models(linear, water_limited)
+
+@test compatible.override_compatible
+@test needs_water.requires_binding_changes
(
- growth_model=:ToyAssimGrowthModel,
- carbon_assimilation=assim_status.carbon_assimilation,
- Rm=assim_status.Rm,
- biomass=assim_status.biomass,
+ saturating_can_replace_directly=compatible.override_compatible,
+ water_limited_needs_new_inputs=needs_water.requires_binding_changes,
)
```
-The dependency graph and execution plan are rebuilt from the new application
-set:
+The linear and saturating models can replace each other directly: they read
+and write the same kinds of values and have compatible settings. The
+water-limited model answers the same question, but it also needs `ftsw`, the
+fraction of transpirable soil water.
-```@example scene_model_switching
-select(
- DataFrame(Diagnostics.explain_execution_plan(assim_sim)),
- :application_id,
- :object_ids,
- :batch_size,
- :inner_loop_dispatch,
-)
-```
+Three distinctions matter:
-## Check before replacing
+- **Same process:** the models answer the same scientific question.
+- **Usable in this scenario:** the new model can get all its inputs and
+ provide the results needed by the models connected to it.
+- **Direct override:** you can replace the model without changing its
+ connections or other settings. PlantSimEngine checks the process, variables
+ and their physical meaning, calls to other models, and timing requirements.
-Use the public authoring report on the concrete instances:
+Use `Override` only for the third case. For broader changes, replace the
+model in a `ModelSpec` and update the affected inputs or other configuration.
-```@example scene_model_switching
-alternative_asset = joinpath(
- pkgdir(PlantSimEngine),
- "skills",
- "plantsimengine",
- "assets",
- "alternative-model.jl",
-)
-include(alternative_asset)
-using .AlternativeModelExample
+## Compare the two light responses
-drop_in = Authoring.compare_models(
- LinearCarbonGain(0.2),
- SaturatingCarbonGain(10.0, 5.0),
-)
-needs_binding = Authoring.compare_models(
- LinearCarbonGain(0.2),
- WaterLimitedCarbonGain(0.2),
-)
-(
- drop_in=(
- same_process=drop_in.same_process,
- override_compatible=drop_in.override_compatible,
- requires_reconfiguration=drop_in.requires_reconfiguration,
- compatibility=drop_in.compatibility,
- ),
- water_limited=(
- same_process=needs_binding.same_process,
- override_compatible=needs_binding.override_compatible,
- requires_binding_changes=needs_binding.requires_binding_changes,
- requires_reconfiguration=needs_binding.requires_reconfiguration,
- compatibility=needs_binding.compatibility,
- ),
-)
-```
+Keep the plants, input data, and timing identical:
+
+```@example scene_model_switching
+function carbon_scenario(gain_model; values=(absorbed_par=10.0,))
+ CompositeModel(
+ Object(:plant; scale=:Plant, status=Status(; values...));
+ applications=(
+ ModelSpec(gain_model; name=:carbon_gain, on=One(scale=:Plant)),
+ ),
+ environment=(duration=Day(1),),
+ )
+end
-`requires_binding_changes` is specific to ports, contracts, dependencies,
-output policies, or model-level environment hints. `requires_reconfiguration`
-is broader: it is true for any interface difference that prevents a direct
-override, including a schedule-only trait change.
+linear_scene = carbon_scenario(linear)
+saturating_scene = carbon_scenario(saturating)
-The report compares:
+linear_gain = final_state(run!(linear_scene)).carbon_gain
+saturating_gain = final_state(run!(saturating_scene)).carbon_gain
+@test linear_gain == 2.0
+@test saturating_gain ≈ 100 / 15
+(linear=linear_gain, saturating=saturating_gain)
+```
-- `process(model)`;
-- required and defaulted status inputs, including declared types;
-- local `outputs_` schemas, including initial values;
-- environment inputs and outputs;
-- complete `VariableContract`s;
-- model-authored `Input`, `Call`, and `Initializer` dependencies;
-- cadence and temporal output policies.
+Only the selected hypothesis changed. At 10 mol of absorbed photons the
+linear equation produces 2 g carbon and the saturating equation about
+6.67 g carbon. These different outcomes reflect the chosen teaching
+coefficients. Models that can replace each other do not have to give the
+same result: comparing those results is the purpose of the experiment.
-A variable with the same name but a different unit, basis, temporal meaning,
-aggregation, or extent is incompatible. Add an explicit adapter rather than
-weakening or omitting the contract.
+## Supply the additional water input
-After replacement, compile the candidate scenario and inspect:
+For a controlled comparison, supply a soil-water fraction of 0.5 alongside
+the same light input:
```@example scene_model_switching
-candidate_validation = Authoring.validate_scenario(assim_scene)
-(
- scenario_valid=candidate_validation.valid,
- initialization=Diagnostics.explain_initialization(assim_scene),
- bindings=Diagnostics.explain_bindings(assim_scene),
- schedule=Diagnostics.explain_schedule(assim_scene),
+water_scene = carbon_scenario(
+ water_limited;
+ values=(absorbed_par=10.0, ftsw=0.5),
)
-```
+validation = Authoring.validate_scenario(water_scene)
+@test validation.valid
-The compiler diagnostics prove that this concrete scenario can initialize and
-route the replacement. They do not prove that two equations are scientifically
-equivalent or valid over the same domain; that remains model documentation and
-validation evidence. Distributed `outputs_to` destinations belong to the
-`ModelSpec`, not `ModelInterface`, so revalidate them at this scenario level.
+water_gain = final_state(run!(water_scene)).carbon_gain
+@test water_gain == 1.0
+(linear=linear_gain, water_limited=water_gain)
+```
-Use `ObjectInstance(...; overrides=...)` only for a model that satisfies the
-logical application's exact replacement contract. Otherwise create or replace
-a complete `ModelSpec` and update the affected bindings explicitly.
+Here we supplied the soil-water fraction ourselves. To let it change during
+a simulation, read it from a soil model or dataset using [Coupling models](@ref). Inspect
+`Diagnostics.explain_initialization(water_scene)` to see how each required
+input was supplied, then `Diagnostics.explain_bindings(water_scene)` for
+connections to other models.
+
+## Keep physical meaning and scientific validation visible
+
+Two variables with the same name may use different units or describe
+different quantities. Check whether each value is per plant or per unit
+area, and whether it is a rate, a mean, or a total. If a conversion is needed,
+write it as a small model; see [explicit adapters](../guides/coupling.md).
+
+`Authoring.compare_models` reports differences in declarations.
+`Authoring.validate_scenario` checks whether the models can work together in
+your setup. Neither proves that an equation or its parameters are valid for
+your study. Also compare assumptions, the conditions in which the models
+have been tested, reference data, and simulation results.
+
+For detailed reports, inspect `requires_binding_changes` for connection
+changes and `requires_reconfiguration` for all differences that prevent a
+direct override, including changes to how often a model runs. The [Public API](@ref) describes
+the complete report.
diff --git a/docs/src/step_by_step/quick_and_dirty_examples.md b/docs/src/step_by_step/quick_and_dirty_examples.md
deleted file mode 100644
index be48327f0..000000000
--- a/docs/src/step_by_step/quick_and_dirty_examples.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Quick Examples
-
-This page is for copy-paste experimentation with the native composite-model/object API.
-If you want a slower explanation of the same ideas, see
-[Detailed Walkthrough Of A Simple Simulation](@ref detailed-walkthrough-of-a-simple-simulation).
-
-The examples use one model object, but the same pattern scales to plants,
-organs, soil objects, and microclimate grids by adding more `Object`s and
-selecting them with `ModelSpec(...; on=...)` and `ModelSpec(...; inputs=...)`.
-
-```@setup quick_model_examples
-using PlantSimEngine, PlantMeteo, Dates, DataFrames
-using PlantSimEngine.Examples
-
-meteo_day = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
- duration=Dates.Day,
-)
-```
-
-```@contents
-Pages = ["quick_and_dirty_examples.md"]
-Depth = 2
-```
-
-## One Light Interception Model
-
-```@example quick_model_examples
-model = CompositeModel(
- Beer(0.5);
- status=(LAI=2.0,),
- environment=meteo_day,
-)
-
-sim = run!(model; steps=3, outputs=:all)
-first(collect_outputs(sim), 3)
-```
-
-## LAI And Light Interception
-
-Here, `ToyDegreeDaysCumulModel` computes cumulative thermal time, `ToyLAIModel`
-computes `LAI`, and `Beer` consumes `LAI`. The compiler infers the same-object
-value bindings from model inputs and outputs.
-
-```@example quick_model_examples
-lai_scene = CompositeModel(
- ToyDegreeDaysCumulModel(),
- ToyLAIModel(),
- Beer(0.5);
- environment=meteo_day,
-)
-
-lai_sim = run!(lai_scene; steps=5, outputs=:all)
-first(collect_outputs(lai_sim), 8)
-```
-
-Inspect the inferred coupling:
-
-```@example quick_model_examples
-select(
- DataFrame(Diagnostics.explain_bindings(lai_scene)),
- :application_id,
- :input,
- :source_application_ids,
- :carrier_kind,
-)
-```
-
-## Add Biomass Growth
-
-`ToyRUEGrowthModel` consumes absorbed light and accumulates biomass. No extra
-input binding is needed because `Beer` is the unique producer of `aPPFD` on the
-same object.
-
-```@example quick_model_examples
-growth_scene = CompositeModel(
- ToyDegreeDaysCumulModel(),
- ToyLAIModel(),
- Beer(0.5),
- ToyRUEGrowthModel(0.2);
- environment=meteo_day,
-)
-
-growth_sim = run!(growth_scene; steps=5)
-growth_status = final_state(growth_sim)
-(LAI=growth_status.LAI, aPPFD=growth_status.aPPFD, biomass=growth_status.biomass)
-```
-
-## Keep Only One Requested Output
-
-For larger simulations, request only the streams you want to keep:
-
-```@example quick_model_examples
-request = OutputRequest(
- :Scene,
- :biomass;
- name=:biomass_daily,
- application=:growth,
- policy=HoldLast(),
- clock=Day(1),
-)
-
-requested_sim = run!(
- growth_scene;
- steps=5,
- outputs=request,
-)
-
-first(collect_outputs(requested_sim, :biomass_daily), 5)
-```
-
-## PlantBiophysics
-
-The same composite-model/object API can host models from companion packages such as
-PlantBiophysics. A typical PlantBiophysics energy-balance setup uses
-`ModelSpec(...; calls=...)` so an iterative parent model can manually run photosynthesis and
-stomatal-conductance models, then call `run_call!(target; publish=true)` once
-for the accepted solution.
-
-See [MAESPA-style model example handoff](../dev/maespa_model_handoff.md) for
-the current multi-plant energy-balance acceptance example.
diff --git a/docs/src/step_by_step/simple_model_coupling.md b/docs/src/step_by_step/simple_model_coupling.md
deleted file mode 100644
index 56882d6b2..000000000
--- a/docs/src/step_by_step/simple_model_coupling.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Standard model coupling
-
-```@setup scene_coupling
-using PlantSimEngine
-using PlantSimEngine.Examples
-using PlantMeteo, Dates, DataFrames
-
-meteo_day = read_weather(
- joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv");
- duration=Dates.Day,
-)
-```
-
-This page shows the standard coupling case: one model computes a variable that
-another model reads. In the composite-model/object API, the user describes model
-applications on objects, and the compiler wires the value dependencies.
-
-## Setting up your environment
-
-Make sure you have a working Julia environment with PlantSimEngine and the
-recommended companion packages. Details are provided on the
-[Installing PlantSimEngine](../prerequisites/installing_plantsimengine.md)
-page.
-
-## One object and one model
-
-A model contains objects. A model application says where a model runs. Here a
-light interception model runs on the model object, uses the environment's
-daily cadence, and reads `LAI` from that object's status:
-
-```@example scene_coupling
-light_scene = CompositeModel(
- Beer(0.5);
- status=(LAI=2.0,),
- environment=meteo_day,
-)
-
-light_sim = run!(light_scene; steps=3, outputs=:all)
-first(collect_outputs(light_sim; sink=DataFrame), 3)
-```
-
-## Coupling two models
-
-Suppose we want `ToyLAIModel` to compute `LAI` for `Beer`. Both models can run
-on the same object. `ToyLAIModel` produces `LAI`, and `Beer` declares `LAI` as
-an input, so the model compiler infers the binding:
-
-```@example scene_coupling
-coupled_scene = CompositeModel(
- ToyDegreeDaysCumulModel(),
- ToyLAIModel(),
- Beer(0.5);
- environment=meteo_day,
-)
-
-select(
- DataFrame(Diagnostics.explain_bindings(coupled_scene)),
- :application_id,
- :input,
- :source_application_ids,
- :origin,
- :carrier_kind,
- :copy_semantics,
-)
-```
-
-The `:inferred_same_object` rows are soft dependencies: the consumer input is
-provided by another model output. Same-rate local links use live references, so
-the timestep loop does not copy values between models.
-
-Run the coupled model:
-
-```@example scene_coupling
-coupled_sim = run!(coupled_scene; steps=5)
-coupled_status = final_state(coupled_sim)
-(TT_cu=coupled_status.TT_cu, LAI=coupled_status.LAI, aPPFD=coupled_status.aPPFD)
-```
-
-## Adding another model
-
-Additional models are just additional applications. `ToyRUEGrowthModel`
-consumes `aPPFD`, which is produced by `Beer`, so the compiler infers another
-same-object binding:
-
-```@example scene_coupling
-growth_scene = CompositeModel(
- ToyDegreeDaysCumulModel(),
- ToyLAIModel(),
- Beer(0.5),
- ToyRUEGrowthModel(0.2);
- environment=meteo_day,
-)
-
-growth_sim = run!(growth_scene; steps=5)
-growth_status = final_state(growth_sim)
-(LAI=growth_status.LAI, aPPFD=growth_status.aPPFD, biomass=growth_status.biomass)
-```
diff --git a/docs/src/troubleshooting/common_errors.md b/docs/src/troubleshooting/common_errors.md
index a8df3b70d..6c75af856 100644
--- a/docs/src/troubleshooting/common_errors.md
+++ b/docs/src/troubleshooting/common_errors.md
@@ -1,13 +1,59 @@
# Common Errors
-A missing-input error means a `Required(T)` input has neither supplied state
-nor a producer binding; start with `Diagnostics.explain_initialization`. A
-plain-input-declaration error means `inputs_` must replace each literal with
-`Required(T)` or `Default(value)`. A cardinality error lists selector matches;
-correct the scope or choose the intended `OptionalOne`/`Many` multiplicity. An
-ambiguity requires an explicit application/object selector.
-
-Duplicate-writer errors require either distinct output routing or an explicit
-`Updates` order. Cadence errors require fixed `Dates` periods compatible with
-the environment base step. Extend package functions as
-`PlantSimEngine.run!(...)`, including the package qualification.
+Start with the model application, object, and variable named in the error.
+An application is a model configured to run on selected objects. These names
+tell you where to look: for example, a light calculation on a particular
+canopy may be missing LAI. PlantSimEngine catches many such problems before
+running any equation.
+
+| Symptom | What it means | First action |
+|:--|:--|:--|
+| A required input is missing | You have not supplied a starting value or a model that calculates it | Supply the value or connect a model that provides it |
+| A selector finds too few or too many objects | For example, `One` expects one match but finds two | Check the object labels and where the selector searches |
+| More than one source matches | Several models could supply the input | Name the intended `application` and, if needed, `var` |
+| Several models set the same variable | PlantSimEngine cannot decide which value to keep | Choose one model, or use `Updates` if one model is meant to change another's result |
+| Variable contracts differ | The connected variables declare different units or physical meanings | Check what each equation expects; add a conversion model where appropriate |
+| A cadence is rejected | The chosen interval does not fit the base step or is not supported by the model | [Choose compatible time steps](../guides/time/advanced_time_environment.md) |
+| There is a dependency cycle | Two or more models each wait for a result from the others | Decide whether one input should come from the previous step or the equations must be solved together |
+
+## Example: the light model needs LAI
+
+`Beer` needs the canopy's LAI to calculate absorbed light. If no other model
+calculates LAI, supply its value yourself:
+
+```@example missing_lai
+using PlantSimEngine
+using PlantSimEngine.Examples
+
+model = CompositeModel(Beer(0.6);
+ status=(LAI=2.0,), environment=(Ri_PAR_f=100.0,))
+simulation = run!(model)
+final_state(simulation).aPPFD
+```
+
+Alternatively, add a model that calculates LAI and supply that model's inputs.
+An arbitrary zero may remove a missing-input error while changing the
+scientific question. Use a value with a clear meaning.
+
+## Check the objects a selector actually sees
+
+Inspect `Diagnostics.explain_objects(model)` to see labels and parent links.
+For a plant application, `Many(scale=:Leaf, within=Subtree())` searches that
+plant and its descendants. For a leaf application, the same selector starts
+at that leaf. Use `SelfPlant()` when the search should cover its whole plant.
+
+Choose `Many` when the receiving model accepts a collection. Choosing it only
+to silence an error can pass a vector to an equation that expects one value.
+
+## Errors while writing a model
+
+Declare each status input with `Required(T)` if it must be supplied, or
+`Default(value)` if the model provides a fallback value. For example, use
+`inputs_(::MyModel) = (LAI=Required(Real),)` with the function qualified as
+`PlantSimEngine.inputs_`. Writing a number directly in `inputs_` does not
+declare a required input. Define the equation as `PlantSimEngine.run!(...)`
+so that Julia adds your model's method to the package function.
+
+For the complete sequence, see [Write and test a first model](../journeys/modelers/basic_model.md).
+For further investigation, see [Inspect a simulation](runtime_contracts.md)
+and [Dependency cycles](dependency_cycles.md).
diff --git a/docs/src/troubleshooting/dependency_cycles.md b/docs/src/troubleshooting/dependency_cycles.md
index 312321816..db1979aa9 100644
--- a/docs/src/troubleshooting/dependency_cycles.md
+++ b/docs/src/troubleshooting/dependency_cycles.md
@@ -1,17 +1,17 @@
# Diagnosing Dependency Cycles
-A same-step value cycle is rejected because no valid execution order exists.
-Read the reported application, object, and variable edges. If the science uses
-yesterday's value, put `PreviousTimeStep(:variable)` on that input. If the
-science requires convergence in the current step, make one parent application
-own child trials with `calls`. Otherwise reformulate the coupled equations.
+A **dependency cycle** occurs when two or more models each need a new result
+from the others before they can run. For example, a leaf model needs water
+from a root model, but the root model needs carbon from the leaf model.
+Neither can go first, so PlantSimEngine reports the problem before running
+the equations. The error names the applications, objects, and variables
+involved.
-Application declaration order is not a cycle-resolution mechanism.
-
-For example, if application `:leaf` reads same-step `water` from `:root` while
-`:root` reads same-step `carbon` from `:leaf`, compilation fails before either
-kernel runs. If root water scientifically affects tomorrow's leaf carbon,
-change only that edge:
+Choose the solution that matches your equations. If the leaf should use the
+root's water from the previous step, mark that input with
+`PreviousTimeStep(:water)`. For a daily simulation, this means yesterday's
+water. The leaf can then calculate today's carbon before the root calculates
+today's water:
```julia
ModelSpec(
@@ -23,7 +23,15 @@ ModelSpec(
)
```
-The receiving object's initial `water` value is used until the first accepted
-historical sample exists. If both values must converge within the same step,
-do not add a lag: make a parent model own `calls` to the two trial models,
-iterate with `publish=false`, and publish each accepted state once.
+At the start, the leaf's initial `water` value is used until a result from a
+previous step is available.
+
+If the two values must instead be solved together in the current step, use
+a controller model that calls both models repeatedly. Try values with
+`publish=false` and record each accepted result once with `publish=true`.
+The controller must decide when the result is close enough and what to do if
+the calculation does not converge. See
+[Control Advanced Execution](../journeys/users/advanced_execution.md).
+
+You may also need to rewrite the equations. Simply changing the order of
+the `ModelSpec` declarations does not resolve a cycle.
diff --git a/docs/src/troubleshooting/runtime_contracts.md b/docs/src/troubleshooting/runtime_contracts.md
index 7e5fda2bd..59b0c41c2 100644
--- a/docs/src/troubleshooting/runtime_contracts.md
+++ b/docs/src/troubleshooting/runtime_contracts.md
@@ -1,12 +1,60 @@
-# Runtime Contracts And Diagnostics
-
-Use `Diagnostics.explain_initialization`, `Diagnostics.explain_bindings`, `Diagnostics.explain_calls`,
-`Diagnostics.explain_schedule`, `Diagnostics.explain_environment_bindings`, and
-`Diagnostics.explain_output_retention` as the supported inspection surface. Do not inspect
-compiled internal fields.
-
-Targets, carriers, calls, writer checks, and schedules refresh after the
-application that made a structural change. New objects join applications still
-remaining in that timestep; they do not retroactively run earlier applications.
-Movement and geometry changes invalidate affected spatial environment bindings.
-Accepted streams are append-only.
+# Inspect A Simulation
+
+When a result is unexpected, first check what ran, where it ran, and which
+values it read. The `Diagnostics` functions return information you can display
+as a table. Use them to check how the simulation is configured. Check the
+scientific equations and parameter values separately.
+
+| Question | Diagnostic |
+|:--|:--|
+| Which objects and labels exist? | `Diagnostics.explain_objects(model)` |
+| Which models run on them? | `Diagnostics.explain_applications(model)` |
+| Where does each input come from? | `Diagnostics.explain_bindings(model)` |
+| Which initial values are supplied or missing? | `Diagnostics.explain_initialization(model)` |
+| When does each application run? | `Diagnostics.explain_schedule(model)` |
+| Which weather or spatial source is sampled? | `Diagnostics.explain_environment_bindings(model)` |
+| Which models are called by a controller? | `Diagnostics.explain_calls(model)` |
+| Why were earlier values of an output kept? | `Diagnostics.explain_output_retention(simulation)` |
+
+## Inspect a small working example
+
+Here leaf surface depends on supplied carbon biomass. Check its input source
+before running a larger structure:
+
+```@example inspect_simulation
+using PlantSimEngine, DataFrames
+using PlantSimEngine.Examples
+
+model = CompositeModel(ToyLeafSurfaceModel(0.02);
+ status=(carbon_biomass=50.0,), id=:leaf, scale=:Leaf)
+DataFrame([
+ (variable=row.variable, role=row.role, disposition=row.disposition)
+ for row in Diagnostics.explain_initialization(model)
+])
+```
+
+```@example inspect_simulation
+simulation = run!(model; outputs=:all)
+@assert final_state(simulation).surface ≈ 1.0 # hide
+DataFrame(Diagnostics.explain_output_retention(simulation))
+```
+
+If model construction fails, use the error's object and application names to
+correct the configuration first. A diagnostic cannot inspect a model that was
+never constructed.
+
+## After growth or movement
+
+After adding or removing organs, PlantSimEngine updates which objects each
+model runs on and where its inputs come from. This happens **after the
+application that changed the structure finishes**. New organs may run models
+scheduled later in the same time step. Models that already ran are not
+repeated. If you change the structure between steps, these connections are
+updated before the next step.
+
+If you move an organ or change its geometry, PlantSimEngine needs to find its
+new location in the spatial environment. Use functions such as `move_object!`
+and `update_geometry!` so it knows to update this connection. Removing an
+organ stops its future calculations and keeps results that were already
+saved. See [Modify plant structure](../journeys/users/structure_changes.md)
+for an example that checks the affected objects and their carbon balance.
diff --git a/docs/src/troubleshooting_and_testing/downstream_tests.md b/docs/src/troubleshooting_and_testing/downstream_tests.md
deleted file mode 100644
index f78ec40ab..000000000
--- a/docs/src/troubleshooting_and_testing/downstream_tests.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Automated tests : downstream dependency checking
-
-PlantSimEngine is [open sourced on Github](https://github.com/VirtualPlantLab/PlantSimEngine.jl), and so are its other companion packages, [PlantGeom.jl](https://github.com/VEZY/PlantGeom.jl), [PlantMeteo.jl](https://github.com/VEZY/PlantMeteo.jl), [PlantBioPhysics.jl](https://github.com/VEZY/PlantBioPhysics.jl), [MultiScaleTreeGraph.jl](https://github.com/VEZY/MultiScaleTreeGraph.jl), and [XPalm](https://github.com/PalmStudio/XPalm.jl).
-
-One handy CI (Continuous Integration) feature implemented for these packages is automated integration and downstream testing: after changes to a package, its known downstream dependencies are tested to ensure no breaking changes were introduced.
-
-For instance, PlantBioPhysics uses PlantSimEngine, so the integration workflow checks that PlantBioPhysics's tests do not break unexpectedly after changes to PlantSimEngine. The repository also keeps a separate benchmark workflow and benchmark scripts in the `benchmark/` directory for performance tracking.
-
-If you maintain a package that depends on PlantSimEngine, you can propose adding it to the downstream integration workflow through a pull request.
diff --git a/docs/src/tutorials/growing_plant/part1_growth.md b/docs/src/tutorials/growing_plant/part1_growth.md
index aa692f125..b9ed2d74b 100644
--- a/docs/src/tutorials/growing_plant/part1_growth.md
+++ b/docs/src/tutorials/growing_plant/part1_growth.md
@@ -1,35 +1,51 @@
# Growing A Plant CompositeModel
-Begin with a plant object and leaf objects whose carbon production is gathered
-by a plant application through `Many(scale=:Leaf, within=Subtree())`. A growth
-model calls `register_object!` after its carbon or thermal threshold is met.
-
-Structural changes refresh compiled targets after the application that made
-the change. A new leaf may run applications that remain later in the same
-timestep. It runs an application that already completed only when its creator
-declares that application as an `Initializer` and explicitly initializes the
-newborn object.
-When callers mutate structure between `step!` calls, refresh occurs before the
-next step.
-
-Build the initial registry explicitly so ownership remains visible:
-
-```julia
-model = CompositeModel(
- Object(:plant; scale=:Plant, status=Status(carbon=0.0)),
- Object(:leaf_1; scale=:Leaf, parent=:plant, status=Status(area=1.0));
- applications=(leaf_application, plant_balance, growth_application),
- environment=weather,
-)
-```
-
-The plant balance gathers leaf production with
-`Many(scale=:Leaf, within=Subtree())`. The growth kernel obtains the live model
-with `runtime_model(context)`, checks its carbon and thermal thresholds, creates
-a fully initialized `Object`, and calls `register_object!`. It should deduct
-the construction cost exactly once before registration.
-
-After each step, assert both biology and structure: remaining plant carbon,
-the number of leaf objects, each new leaf's parent, and accepted historical
-outputs. `Diagnostics.explain_applications` should show that the new leaf is absent
-during its creation step and present after the between-step refresh.
+Start with [Modify Plant Structure](@ref) for a runnable example of adding and
+removing leaves, or changing which object they belong to. This page explains
+how to connect those operations to a growth model and a carbon budget.
+
+## Connect organ creation to a resource budget
+
+Store the plant's carbon reserves on the plant object and each leaf's carbon
+production on that leaf. `Many(scale=:Leaf, within=Subtree())` lets the plant
+collect production from its own leaves. Before adding production to reserves,
+check what it represents: a rate, an amount produced during one interval, or
+a total accumulated since the start of the simulation. For an accumulated
+total, add only the increase since the last update. Adding the full total at
+every step would count the same carbon repeatedly.
+
+The growth model's `run!` function then follows this sequence:
+
+1. Read the available carbon and check whether the plant is ready to grow.
+2. If so, create the new organ's status with initial area, mass, and any
+ other values its models need.
+3. Subtract the construction cost once. Add the organ to the simulation with
+ a unique identity and the correct parent object.
+4. Check the carbon balance: the remaining reserves, the carbon in the new
+ organ, and any carbon spent on respiration must add up to the amount
+ available before growth.
+
+Use `register_object!` if your model has already created an `Object` with all
+its initial values. If you represent the plant with a MultiScaleTreeGraph
+(MTG), use `add_organ!` to create its MTG node, status, and simulation object
+together. Your growth model must still decide when an organ appears, what it
+costs, and how carbon is converted to dry matter.
+
+## Know when a newborn can run
+
+When a model adds, removes, or reparents an organ, PlantSimEngine updates
+which objects each model runs on and where their inputs come from
+**after that application finishes**. A new leaf can run models scheduled
+later in the same timestep. Models that already ran are not repeated.
+If the new leaf needs one of those earlier calculations immediately, the
+model creating it must declare an `Initializer` and call `run_initializer!`.
+
+If you change the plant structure between `step!` calls, these connections
+are updated before the next step. Neither case automatically undoes changes
+to the plant's values if something goes wrong.
+
+After creating an organ, check its parent, initial values, first saved result,
+and the plant's carbon budget. Use the working example in
+[Modify Plant Structure](@ref) to inspect the objects, then continue with
+[Adding Roots And Water](@ref) for a small resource-accounting example and
+[Debugging Growth And Resource Ordering](@ref) when execution order is unclear.
diff --git a/docs/src/tutorials/growing_plant/part2_roots_water.md b/docs/src/tutorials/growing_plant/part2_roots_water.md
index c15bd5bd5..3669d1f81 100644
--- a/docs/src/tutorials/growing_plant/part2_roots_water.md
+++ b/docs/src/tutorials/growing_plant/part2_roots_water.md
@@ -1,38 +1,89 @@
# Adding Roots And Water
-Add root objects and gather absorption through a plant-local `Many` selector.
-Keep shared carbon and water stocks on the plant, while leaf and root state
-remains object-local. Environment precipitation is an environment input; root
-creation is an explicit `register_object!` operation with initialized status.
-
-When several plants share one soil object, select it explicitly with a
-model-wide `One` selector rather than relying on traversal order.
-
-Keep stocks at the scale that owns conservation. A root model may publish an
-absorption rate per root, while the plant model integrates all root rates and
-updates one plant water stock. The example below assumes uptake per second
-and weights rates by their durations in seconds. A soil model owns soil water; plants read it
-through an explicit model-wide selector. This avoids copying one stock into
-every organ and makes duplicate writers visible.
-
-```julia
-ModelSpec(
- PlantWaterModel();
- inputs=(
- :root_uptake => Many(
- scale=:Root, within=Subtree(), application=:root_absorption,
- var=:uptake, policy=Integrate((values, durations_seconds) -> sum(values .* durations_seconds)), window=Day(1),
- ),
- :soil_water => One(
- scale=:Soil, within=SceneScope(), application=:soil_water,
- var=:water,
+This example adds the water taken up by two roots to one plant water stock.
+It shows how to count each amount once when roots update hourly and the plant
+updates daily. The uptake rates are constant teaching values, not predictions
+from a root-uptake equation. We assume that the water supply can provide
+these **already accepted** rates. The example does not calculate soil
+competition, transpiration, or a complete plant water balance.
+
+## Integrate each accepted interval once
+
+Rates are in g water s⁻¹ per root. The plant adds a day's uptake in g water
+once per day. Both `on=One(scale=:Plant)` and `every=Day(1)` are explicit:
+updating this stock hourly with a rolling one-day total would count overlapping
+intervals repeatedly.
+
+```@example root-water
+using Dates, PlantSimEngine
+
+PlantSimEngine.@process "docs_root_uptake" verbose=false
+PlantSimEngine.@process "docs_plant_water" verbose=false
+struct DocsRootUptake <: AbstractDocs_Root_UptakeModel end
+struct DocsPlantWater <: AbstractDocs_Plant_WaterModel end
+
+PlantSimEngine.inputs_(::DocsRootUptake) = (accepted_rate=Required(Real),)
+PlantSimEngine.outputs_(::DocsRootUptake) = (uptake=0.0,)
+function PlantSimEngine.run!(::DocsRootUptake, status, environment, constants, context)
+ status.uptake = status.accepted_rate
+ return nothing
+end
+
+PlantSimEngine.inputs_(::DocsPlantWater) = (root_uptake=Required(AbstractVector{<:Real}),)
+PlantSimEngine.outputs_(::DocsPlantWater) = (stored_water=0.0,)
+function PlantSimEngine.run!(::DocsPlantWater, status, environment, constants, context)
+ status.stored_water += sum(status.root_uptake)
+ return nothing
+end
+
+model = CompositeModel(
+ Object(:plant; scale=:Plant),
+ Object(:root_1; scale=:Root, parent=:plant, status=Status(accepted_rate=1e-4)),
+ Object(:root_2; scale=:Root, parent=:plant, status=Status(accepted_rate=2e-4));
+ applications=(
+ ModelSpec(DocsRootUptake(); name=:uptake, on=Many(scale=:Root), every=Hour(1)),
+ ModelSpec(
+ DocsPlantWater(); name=:water, on=One(scale=:Plant), every=Day(1),
+ inputs=(root_uptake=Many(
+ scale=:Root, within=Subtree(), application=:uptake, var=:uptake,
+ policy=Integrate((values, seconds) -> sum(values .* seconds)),
+ window=Day(1),
+ ),),
),
),
+ environment=(duration=Hour(1),),
)
+simulation = run!(model; steps=49, outputs=:all)
+water_history = [
+ (base_step=row.time, stored_water_g=row.value)
+ for row in collect_outputs(simulation; sink=nothing)
+ if row.object_id == :plant && row.variable == :stored_water
+]
+@assert isapprox(final_state(simulation, :plant).stored_water, 49 * (1e-4 + 2e-4) * 3600)
+water_history
```
-Precipitation, temperature, and radiation remain environment variables, not
-ordinary object outputs. Use `Environment(sources=...)` when provider column
-names differ from model-facing names. When growth creates a root, initialize
-all required root status values before `register_object!`; verify the next
-timestep's carrier with `Diagnostics.input_value` or `Diagnostics.explain_bindings`.
+The first daily calculation has only one hourly value available, so it adds
+`1.08` g. Each following daily calculation adds 24 new hourly amounts, or
+`25.92` g. The stock is therefore `1.08`, `27.0`, and `52.92` g at base steps
+1, 25, and 49. All 49 supplied hourly amounts are counted once. A model set
+to run daily still runs at the start, before a full day of values is available.
+
+These totals describe uptake added to storage, not tissue hydration or growth.
+
+## Share a limited soil water supply
+
+When several plants share a limited soil water supply, use one model to
+manage that stock. This model must collect all root demands, decide how much
+water each receives, subtract the total withdrawal once, and return the
+accepted rates or amounts to the plants. Simply letting each root read the
+same soil stock does not prevent them from taking too much water together.
+`Updates` can specify the order in which models change a value, but your
+equations must decide how to share the water.
+
+Supply rainfall through the environment and state its units before converting
+it to an amount of soil water. Add each loss and exchange when extending
+the plant balance. When growth adds a root, provide its initial values and
+add it with `register_object!` or, for an MTG, `add_organ!`. The plant's `Many`
+selection then includes that root after the creating application finishes.
+See [Growing A Plant CompositeModel](@ref).
diff --git a/docs/src/tutorials/growing_plant/part3_debugging.md b/docs/src/tutorials/growing_plant/part3_debugging.md
index eff0e2282..8c9b488e9 100644
--- a/docs/src/tutorials/growing_plant/part3_debugging.md
+++ b/docs/src/tutorials/growing_plant/part3_debugging.md
@@ -1,33 +1,58 @@
# Debugging Growth And Resource Ordering
-If an organ appears to spend resources before it exists, inspect activation
-timing and the compiled schedule. If two models intentionally update one stock,
-declare `Updates(:stock; after=:producer)`. If a parent must test several child
-states before accepting one, use `calls` and publish only the accepted call.
-
-For cycles, choose a scientific meaning: lag one edge with
-`PreviousTimeStep`, put convergence under a parent-owned hard call, or
-reformulate the equations. Do not resolve a cycle by incidental application
-ordering.
-
-Use this debugging order:
-
-1. `Diagnostics.explain_initialization(model)` for missing state or environment values.
-2. `Diagnostics.explain_bindings(model)` for source scope and multiplicity.
-3. `Diagnostics.explain_writers(model)` for competing canonical outputs.
-4. `Diagnostics.explain_calls(model)` for call-only targets and target cardinality.
-5. `Diagnostics.explain_schedule(model)` for cadence and root ordering.
-6. `Diagnostics.explain_outputs(simulation)` after execution for publication history.
-
-A trial call must not mutate accepted output history or scatter mutable
-environment outputs. Nested trials inherit the outer publication decision.
-Convergence and failure policy belongs to the parent model: it decides the
-iteration limit, tolerance, fallback, and whether any state is accepted.
-
-Structural mutation is also transactional at the timestep boundary. A new
-organ is registered immediately in the model registry but does not recursively
-run merely because it was created. A creator may explicitly initialize one
-newborn object through a declared `Initializer`; otherwise, compilation refreshes
-targets, carriers, calls, writer validation, schedules, and requested outputs at
-the safe barrier. Geometry-only movement refreshes only affected spatial
-bindings where possible.
+Use this page when a new organ receives the wrong inputs, spends a resource
+twice, or appears in outputs at an unexpected time. Begin with the runnable
+[Modify Plant Structure](@ref) example and change one mechanism at a time.
+
+## Follow one timestep
+
+First check whether a model created the organ during `run!`, or whether you
+added it between `step!` calls. During a step, PlantSimEngine updates the list
+of objects and their input connections after the creating application
+finishes. The new organ can then run models scheduled later in that step;
+models that already ran are not repeated. If you add the organ between steps,
+these connections are updated before the next step.
+
+An `Initializer` lets a model calculate a new organ's initial values without
+adding an extra saved result during the step. Adding an organ changes the
+object registry immediately. The later update to input connections does not
+automatically undo those changes, or changes to other model values, if a
+calculation fails.
+
+## Find the cause of the unexpected result
+
+| Symptom | First check | What to verify |
+|---|---|---|
+| A new organ has missing or invalid values | `Diagnostics.explain_initialization(model)` | Every required input has a meaningful initial value. |
+| One plant reads values from another plant's leaves | `Diagnostics.explain_bindings(model)` | `Subtree()` or another selector limits the search to the intended plant. |
+| Two models change the same stock | `Diagnostics.explain_writers(model)` | Only one model sets the value, or `Updates(:stock; after=:producer)` specifies the intended order. |
+| A called model runs unexpectedly | `Diagnostics.explain_calls(model)` | The controller selects the intended models and calls them only for the intended trials and accepted calculations. |
+| A day's uptake is added every hour | `Diagnostics.explain_schedule(model)` | The stock is updated only once for each amount of water taken up. |
+| A new organ's results start too early or too late | `Diagnostics.explain_outputs(simulation)` and `collect_outputs(simulation)` | The first result matches when the organ was created and which models could still run in that step. |
+
+For carbon or water, also check the balance directly: initial stock plus
+accepted inputs equals final stock plus losses and transfers. PlantSimEngine
+can check which models change a stock, but that check does not prove that your
+equations conserve the resource.
+[Adding Roots And Water](@ref) shows a two-day accounting check.
+
+## Keep trial and accepted state distinct
+
+If two models each need the other's result, decide what the equations require.
+Use `PreviousTimeStep` if one input should come from the preceding step. If
+both must be solved together in the current step, use a controller to repeat
+the calculations until the solution is acceptable. You may also need to
+rewrite the equations. Changing the order of the `ModelSpec` declarations
+does not solve this problem.
+
+Use `run_call!(context, name; publish=false)` for trials and `publish=true`
+only for the accepted result. `publish=false` keeps the trial out of the
+output history, but it does not undo changes to status values. The controller
+must prepare the values for each new trial and avoid permanently spending
+carbon or water on rejected trials. Save environment changes with
+`commit_environment!` only after accepting a solution.
+
+The controller must set the maximum number of trials, how close the solution
+must be, and what to do if no acceptable solution is found. The
+[MAESPA-Style Synthesis](@ref) shows repeated leaf calculations followed by
+an accepted canopy air update. It adds carbon only after accepting a solution.
diff --git a/docs/src/working_with_data/fitting.md b/docs/src/working_with_data/fitting.md
index 65f058af8..78c2b5417 100644
--- a/docs/src/working_with_data/fitting.md
+++ b/docs/src/working_with_data/fitting.md
@@ -1,11 +1,13 @@
# Parameter Fitting
-`PlantSimEngine.Evaluation.fit` is the shared interface for model-specific
-calibration.
-Model packages implement a method whose first argument is the model type and
-whose second argument is Tables.jl-compatible observations.
+Parameter fitting means finding parameter values that make a model agree
+with observations. PlantSimEngine provides `Evaluation.fit` as a common
+function name; each model package supplies the fitting method for its own
+models. Pass the model type first and a table of observations second.
+The table can be a DataFrame or another format supported by Tables.jl.
-The mathematical core of the Beer fit is:
+For the `Beer` model, we can calculate the light-extinction coefficient `k`
+from LAI, incoming light, and absorbed light:
```julia
J_to_umol = PlantMeteo.Constants().J_to_umol
@@ -14,16 +16,16 @@ f_abs = data.aPPFD ./ incident_ppfd
k = Statistics.mean(-log1p.(-f_abs) ./ data.LAI)
```
-This snippet shows the inversion only. The implementation in
+This snippet only shows how to rearrange Beer's equation to find `k`. The implementation in
`examples/Beer.jl` validates every observation and returns `(k=k,)`; do not use
the snippet alone as an unchecked fitting method.
-The result should be a `NamedTuple` of fitted parameters.
+A fitting method returns named parameter values, for example `(k=0.6,)`.
-In this Beer example, `Ri_PAR_f` is an incident flux per unit ground area and
-`aPPFD` is the flux absorbed by the whole canopy, also per unit ground area.
+In this Beer example, `Ri_PAR_f` measures incoming light per unit ground area
+and `aPPFD` measures light absorbed by the whole canopy per unit ground area.
`LAI` is leaf area per unit ground area. Do not use a PPFD expressed per unit
-leaf area in this inversion. The fit rejects empty data, non-finite or
+leaf area in this calculation. The fit rejects empty data, non-finite or
non-positive `LAI` and incident PAR, and absorbed fractions outside `[0, 1)`.
```@example fitting
diff --git a/docs/test/runtests.jl b/docs/test/runtests.jl
index 73b4f265c..9ff8d3d65 100644
--- a/docs/test/runtests.jl
+++ b/docs/test/runtests.jl
@@ -1,42 +1,20 @@
-using Test
-
-@testset "Progressive journey structure" begin
- journey_root = joinpath(@__DIR__, "..", "src", "journeys")
- user_pages = sort([
- joinpath(journey_root, "users", file)
- for file in readdir(joinpath(journey_root, "users"))
- if endswith(file, ".md")
- ])
- modeler_pages = sort([
- joinpath(journey_root, "modelers", file)
- for file in readdir(joinpath(journey_root, "modelers"))
- if endswith(file, ".md")
- ])
-
- for page in user_pages
- source = read(page, String)
- @test occursin("New concept", source)
- @test occursin("## Page recap", source)
- @test occursin("**You added:**", source)
- @test occursin("**PlantSimEngine infer", source)
- @test occursin("**You keep explicit:**", source)
- @test occursin("**New API names:**", source)
- @test !occursin("```julia", source)
- end
-
- for page in modeler_pages
- source = read(page, String)
- @test occursin("**New concept:**", source)
- @test occursin("## Model-author recap", source)
- @test occursin("**You implemented:**", source)
- @test occursin("**PlantSimEngine inferred:**", source)
- @test occursin("**The scenario author keeps explicit:**", source)
- @test occursin("**New API names:**", source)
- @test occursin("tested", lowercase(source))
- end
-end
+using Test, Markdown
+# Documenter executes the @example blocks, their numerical assertions, and
+# doctests. Avoid tests that prescribe headings or repeated editorial labels.
@testset "PlantSimEngine documentation" begin
ENV["PLANTSIMENGINE_DOCS_BUILD_ONLY"] = "true"
@test include(joinpath(@__DIR__, "..", "make.jl")) === nothing
end
+
+@testset "Generated Markdown keeps code and table structure" begin
+ writer = Base.get_extension(Bonito, :BonitoDocumenterExt)
+ markdown = Markdown.parse("```julia\nx = 1\ny = 2\n```\n\n| Variable | Value |\n| --- | --- |\n| LAI | 2 |")
+ root = convert(writer.MA.Node, markdown)
+ context = writer.DCtx(nothing, nothing, nothing, joinpath(@__DIR__, "..", "build"))
+ rendered = writer.domify(context, root)
+ html = repr(MIME"text/html"(), Bonito.DOM.div(rendered...))
+ @test occursin(" isfinite(x) && x >= zero(x), (leaf, wood)) ||
+ throw(ArgumentError("Allocation fractions must be finite and nonnegative."))
+ leaf + wood <= one(leaf) ||
+ throw(ArgumentError("Allocation fractions must sum to at most one."))
+ return new{typeof(leaf)}(leaf, wood)
+ end
+end
+
+"""
+ DemandAllocation()
+ DemandAllocation{Float32}()
+
+Allocate available carbon in proportion to leaf and wood demands, up to
+their combined demand. Keep any surplus in reserve. With no demand, keep
+all available carbon in reserve.
+
+This is a second uncalibrated hypothesis for the same `carbon_allocation`
+process as `FixedFractionAllocation`, with the same inputs, outputs, and
+units. The type parameter selects the initial output number type.
+"""
+struct DemandAllocation{T<:Real} <: AbstractCarbon_AllocationModel end
+DemandAllocation() = DemandAllocation{Float64}()
+
+const AllocationModel = Union{FixedFractionAllocation,DemandAllocation}
+
+PlantSimEngine.inputs_(::AllocationModel) = (
+ carbon_offer=Required(Real),
+ leaf_demand=Required(Real),
+ wood_demand=Required(Real),
+)
+
+function PlantSimEngine.outputs_(::Union{FixedFractionAllocation{T},DemandAllocation{T}}) where {T}
+ return (
+ leaf_growth=zero(T),
+ wood_growth=zero(T),
+ reserve_change=zero(T),
+ leaf_carbon=zero(T),
+ wood_carbon=zero(T),
+ reserve_carbon=zero(T),
+ )
+end
+
+PlantSimEngine.environment_inputs_(::AllocationModel) = NamedTuple()
+PlantSimEngine.environment_outputs_(::AllocationModel) = NamedTuple()
+PlantSimEngine.timestep_hint(::AllocationModel) = Day(1)
+
+const DAILY_CARBON = VariableContract(
+ unit=:g_carbon, basis=:plant, temporal=:day,
+ aggregation=:total, extent=:extensive,
+)
+const STORED_CARBON = VariableContract(
+ unit=:g_carbon, basis=:plant, temporal=:instantaneous,
+ aggregation=:state, extent=:extensive,
+)
+
+PlantSimEngine.variable_contracts_(::AllocationModel) = (
+ carbon_offer=DAILY_CARBON,
+ leaf_demand=DAILY_CARBON,
+ wood_demand=DAILY_CARBON,
+ leaf_growth=DAILY_CARBON,
+ wood_growth=DAILY_CARBON,
+ reserve_change=DAILY_CARBON,
+ leaf_carbon=STORED_CARBON,
+ wood_carbon=STORED_CARBON,
+ reserve_carbon=STORED_CARBON,
+)
+
+PlantSimEngine.Authoring.model_metadata(::FixedFractionAllocation) = (
+ hypothesis="Fixed fractions, capped by each organ's demand; unused shares go to reserve.",
+ reference=nothing,
+ maturity=:pedagogical_non_calibrated,
+ validation=:structural_tests_only,
+)
+PlantSimEngine.Authoring.model_metadata(::DemandAllocation) = (
+ hypothesis="Available carbon is shared in proportion to organ demands; surplus goes to reserve.",
+ reference=nothing,
+ maturity=:pedagogical_non_calibrated,
+ validation=:structural_tests_only,
+)
+PlantSimEngine.Authoring.parameter_metadata(::FixedFractionAllocation) = (
+ leaf_fraction=(
+ description="Fraction of available carbon offered to leaves before the demand cap.",
+ unit=:fraction, domain=(minimum=0, maximum=1),
+ ),
+ wood_fraction=(
+ description="Fraction of available carbon offered to wood before the demand cap.",
+ unit=:fraction, domain=(minimum=0, maximum=1),
+ ),
+)
+
+function _check_allocation_inputs(status)
+ for variable in (:carbon_offer, :leaf_demand, :wood_demand)
+ value = getproperty(status, variable)
+ isfinite(value) && value >= zero(value) || throw(ArgumentError(
+ "`$(variable)` must be finite and nonnegative in this teaching example.",
+ ))
+ end
+ return nothing
+end
+
+# Fixed-fraction allocation kernel
+function PlantSimEngine.run!(
+ model::FixedFractionAllocation,
+ status,
+ environment,
+ constants,
+ context,
+)
+ _check_allocation_inputs(status)
+ status.leaf_growth = min(model.leaf_fraction * status.carbon_offer, status.leaf_demand)
+ # The last bound prevents roundoff from creating a negative reserve.
+ status.wood_growth = min(
+ model.wood_fraction * status.carbon_offer,
+ status.wood_demand,
+ status.carbon_offer - status.leaf_growth,
+ )
+ status.reserve_change = status.carbon_offer - status.leaf_growth - status.wood_growth
+
+ status.leaf_carbon += status.leaf_growth
+ status.wood_carbon += status.wood_growth
+ status.reserve_carbon += status.reserve_change
+ return nothing
+end
+
+# Demand-based allocation kernel
+function PlantSimEngine.run!(
+ ::DemandAllocation,
+ status,
+ environment,
+ constants,
+ context,
+)
+ _check_allocation_inputs(status)
+ total_demand = status.leaf_demand + status.wood_demand
+ isfinite(total_demand) || throw(ArgumentError("Combined organ demand must be finite."))
+ growth = min(status.carbon_offer, total_demand)
+ status.leaf_growth = iszero(total_demand) ? zero(growth) :
+ min(status.leaf_demand, growth,
+ growth * (status.leaf_demand / total_demand))
+ # Keep the demand caps and carbon balance even after floating-point rounding.
+ status.wood_growth = min(status.wood_demand, growth - status.leaf_growth)
+ status.reserve_change = status.carbon_offer - status.leaf_growth - status.wood_growth
+
+ status.leaf_carbon += status.leaf_growth
+ status.wood_carbon += status.wood_growth
+ status.reserve_carbon += status.reserve_change
+ return nothing
+end
+
+"""Create a reusable configuration for one plant's allocation model."""
+allocation_template(model::AllocationModel) = CompositeModelTemplate((
+ ModelSpec(model; name=:allocation, on=One(scale=:Plant)),
+))
+
+"""Create one plant, optionally replacing the template's allocation model."""
+function allocation_plant(
+ name,
+ template;
+ carbon_offer=10.0,
+ leaf_demand=8.0,
+ wood_demand=2.0,
+ allocation=nothing,
+)
+ initial_values = Status(; carbon_offer, leaf_demand, wood_demand)
+ _check_allocation_inputs(initial_values)
+ return ObjectInstance(
+ name,
+ template;
+ root=Object(name; scale=:Plant, kind=:plant, status=initial_values),
+ overrides=isnothing(allocation) ? NamedTuple() : (allocation=allocation,),
+ )
+end
+
+"""
+ build_allocation_comparison(; fixed_model, demand_model, plant_a, plant_b)
+
+Build two plants in one `CompositeModel`. Both receive 10 g C per day and
+demands of 8 g C for leaves and 2 g C for wood by default. Override `plant_a`
+or `plant_b` with a named tuple of these inputs for an independent comparison.
+Each day supplies a new carbon offer and new demands; previous reserves are
+kept but cannot be withdrawn. Run with `run!(model; steps=1, outputs=:all)`.
+"""
+function build_allocation_comparison(;
+ fixed_model=FixedFractionAllocation(0.5, 0.3),
+ demand_model=DemandAllocation(),
+ plant_a=(carbon_offer=10.0, leaf_demand=8.0, wood_demand=2.0),
+ plant_b=(carbon_offer=10.0, leaf_demand=8.0, wood_demand=2.0),
+)
+ template = allocation_template(fixed_model)
+ return CompositeModel(
+ allocation_plant(:plant_a, template; plant_a...),
+ allocation_plant(:plant_b, template; plant_b..., allocation=demand_model);
+ environment=(duration=Day(1),),
+ )
+end
+
+end # module TwoPlantAllocationExample
diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl
index 5888db4b0..a5112127f 100644
--- a/src/PlantSimEngine.jl
+++ b/src/PlantSimEngine.jl
@@ -49,6 +49,7 @@ include("component_models/TimeStepTable.jl")
# Model application configuration:
include("ModelSpec.jl")
+include("composite_model/display.jl")
# Model evaluation (statistics):
include("evaluation/statistics.jl")
diff --git a/src/composite_model/display.jl b/src/composite_model/display.jl
new file mode 100644
index 000000000..03102e1de
--- /dev/null
+++ b/src/composite_model/display.jl
@@ -0,0 +1,103 @@
+# Display authored configuration only. Inspecting a model must not compile it,
+# initialize its status, sample the environment, or refresh lifecycle caches.
+function Base.show(io::IO, model::CompositeModel)
+ print(io, "CompositeModel(objects=", length(model.registry.objects),
+ ", applications=", length(model.applications))
+ isempty(model.instances) || print(io, ", instances=", length(model.instances))
+ print(io, ")")
+end
+
+function _model_display_line(io::IO, text::AbstractString)
+ width = max(1, displaysize(io)[2])
+ if textwidth(text) <= width
+ print(io, text)
+ return
+ end
+ used = 0
+ for character in text
+ used + textwidth(character) > width - 1 && break
+ print(io, character)
+ used += textwidth(character)
+ end
+ print(io, '…')
+end
+
+_model_display_label(value) = escape_string(string(value))
+
+function _model_display_scales(model::CompositeModel)
+ by_scale = model.registry.by_scale
+ scales = sort!(collect(keys(by_scale)); by=string)
+ labels = [
+ string(_model_display_label(scale), ": ", length(by_scale[scale]))
+ for scale in Iterators.take(scales, 6)
+ ]
+ length(scales) > 6 && push!(labels, "…")
+ unlabelled = length(model.registry.objects) - sum(length, values(by_scale); init=0)
+ unlabelled > 0 && push!(labels, string("no scale label: ", unlabelled))
+ return join(labels, ", ")
+end
+
+function _model_display_environment(environment)
+ isnothing(environment) && return "none"
+ name = string(nameof(typeof(environment)))
+ if environment isa TimeStepTable
+ return string(name, " (", length(environment), " rows)")
+ end
+ return name
+end
+
+function _model_display_application(application)
+ spec = application isa ModelSpec ? application : nothing
+ model = isnothing(spec) ? application : spec.model
+ base_model = model isa ObjectModelOverrides ? model.base : model
+ label = string(nameof(typeof(base_model)))
+ if !isnothing(spec) && !isnothing(spec.name)
+ label = string(_model_display_label(spec.name), ": ", label)
+ end
+ if model isa ObjectModelOverrides
+ count = length(model.overrides)
+ label *= string(" (", count, " object override", count == 1 ? "" : "s", ")")
+ end
+ if !isnothing(spec) && !isnothing(spec.timestep)
+ cadence = spec.timestep isa Dates.Period ? string(spec.timestep) :
+ string(nameof(typeof(spec.timestep)))
+ label *= string("; every ", cadence)
+ end
+ return label
+end
+
+function Base.show(io::IO, ::MIME"text/plain", model::CompositeModel)
+ if get(io, :compact, false)
+ show(io, model)
+ return
+ end
+ _model_display_line(io, "CompositeModel")
+ scales = _model_display_scales(model)
+ objects = string(" Objects: ", length(model.registry.objects))
+ isempty(scales) || (objects *= string(" (", scales, ")"))
+ print(io, '\n')
+ _model_display_line(io, objects)
+ if !isempty(model.instances)
+ print(io, '\n')
+ _model_display_line(io, string(" Template instances: ", length(model.instances)))
+ end
+ print(io, '\n')
+ _model_display_line(io, string(" Shared environment: ", _model_display_environment(model.environment)))
+ print(io, '\n')
+ _model_display_line(io, string(" Model applications: ", length(model.applications)))
+
+ # This is a summary even when :limit is false. In a small terminal, leave
+ # room for the header and the number of omitted applications.
+ header_lines = isempty(model.instances) ? 4 : 5
+ available = get(io, :limit, false) ? max(0, displaysize(io)[1] - header_lines - 1) : 8
+ shown = min(length(model.applications), 8, available)
+ for application in Iterators.take(model.applications, shown)
+ print(io, '\n')
+ _model_display_line(io, string(" ", _model_display_application(application)))
+ end
+ remaining = length(model.applications) - shown
+ if remaining > 0
+ print(io, '\n')
+ _model_display_line(io, string(" … ", remaining, " more"))
+ end
+end
diff --git a/src/composite_model/registry_topology.jl b/src/composite_model/registry_topology.jl
index 146bc38a4..b0a0849e9 100644
--- a/src/composite_model/registry_topology.jl
+++ b/src/composite_model/registry_topology.jl
@@ -25,6 +25,25 @@ end
ObjectId(id::ObjectId) = id
ObjectId(id::AbstractString) = ObjectId(Symbol(id))
+"""
+ Object(id; scale=nothing, kind=nothing, species=nothing, name=nothing,
+ parent=nothing, children=ObjectId[], geometry=nothing,
+ status=nothing, applications=())
+
+One simulated entity, such as a canopy, plant, leaf, or soil volume. `id` is
+its stable [`ObjectId`](@ref). Labels such as `scale`, `kind`, and `name` let
+selectors choose where models run; parent and child IDs describe topology.
+The scenario chooses these labels and relationships.
+
+Supply initial model values with [`Status`](@ref). Models can share fixed
+parameters while each object owns its changing state. `geometry` may hold
+the spatial information needed by an environment provider or visualization.
+
+Pass objects to [`CompositeModel`](@ref) with their model applications. For
+changes during a simulation, use [`register_object!`](@ref),
+`remove_object!`, and `reparent_object!` so the runtime can
+refresh affected connections.
+"""
mutable struct Object
id::ObjectId
scale::Union{Nothing,Symbol}
diff --git a/test/runtests.jl b/test/runtests.jl
index 5a83d8f3d..7dfec9138 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -38,6 +38,10 @@ else
include("test-model-object-id.jl")
end
+ @testset "CompositeModel display" begin
+ include("test-model-display.jl")
+ end
+
@testset "Composite Model/Object API stabilization" begin
include("test-model-api-stabilization.jl")
end
@@ -182,6 +186,10 @@ else
include("test-maespa-model-example.jl")
end
+ @testset "Two-plant allocation comparison" begin
+ include("test-two-plant-allocation.jl")
+ end
+
@testset "Status" begin
include("test-Status.jl")
end
diff --git a/test/test-maespa-model-example.jl b/test/test-maespa-model-example.jl
index e17baba45..21ee2c23f 100644
--- a/test/test-maespa-model-example.jl
+++ b/test/test-maespa-model-example.jl
@@ -260,8 +260,8 @@ end
@test low_wind.canopy_air_ms ≈ m.gbcan_min
@test isfinite(low_wind.soil_canopy_ms)
- meteo_above = Atmosphere(T=25.0, Rh=0.50, Wind=1.2, Ri_PAR_f=800.0, Ri_SW_f=400.0, duration=Dates.Hour(1))
- canopy_meteo = Atmosphere(T=25.0, Rh=0.50, Wind=1.2, P=meteo_above.P, Ri_PAR_f=800.0, Ri_SW_f=400.0, duration=Dates.Hour(1))
+ meteo_above = Atmosphere(T=25.0, Rh=0.50, Wind=1.2, Ri_PAR_f=200.0, Ri_SW_f=400.0, duration=Dates.Hour(1))
+ canopy_meteo = Atmosphere(T=25.0, Rh=0.50, Wind=1.2, P=meteo_above.P, Ri_PAR_f=200.0, Ri_SW_f=400.0, duration=Dates.Hour(1))
hot_fluxes = (rn=5000.0, lambda_e=-5000.0, a=0.0, lai=0.75, rad_interc=0.0)
hot = canopy_air_update(m, hot_fluxes, meteo_above, canopy_meteo, PlantMeteo.Constants())
@test hot.tair <= meteo_above.T + 10.0
@@ -276,3 +276,68 @@ end
@test meteo_above.T == 25.0
@test max(0.01, meteo_above.VPD) == meteo_above.VPD
end
+
+@testset "MAESPA illustrative radiation units" begin
+ constants = PlantMeteo.Constants()
+ weather = maespa_meteo(; nhours=73)
+ @test all(row -> 0 <= row.Ri_PAR_f <= row.Ri_SW_f, weather)
+ @test all(row -> row.Ri_PAR_f ≈ row.Ri_SW_f * constants.PAR_fraction, weather)
+
+ status = Status(leaf_Ra_SW_f=zeros(2), leaf_aPPFD=zeros(2), Ψₗ=zeros(2))
+ noon = weather[12]
+ _prepare_model_leaf_inputs!(status, noon, -0.2, constants)
+ @test status.leaf_Ra_SW_f == fill(noon.Ri_SW_f, 2)
+ @test status.leaf_aPPFD ≈ fill(noon.Ri_PAR_f * constants.J_to_umol, 2)
+ @test status.Ψₗ == fill(-0.2, 2)
+
+ # The supplied run constants, rather than a hidden default, own the conversion.
+ _prepare_model_leaf_inputs!(status, noon, -0.2, (J_to_umol=2.0,))
+ @test status.leaf_aPPFD == fill(2 * noon.Ri_PAR_f, 2)
+end
+
+@testset "MAESPA allocation accounts each carbon increment once" begin
+ status = _maespa_plant_status()
+ model = AllocA(0.35, 0.55)
+ for (cumulative, increment) in ((3.0, 3.0), (8.0, 5.0), (15.0, 7.0), (15.0, 0.0), (14.0, -1.0))
+ status.leaf_carbon[1] = cumulative
+ PlantSimEngine.run!(model, status, nothing, nothing, nothing)
+ @test status.leaf_carbon == [cumulative]
+ @test status.daily_growth ≈ increment
+ @test status.accounted_carbon ≈ cumulative
+ @test status.leaf_pool + status.wood_pool + status.reserve_pool ≈ cumulative
+ end
+end
+
+@testset "MAESPA three-day elemental carbon balance" begin
+ result = run_maespa_example(; nhours=73, check=true)
+ rows = collect_outputs(result.simulation; sink=nothing)
+ for (plant_id, leaf_ids) in (
+ (:plant_A, (:plant_A_leaf_1, :plant_A_leaf_2)),
+ (:plant_B, (:plant_B_leaf_1, :plant_B_leaf_2, :plant_B_leaf_3)),
+ )
+ state = model_status(result.model, plant_id)
+ # Independently integrate the 73 accepted assimilation samples for each
+ # leaf. Trial iterations must neither publish nor accumulate extra C.
+ accepted_carbon = sum(leaf_ids) do leaf_id
+ leaf = model_status(result.model, leaf_id)
+ assimilation = [row.value for row in rows if row.object_id == leaf_id && row.variable == :A]
+ @test length(assimilation) == 73
+ carbon = sum(assimilation) * leaf.leaf_area * 3600 * 12e-6
+ @test leaf.leaf_carbon ≈ carbon
+ carbon
+ end
+ @test sum(state.leaf_carbon) ≈ accepted_carbon
+ pool_carbon = state.leaf_pool + state.wood_pool + state.reserve_pool
+ @test pool_carbon ≈ state.accounted_carbon
+ pending_carbon = accepted_carbon - state.accounted_carbon
+ @test pool_carbon + pending_carbon ≈ accepted_carbon
+
+ history(variable) = [row.value for row in rows if row.object_id == plant_id && row.variable == variable]
+ accounted = history(:accounted_carbon)
+ growth = history(:daily_growth)
+ @test length(growth) == length(accounted) == 4
+ @test growth ≈ diff([0.0; accounted])
+ @test sum(growth) ≈ last(accounted)
+ @test history(:leaf_pool) .+ history(:wood_pool) .+ history(:reserve_pool) ≈ accounted
+ end
+end
diff --git a/test/test-model-display.jl b/test/test-model-display.jl
new file mode 100644
index 000000000..83265820b
--- /dev/null
+++ b/test/test-model-display.jl
@@ -0,0 +1,109 @@
+using Dates
+
+PlantSimEngine.@process "display_probe" verbose = false
+
+struct DisplayProbeModel{T} <: AbstractDisplay_ProbeModel
+ payload::T
+end
+
+PlantSimEngine.inputs_(::DisplayProbeModel) = NamedTuple()
+PlantSimEngine.outputs_(::DisplayProbeModel) = (count=0,)
+function PlantSimEngine.run!(::DisplayProbeModel, status, environment, constants, context)
+ status.count += 1
+ return nothing
+end
+
+# Neither model parameters nor user-defined environment displays belong in the
+# summary. A raw model must not be normalized through ModelSpec just to show it.
+struct DisplayUnreadable end
+Base.show(::IO, ::DisplayUnreadable) = error("Display must not read this value")
+
+@testset "CompositeModel display does not prepare or run the model" begin
+ model = CompositeModel(DisplayProbeModel(DisplayUnreadable()))
+ status = model_status(model, :scene)
+ revision = model.revision
+ @test isnothing(model.binding_cache)
+ @test repr(model) == "CompositeModel(objects=1, applications=1)"
+ display = repr(MIME"text/plain"(), model)
+ @test occursin("Objects: 1 (Scene: 1)", display)
+ @test occursin("DisplayProbeModel", display)
+ @test occursin("Shared environment: none", display)
+ @test !occursin("DisplayUnreadable", display)
+ @test isnothing(model.binding_cache)
+ @test isnothing(model.environment_binding_cache)
+ @test model.bindings_dirty
+ @test model.revision == revision
+ @test model_status(model, :scene) === status
+
+ simulation = run!(model; steps=1)
+ cache = model.binding_cache
+ environment_cache = model.environment_binding_cache
+ @test final_state(simulation).count == 1
+ @test repr(MIME"text/plain"(), model) == display
+ @test final_state(simulation).count == 1
+ @test model.binding_cache === cache
+ @test model.environment_binding_cache === environment_cache
+ @test !model.bindings_dirty
+
+ register_object!(model, Object(:leaf; scale=:Leaf))
+ dirty_cache = model.binding_cache
+ @test occursin("Objects: 2 (Leaf: 1, Scene: 1)", repr(MIME"text/plain"(), model))
+ @test model.bindings_dirty
+ @test model.binding_cache === dirty_cache
+
+ # This setup cannot compile (no selector), but should still be inspectable.
+ incomplete = CompositeModel(Object(:scene);
+ applications=(DisplayProbeModel(DisplayUnreadable()),),
+ environment=DisplayUnreadable())
+ @test occursin("Shared environment: DisplayUnreadable", repr(MIME"text/plain"(), incomplete))
+ @test isnothing(incomplete.binding_cache)
+ @test occursin("Objects: 0", repr(MIME"text/plain"(), CompositeModel()))
+end
+
+@testset "CompositeModel display summarizes configuration" begin
+ weather = read_weather(joinpath(pkgdir(PlantSimEngine), "examples/meteo_day.csv"))
+ template = CompositeModelTemplate((
+ ModelSpec(DisplayProbeModel(zeros(10_000)); name=:growth,
+ on=Many(scale=:Leaf), every=Hour(1)),
+ ))
+ plant = Object(:plant; scale=:Plant)
+ leaf = Object(:leaf; scale=:Leaf, parent=:plant)
+ model = CompositeModel(
+ ObjectInstance(:plant, template; root=plant, objects=(leaf,),
+ object_overrides=(Override(object=:leaf, application=:growth,
+ model=DisplayProbeModel(ones(10_000))),));
+ environment=weather,
+ )
+ display = repr(MIME"text/plain"(), model)
+ @test repr(model) == "CompositeModel(objects=2, applications=1, instances=1)"
+ @test occursin("Objects: 2 (Leaf: 1, Plant: 1)", display)
+ @test occursin("Template instances: 1", display)
+ @test occursin("TimeStepTable (365 rows)", display)
+ @test occursin("plant__growth: DisplayProbeModel (1 object override); every 1 hour", display)
+ @test !occursin("ObjectModelOverrides", display)
+ @test length(display) < 500
+ @test repr(MIME"text/plain"(), model; context=:compact => true) == repr(model)
+ @test count(repr(model), repr([model, model])) == 2
+end
+
+@testset "CompositeModel display stays small" begin
+ applications = [ModelSpec(DisplayProbeModel(zeros(100));
+ name=Symbol("growth_", index), on=Many(scale=:Leaf)) for index in 1:100]
+ model = CompositeModel((Object(index; scale=:Leaf) for index in 1:100)...;
+ applications=applications)
+ display = repr(MIME"text/plain"(), model)
+ @test occursin("Objects: 100 (Leaf: 100)", display)
+ @test occursin("Model applications: 100", display)
+ @test occursin("92 more", display)
+ @test length(split(display, '\n')) <= 13
+ @test length(display) < 1000
+
+ pushfirst!(model.applications, ModelSpec(DisplayProbeModel(0);
+ name=Symbol(repeat("叶", 50), "\nnext line"), on=Many(scale=:Leaf)))
+ small = sprint(show, MIME"text/plain"(), model;
+ context=(:limit => true, :displaysize => (10, 32)))
+ @test length(split(small, '\n')) <= 10
+ @test all(line -> textwidth(line) <= 32, split(small, '\n'))
+ @test occursin("…", small)
+ @test occursin("96 more", small)
+end
diff --git a/test/test-two-plant-allocation.jl b/test/test-two-plant-allocation.jl
new file mode 100644
index 000000000..64720191e
--- /dev/null
+++ b/test/test-two-plant-allocation.jl
@@ -0,0 +1,306 @@
+module TwoPlantAllocationTests
+
+using Test, PlantSimEngine, Dates
+
+include(joinpath(@__DIR__, "..", "examples", "two_plant_allocation.jl"))
+using .TwoPlantAllocationExample
+
+function allocation_status(offer, leaf_demand, wood_demand; initial=(0, 0, 0))
+ Status(
+ carbon_offer=offer,
+ leaf_demand=leaf_demand,
+ wood_demand=wood_demand,
+ leaf_growth=zero(offer),
+ wood_growth=zero(offer),
+ reserve_change=zero(offer),
+ leaf_carbon=oftype(offer, initial[1]),
+ wood_carbon=oftype(offer, initial[2]),
+ reserve_carbon=oftype(offer, initial[3]),
+ )
+end
+
+allocation_amounts(status) =
+ (status.leaf_growth, status.wood_growth, status.reserve_change)
+carbon_stocks(status) =
+ (status.leaf_carbon, status.wood_carbon, status.reserve_carbon)
+
+function calculate!(model, status)
+ PlantSimEngine.run!(model, status, NamedTuple(), nothing, nothing)
+ return status
+end
+
+@testset "Allocation equations and carbon conservation" begin
+ cases = (
+ (name="balanced supply", offer=10.0, leaf=8.0, wood=2.0,
+ fixed=(5.0, 2.0, 3.0), demand=(8.0, 2.0, 0.0)),
+ (name="scarce supply", offer=4.0, leaf=8.0, wood=2.0,
+ fixed=(2.0, 1.2, 0.8), demand=(3.2, 0.8, 0.0)),
+ (name="supply exceeds both demands", offer=20.0, leaf=3.0, wood=2.0,
+ fixed=(3.0, 2.0, 15.0), demand=(3.0, 2.0, 15.0)),
+ (name="no supply", offer=0.0, leaf=8.0, wood=2.0,
+ fixed=(0.0, 0.0, 0.0), demand=(0.0, 0.0, 0.0)),
+ (name="no demand", offer=10.0, leaf=0.0, wood=0.0,
+ fixed=(0.0, 0.0, 10.0), demand=(0.0, 0.0, 10.0)),
+ (name="wood demand only", offer=10.0, leaf=0.0, wood=2.0,
+ fixed=(0.0, 2.0, 8.0), demand=(0.0, 2.0, 8.0)),
+ )
+
+ for case in cases
+ @testset "$(case.name)" begin
+ for (model, expected) in (
+ (FixedFractionAllocation(0.5, 0.3), case.fixed),
+ (DemandAllocation(), case.demand),
+ )
+ initial = (11.0, 17.0, 23.0)
+ status = allocation_status(case.offer, case.leaf, case.wood; initial)
+ calculate!(model, status)
+
+ @test all(isapprox.(allocation_amounts(status), expected))
+ @test all(isapprox.(carbon_stocks(status), initial .+ expected))
+ @test sum(allocation_amounts(status)) ≈ case.offer
+ @test sum(carbon_stocks(status)) - sum(initial) ≈ case.offer
+ @test 0 <= status.leaf_growth <= case.leaf
+ @test 0 <= status.wood_growth <= case.wood
+ @test status.reserve_change >= 0
+ end
+ end
+ end
+end
+
+@testset "Allocation rejects invalid fractions and inputs" begin
+ for fractions in ((-0.1, 0.3), (0.5, -0.1), (0.8, 0.3), (1.1, 0.0),
+ (NaN, 0.3), (0.5, NaN), (Inf, 0.3), (0.5, Inf))
+ @test_throws ArgumentError FixedFractionAllocation(fractions...)
+ end
+
+ for model in (FixedFractionAllocation(0.5, 0.3), DemandAllocation())
+ for input in (:carbon_offer, :leaf_demand, :wood_demand)
+ for invalid in (-1.0, NaN, Inf, -Inf)
+ status = allocation_status(10.0, 8.0, 2.0)
+ calculate!(model, status)
+ saved_amounts = allocation_amounts(status)
+ saved_stocks = carbon_stocks(status)
+ setproperty!(status, input, invalid)
+ @test_throws ArgumentError calculate!(model, status)
+ @test allocation_amounts(status) == saved_amounts
+ @test carbon_stocks(status) == saved_stocks
+ end
+ end
+ end
+
+ overflow = allocation_status(10.0, floatmax(Float64), floatmax(Float64))
+ @test_throws ArgumentError calculate!(DemandAllocation(), overflow)
+ @test carbon_stocks(overflow) == (0.0, 0.0, 0.0)
+
+ template = allocation_template(FixedFractionAllocation(0.5, 0.3))
+ @test_throws ArgumentError allocation_plant(:invalid, template; carbon_offer=-1.0)
+ @test_throws ArgumentError allocation_plant(:invalid, template; leaf_demand=NaN)
+ @test_throws ArgumentError allocation_plant(:invalid, template; wood_demand=Inf)
+end
+
+@testset "Boundary allocation fractions remain valid" begin
+ for (fractions, expected) in (
+ ((0.0, 0.0), (0.0, 0.0, 10.0)),
+ ((1.0, 0.0), (8.0, 0.0, 2.0)),
+ ((0.0, 1.0), (0.0, 2.0, 8.0)),
+ )
+ status = allocation_status(10.0, 8.0, 2.0)
+ calculate!(FixedFractionAllocation(fractions...), status)
+ @test allocation_amounts(status) == expected
+ @test sum(carbon_stocks(status)) == 10.0
+ end
+end
+
+@testset "Roundoff preserves nonnegative allocation and demand caps" begin
+ cases = (
+ (FixedFractionAllocation(0.01f0, 0.99f0), 0.7f0, 10.0f0, 10.0f0),
+ (DemandAllocation{Float32}(), 0.3f0, 0.1f0, 0.2f0),
+ (DemandAllocation(), 1.0, 0.2, 0.1),
+ )
+ for (model, offer, leaf_demand, wood_demand) in cases
+ status = allocation_status(offer, leaf_demand, wood_demand)
+ calculate!(model, status)
+ # Exact inequalities catch a negative reserve or an allocation just
+ # above demand; approximate equality alone would hide these defects.
+ @test 0 <= status.leaf_growth <= leaf_demand
+ @test 0 <= status.wood_growth <= wood_demand
+ @test status.reserve_change >= 0
+ @test sum(allocation_amounts(status)) ≈ offer
+ @test sum(carbon_stocks(status)) ≈ offer
+ end
+end
+
+@testset "Repeated calculations replace step outputs and preserve stocks" begin
+ for model in (FixedFractionAllocation(0.5, 0.3), DemandAllocation())
+ status = allocation_status(10.0, 8.0, 2.0)
+ calculate!(model, status)
+ saved_stocks = carbon_stocks(status)
+
+ status.carbon_offer = 0.0
+ calculate!(model, status)
+ @test allocation_amounts(status) == (0.0, 0.0, 0.0)
+ @test carbon_stocks(status) == saved_stocks
+
+ status.carbon_offer = 4.0
+ status.leaf_demand = 0.0
+ status.wood_demand = 0.0
+ calculate!(model, status)
+ @test allocation_amounts(status) == (0.0, 0.0, 4.0)
+ @test carbon_stocks(status) == saved_stocks .+ (0.0, 0.0, 4.0)
+ @test sum(carbon_stocks(status)) ≈ 14.0
+ end
+end
+
+@testset "Float32 allocation and alternative-model declarations" begin
+ fixed = FixedFractionAllocation(0.5f0, 0.3f0)
+ demand = DemandAllocation{Float32}()
+ @test fixed isa PlantSimEngine.Examples.AbstractCarbon_AllocationModel
+ @test demand isa PlantSimEngine.Examples.AbstractCarbon_AllocationModel
+ @test typeof(fixed) != typeof(demand)
+ @test process(fixed) == process(demand) == :carbon_allocation
+
+ comparison = Authoring.compare_models(fixed, demand)
+ @test comparison.same_process
+ @test comparison.override_compatible
+ @test !comparison.requires_binding_changes
+
+ for (model, expected) in ((fixed, (5.0f0, 2.0f0, 3.0f0)),
+ (demand, (8.0f0, 2.0f0, 0.0f0)))
+ status = allocation_status(10.0f0, 8.0f0, 2.0f0)
+ calculate!(model, status)
+ @test allocation_amounts(status) == expected
+ @test all(value -> value isa Float32, allocation_amounts(status))
+ @test all(value -> value isa Float32, carbon_stocks(status))
+ @test Authoring.validate_model(model; strict=true).valid
+ end
+end
+
+@testset "Daily carbon offers cannot be reapplied every hour" begin
+ for model in (FixedFractionAllocation(0.5, 0.3), DemandAllocation())
+ hourly = CompositeModel(model;
+ status=(carbon_offer=10.0, leaf_demand=8.0, wood_demand=2.0),
+ environment=(duration=Hour(1),))
+ @test_throws "timestep_hint.required=1 day" run!(hourly; steps=1)
+ end
+end
+
+@testset "Two plants compare different allocation models in one simulation" begin
+ scenario = build_allocation_comparison()
+ @test scenario isa CompositeModel
+ @test Set(object_ids(scenario)) == Set((ObjectId(:plant_a), ObjectId(:plant_b)))
+ @test Authoring.validate_scenario(scenario; strict=true).valid
+
+ applications = Diagnostics.explain_applications(scenario)
+ application_a = only(row for row in applications if row.application_id == :plant_a__allocation)
+ application_b = only(row for row in applications if row.application_id == :plant_b__allocation)
+ @test length(applications) == 2
+ @test application_a.process == application_b.process
+ @test application_a.model_type <: FixedFractionAllocation
+ @test application_b.model_type <: DemandAllocation
+ @test application_a.target_ids == [:plant_a]
+ @test application_b.target_ids == [:plant_b]
+
+ simulation = run!(scenario; outputs=:all)
+ state_a = final_state(simulation, :plant_a)
+ state_b = final_state(simulation, :plant_b)
+ @test current_step(simulation) == 1
+ @test allocation_amounts(state_a) == (5.0, 2.0, 3.0)
+ @test allocation_amounts(state_b) == (8.0, 2.0, 0.0)
+ @test carbon_stocks(state_a) == (5.0, 2.0, 3.0)
+ @test carbon_stocks(state_b) == (8.0, 2.0, 0.0)
+ @test sum(carbon_stocks(state_a)) + sum(carbon_stocks(state_b)) == 20.0
+
+ continue!(simulation; steps=2)
+ @test current_step(simulation) == 3
+ @test carbon_stocks(final_state(simulation, :plant_a)) == (15.0, 6.0, 9.0)
+ @test carbon_stocks(final_state(simulation, :plant_b)) == (24.0, 6.0, 0.0)
+
+ # With no new carbon, plant A must stop growing without reusing yesterday's
+ # outputs. Plant B still receives its own unchanged supply and demands.
+ status_a = model_status(scenario, :plant_a)
+ status_b = model_status(scenario, :plant_b)
+ status_a.carbon_offer = 0.0
+ @test (status_b.carbon_offer, status_b.leaf_demand, status_b.wood_demand) == (10.0, 8.0, 2.0)
+ step!(simulation)
+ @test current_step(simulation) == 4
+ @test allocation_amounts(final_state(simulation, :plant_a)) == (0.0, 0.0, 0.0)
+ @test carbon_stocks(final_state(simulation, :plant_a)) == (15.0, 6.0, 9.0)
+ @test carbon_stocks(final_state(simulation, :plant_b)) == (32.0, 8.0, 0.0)
+
+ # Carbon offered to a plant with no growth demand goes entirely to reserve.
+ status_a.carbon_offer = 4.0
+ status_a.leaf_demand = 0.0
+ status_a.wood_demand = 0.0
+ continue!(simulation; steps=2)
+ @test current_step(simulation) == 6
+ @test allocation_amounts(final_state(simulation, :plant_a)) == (0.0, 0.0, 4.0)
+ @test allocation_amounts(final_state(simulation, :plant_b)) == (8.0, 2.0, 0.0)
+ @test carbon_stocks(final_state(simulation, :plant_a)) == (15.0, 6.0, 17.0)
+ @test carbon_stocks(final_state(simulation, :plant_b)) == (48.0, 12.0, 0.0)
+ @test (status_b.carbon_offer, status_b.leaf_demand, status_b.wood_demand) == (10.0, 8.0, 2.0)
+
+ rows = collect_outputs(simulation; sink=nothing)
+ expected = (
+ plant_a=(
+ leaf_growth=[5.0, 5.0, 5.0, 0.0, 0.0, 0.0],
+ wood_growth=[2.0, 2.0, 2.0, 0.0, 0.0, 0.0],
+ reserve_change=[3.0, 3.0, 3.0, 0.0, 4.0, 4.0],
+ leaf_carbon=[5.0, 10.0, 15.0, 15.0, 15.0, 15.0],
+ wood_carbon=[2.0, 4.0, 6.0, 6.0, 6.0, 6.0],
+ reserve_carbon=[3.0, 6.0, 9.0, 9.0, 13.0, 17.0],
+ ),
+ plant_b=(
+ leaf_growth=fill(8.0, 6),
+ wood_growth=fill(2.0, 6),
+ reserve_change=zeros(6),
+ leaf_carbon=[8.0, 16.0, 24.0, 32.0, 40.0, 48.0],
+ wood_carbon=[2.0, 4.0, 6.0, 8.0, 10.0, 12.0],
+ reserve_carbon=zeros(6),
+ ),
+ )
+ @test length(rows) == 2 * 6 * 6
+ for (plant_id, plant_expected) in pairs(expected)
+ application_id = Symbol(plant_id, "__allocation")
+ for (variable, expected_values) in pairs(plant_expected)
+ stream = filter(row -> row.object_id == plant_id && row.variable == variable, rows)
+ @test getproperty.(stream, :application_id) == fill(application_id, 6)
+ @test getproperty.(stream, :timestep) == collect(1:6)
+ @test getproperty.(stream, :value) ≈ expected_values
+ end
+ end
+
+ cumulative_offers = (
+ plant_a=[10.0, 20.0, 30.0, 30.0, 34.0, 38.0],
+ plant_b=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
+ )
+ stock_variables = (:leaf_carbon, :wood_carbon, :reserve_carbon)
+ for timestep in 1:6
+ scene_carbon = 0.0
+ for (plant_id, supplied) in pairs(cumulative_offers)
+ stored_carbon = sum(
+ row.value for row in rows
+ if row.object_id == plant_id && row.timestep == timestep &&
+ row.variable in stock_variables
+ )
+ @test stored_carbon ≈ supplied[timestep]
+ scene_carbon += stored_carbon
+ end
+ @test scene_carbon ≈ cumulative_offers.plant_a[timestep] + cumulative_offers.plant_b[timestep]
+ end
+end
+
+@testset "Float32 survives plant templates and model replacement" begin
+ daily_inputs = (carbon_offer=10.0f0, leaf_demand=8.0f0, wood_demand=2.0f0)
+ scenario = build_allocation_comparison(
+ fixed_model=FixedFractionAllocation(0.5f0, 0.3f0),
+ demand_model=DemandAllocation{Float32}(),
+ plant_a=daily_inputs,
+ plant_b=daily_inputs,
+ )
+ simulation = run!(scenario; steps=2, outputs=:all)
+ @test carbon_stocks(final_state(simulation, :plant_a)) == (10.0f0, 4.0f0, 6.0f0)
+ @test carbon_stocks(final_state(simulation, :plant_b)) == (16.0f0, 4.0f0, 0.0f0)
+ @test all(row -> row.value isa Float32, collect_outputs(simulation; sink=nothing))
+end
+
+end # module TwoPlantAllocationTests